forked from home-assistant/core
Compare commits
152 Commits
2023.1.0b2
...
2023.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a4329aa1d | ||
|
|
aa7e051538 | ||
|
|
82a13740b3 | ||
|
|
8dd0752bd0 | ||
|
|
58beab1b59 | ||
|
|
2c127c00d4 | ||
|
|
67f7a9ea78 | ||
|
|
15a35004dd | ||
|
|
d935f9400d | ||
|
|
c5fb3e7fab | ||
|
|
669e6202ad | ||
|
|
6a7e6ad0fd | ||
|
|
5656129b60 | ||
|
|
96578f3f89 | ||
|
|
4138e518ef | ||
|
|
aa43acb443 | ||
|
|
b459261ef2 | ||
|
|
a318576c4f | ||
|
|
9a6aaea9db | ||
|
|
627ded42f5 | ||
|
|
fa09eba165 | ||
|
|
fcf53668c5 | ||
|
|
d61b915286 | ||
|
|
b0153c7deb | ||
|
|
2447e24677 | ||
|
|
502fea5f95 | ||
|
|
caa8f9e49b | ||
|
|
8beb043d62 | ||
|
|
cb27cfe7dd | ||
|
|
1d5ecdd4ea | ||
|
|
3bb9be2382 | ||
|
|
6581bad7ce | ||
|
|
197634503f | ||
|
|
32fc0e03a5 | ||
|
|
2e9ea0c934 | ||
|
|
856f68252b | ||
|
|
2789747b0f | ||
|
|
45d14739c5 | ||
|
|
d0f95d84b4 | ||
|
|
1e852e761c | ||
|
|
c3859f9170 | ||
|
|
6df4fc6708 | ||
|
|
b297b78086 | ||
|
|
4bdf87d383 | ||
|
|
e47364f34d | ||
|
|
fe7d32dc5d | ||
|
|
62a003a053 | ||
|
|
b5d1421dfd | ||
|
|
ebab2bd0f9 | ||
|
|
e7babb4266 | ||
|
|
1a042c2dad | ||
|
|
731ca046f6 | ||
|
|
c844276e95 | ||
|
|
9f9cdb62eb | ||
|
|
c73830439f | ||
|
|
940b5d62b4 | ||
|
|
b3454bfd9c | ||
|
|
834847988d | ||
|
|
caf15534bb | ||
|
|
10cb2e31c4 | ||
|
|
85c9f9facf | ||
|
|
5ff7b3bb1a | ||
|
|
e5ba423d6d | ||
|
|
b30d4ef7cf | ||
|
|
00e563f1b8 | ||
|
|
cf06f3b81d | ||
|
|
a781fcca86 | ||
|
|
764550f2e1 | ||
|
|
7396bcc585 | ||
|
|
7e6b087773 | ||
|
|
71ce7373a3 | ||
|
|
33bb9c230b | ||
|
|
f0f2c12d91 | ||
|
|
2840821594 | ||
|
|
edfd83c3a7 | ||
|
|
ee88f34a91 | ||
|
|
fa4c250001 | ||
|
|
59d6f827c3 | ||
|
|
26ea02aa8f | ||
|
|
d73b86132b | ||
|
|
8034faadca | ||
|
|
3c2b7c0d69 | ||
|
|
563ad02c65 | ||
|
|
fe89b663e7 | ||
|
|
dcd07d3135 | ||
|
|
8bf2299407 | ||
|
|
9c689d757c | ||
|
|
4e4fc1767f | ||
|
|
cc3c5772c5 | ||
|
|
6ba6991ecd | ||
|
|
d52d068469 | ||
|
|
09b3611a63 | ||
|
|
ab2f05d3e9 | ||
|
|
90ac0c870f | ||
|
|
0fd113db59 | ||
|
|
1b43323f5e | ||
|
|
6108e581b1 | ||
|
|
c8c68f05ec | ||
|
|
b80467dc58 | ||
|
|
6e9f0eca03 | ||
|
|
cc6a2f0338 | ||
|
|
6ebf2ec9ec | ||
|
|
9ecee11af6 | ||
|
|
9a1669103b | ||
|
|
368ea0586d | ||
|
|
4a7db6ee71 | ||
|
|
a10b9572c7 | ||
|
|
0b47bf1f0b | ||
|
|
5f4d286556 | ||
|
|
b23ab3c65a | ||
|
|
7c199b36f8 | ||
|
|
d4e55ee030 | ||
|
|
f3ec82543e | ||
|
|
4013d4c48d | ||
|
|
93ac908776 | ||
|
|
2ad1a53038 | ||
|
|
3ba59fbebe | ||
|
|
f3fab5c1f5 | ||
|
|
2d120cb6ba | ||
|
|
ad782166c7 | ||
|
|
bc9202cf02 | ||
|
|
0d385d3b67 | ||
|
|
76fa24aba1 | ||
|
|
95ae37cd87 | ||
|
|
bc1d22f4ec | ||
|
|
67e1872ab6 | ||
|
|
516c2b0cdb | ||
|
|
60f067b68f | ||
|
|
ff76567061 | ||
|
|
93488cfa0f | ||
|
|
9655619667 | ||
|
|
32736b3336 | ||
|
|
c77b78928e | ||
|
|
a7ba242f1f | ||
|
|
043d58d697 | ||
|
|
6408890543 | ||
|
|
c5f7d7ae85 | ||
|
|
7ab27cd9bf | ||
|
|
9932c0cb91 | ||
|
|
565d4f85c1 | ||
|
|
7be60d4569 | ||
|
|
a50622cbfd | ||
|
|
fb41b024c0 | ||
|
|
80ac4c0269 | ||
|
|
0e0677b690 | ||
|
|
50d9e3efe6 | ||
|
|
ca28006d76 | ||
|
|
ac3711e6ab | ||
|
|
5901964bf6 | ||
|
|
b24c40f2df | ||
|
|
2cb7a80f98 | ||
|
|
f05de2b28c |
@@ -947,8 +947,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/remote/ @home-assistant/core
|
||||
/homeassistant/components/renault/ @epenet
|
||||
/tests/components/renault/ @epenet
|
||||
/homeassistant/components/reolink/ @starkillerOG @JimStar
|
||||
/tests/components/reolink/ @starkillerOG @JimStar
|
||||
/homeassistant/components/reolink/ @starkillerOG
|
||||
/tests/components/reolink/ @starkillerOG
|
||||
/homeassistant/components/repairs/ @home-assistant/core
|
||||
/tests/components/repairs/ @home-assistant/core
|
||||
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
|
||||
@@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weather/ @home-assistant/core
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
/tests/components/webhook/ @home-assistant/core
|
||||
/homeassistant/components/webostv/ @bendavid @thecode
|
||||
/tests/components/webostv/ @bendavid @thecode
|
||||
/homeassistant/components/webostv/ @thecode
|
||||
/tests/components/webostv/ @thecode
|
||||
/homeassistant/components/websocket_api/ @home-assistant/core
|
||||
/tests/components/websocket_api/ @home-assistant/core
|
||||
/homeassistant/components/wemo/ @esev
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@@ -11,22 +11,45 @@ WORKDIR /usr/src
|
||||
COPY requirements.txt homeassistant/
|
||||
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
|
||||
RUN \
|
||||
pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
|
||||
-r homeassistant/requirements.txt --use-deprecated=legacy-resolver
|
||||
pip3 install \
|
||||
--no-cache-dir \
|
||||
--no-index \
|
||||
--only-binary=:all: \
|
||||
--find-links "${WHEELS_LINKS}" \
|
||||
--use-deprecated=legacy-resolver \
|
||||
-r homeassistant/requirements.txt
|
||||
|
||||
COPY requirements_all.txt home_assistant_frontend-* homeassistant/
|
||||
RUN \
|
||||
if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \
|
||||
pip3 install --no-cache-dir --no-index homeassistant/home_assistant_frontend-*.whl; \
|
||||
pip3 install \
|
||||
--no-cache-dir \
|
||||
--no-index \
|
||||
homeassistant/home_assistant_frontend-*.whl; \
|
||||
fi \
|
||||
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
|
||||
-r homeassistant/requirements_all.txt --use-deprecated=legacy-resolver
|
||||
&& \
|
||||
LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \
|
||||
MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \
|
||||
pip3 install \
|
||||
--no-cache-dir \
|
||||
--no-index \
|
||||
--only-binary=:all: \
|
||||
--find-links "${WHEELS_LINKS}" \
|
||||
--use-deprecated=legacy-resolver \
|
||||
-r homeassistant/requirements_all.txt
|
||||
|
||||
## Setup Home Assistant Core
|
||||
COPY . homeassistant/
|
||||
RUN \
|
||||
pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
|
||||
-e ./homeassistant --use-deprecated=legacy-resolver \
|
||||
&& python3 -m compileall homeassistant/homeassistant
|
||||
pip3 install \
|
||||
--no-cache-dir \
|
||||
--no-index \
|
||||
--only-binary=:all: \
|
||||
--find-links "${WHEELS_LINKS}" \
|
||||
--use-deprecated=legacy-resolver \
|
||||
-e ./homeassistant \
|
||||
&& python3 -m compileall \
|
||||
homeassistant/homeassistant
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "AdGuard Home",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/adguard",
|
||||
"requirements": ["adguardhome==0.5.1"],
|
||||
"requirements": ["adguardhome==0.6.1"],
|
||||
"codeowners": ["@frenck"],
|
||||
"iot_class": "local_polling",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
@@ -117,36 +118,6 @@ def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_pro_config_entry_by_ip_address(
|
||||
hass: HomeAssistant, ip_address: str
|
||||
) -> ConfigEntry:
|
||||
"""Get the Pro config entry related to an IP address."""
|
||||
[config_entry] = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN_AIRVISUAL_PRO)
|
||||
if entry.data[CONF_IP_ADDRESS] == ip_address
|
||||
]
|
||||
return config_entry
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_pro_device_by_config_entry(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dr.DeviceEntry:
|
||||
"""Get the Pro device entry related to a config entry.
|
||||
|
||||
Note that a Pro config entry can only contain a single device.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
[device_entry] = [
|
||||
device_entry
|
||||
for device_entry in device_registry.devices.values()
|
||||
if config_entry.entry_id in device_entry.config_entries
|
||||
]
|
||||
return device_entry
|
||||
|
||||
|
||||
@callback
|
||||
def async_sync_geo_coordinator_update_intervals(
|
||||
hass: HomeAssistant, api_key: str
|
||||
@@ -306,14 +277,31 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
version = 3
|
||||
|
||||
if entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_NODE_PRO:
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
ip_address = entry.data[CONF_IP_ADDRESS]
|
||||
|
||||
# Get the existing Pro device entry before it is removed by the migration:
|
||||
old_device_entry = async_get_pro_device_by_config_entry(hass, entry)
|
||||
# Store the existing Pro device before the migration removes it:
|
||||
old_device_entry = next(
|
||||
entry
|
||||
for entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Store the existing Pro entity entries (mapped by unique ID) before the
|
||||
# migration removes it:
|
||||
old_entity_entries: dict[str, er.RegistryEntry] = {
|
||||
entry.unique_id: entry
|
||||
for entry in er.async_entries_for_device(
|
||||
entity_registry, old_device_entry.id, include_disabled_entities=True
|
||||
)
|
||||
}
|
||||
|
||||
# Remove this config entry and create a new one under the `airvisual_pro`
|
||||
# domain:
|
||||
new_entry_data = {**entry.data}
|
||||
new_entry_data.pop(CONF_INTEGRATION_TYPE)
|
||||
|
||||
tasks = [
|
||||
hass.config_entries.async_remove(entry.entry_id),
|
||||
hass.config_entries.flow.async_init(
|
||||
@@ -324,18 +312,52 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# After the migration has occurred, grab the new config and device entries
|
||||
# (now under the `airvisual_pro` domain):
|
||||
new_config_entry = next(
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN_AIRVISUAL_PRO)
|
||||
if entry.data[CONF_IP_ADDRESS] == ip_address
|
||||
)
|
||||
new_device_entry = next(
|
||||
entry
|
||||
for entry in dr.async_entries_for_config_entry(
|
||||
device_registry, new_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Update the new device entry with any customizations from the old one:
|
||||
device_registry.async_update_device(
|
||||
new_device_entry.id,
|
||||
area_id=old_device_entry.area_id,
|
||||
disabled_by=old_device_entry.disabled_by,
|
||||
name_by_user=old_device_entry.name_by_user,
|
||||
)
|
||||
|
||||
# Update the new entity entries with any customizations from the old ones:
|
||||
for new_entity_entry in er.async_entries_for_device(
|
||||
entity_registry, new_device_entry.id, include_disabled_entities=True
|
||||
):
|
||||
if old_entity_entry := old_entity_entries.get(
|
||||
new_entity_entry.unique_id
|
||||
):
|
||||
entity_registry.async_update_entity(
|
||||
new_entity_entry.entity_id,
|
||||
area_id=old_entity_entry.area_id,
|
||||
device_class=old_entity_entry.device_class,
|
||||
disabled_by=old_entity_entry.disabled_by,
|
||||
hidden_by=old_entity_entry.hidden_by,
|
||||
icon=old_entity_entry.icon,
|
||||
name=old_entity_entry.name,
|
||||
new_entity_id=old_entity_entry.entity_id,
|
||||
unit_of_measurement=old_entity_entry.unit_of_measurement,
|
||||
)
|
||||
|
||||
# If any automations are using the old device ID, create a Repairs issues
|
||||
# with instructions on how to update it:
|
||||
if device_automations := automation.automations_with_device(
|
||||
hass, old_device_entry.id
|
||||
):
|
||||
new_config_entry = async_get_pro_config_entry_by_ip_address(
|
||||
hass, ip_address
|
||||
)
|
||||
new_device_entry = async_get_pro_device_by_config_entry(
|
||||
hass, new_config_entry
|
||||
)
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.node import (
|
||||
@@ -33,13 +34,24 @@ STEP_USER_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_credentials(ip_address: str, password: str) -> dict[str, Any]:
|
||||
"""Validate an IP address/password combo (and return any errors as appropriate)."""
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Define a validation result."""
|
||||
|
||||
serial_number: str | None = None
|
||||
errors: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
async def async_validate_credentials(
|
||||
ip_address: str, password: str
|
||||
) -> ValidationResult:
|
||||
"""Validate an IP address/password combo."""
|
||||
node = NodeSamba(ip_address, password)
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
await node.async_connect()
|
||||
measurements = await node.async_get_latest_measurements()
|
||||
except InvalidAuthenticationError as err:
|
||||
LOGGER.error("Invalid password for Pro at IP address %s: %s", ip_address, err)
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -52,10 +64,12 @@ async def async_validate_credentials(ip_address: str, password: str) -> dict[str
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
LOGGER.exception("Unknown error while connecting to %s: %s", ip_address, err)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return ValidationResult(serial_number=measurements["serial_number"])
|
||||
finally:
|
||||
await node.async_disconnect()
|
||||
|
||||
return errors
|
||||
return ValidationResult(errors=errors)
|
||||
|
||||
|
||||
class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -89,11 +103,15 @@ class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
assert self._reauth_entry
|
||||
|
||||
if errors := await async_validate_credentials(
|
||||
validation_result = await async_validate_credentials(
|
||||
self._reauth_entry.data[CONF_IP_ADDRESS], user_input[CONF_PASSWORD]
|
||||
):
|
||||
)
|
||||
|
||||
if validation_result.errors:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm", data_schema=STEP_REAUTH_SCHEMA, errors=errors
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
errors=validation_result.errors,
|
||||
)
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
@@ -113,14 +131,18 @@ class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
ip_address = user_input[CONF_IP_ADDRESS]
|
||||
|
||||
await self.async_set_unique_id(ip_address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if errors := await async_validate_credentials(
|
||||
validation_result = await async_validate_credentials(
|
||||
ip_address, user_input[CONF_PASSWORD]
|
||||
):
|
||||
)
|
||||
|
||||
if validation_result.errors:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors=validation_result.errors,
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(validation_result.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=ip_address, data=user_input)
|
||||
|
||||
@@ -47,6 +47,7 @@ async def verify_redirect_uri(
|
||||
if client_id == "https://home-assistant.io/android" and redirect_uri in (
|
||||
"homeassistant://auth-callback",
|
||||
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android",
|
||||
"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android",
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[0]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -78,6 +79,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[1]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.10.4"],
|
||||
"requirements": ["bimmer_connected==0.12.0"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -44,8 +44,6 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self.client: BraviaTV | None = None
|
||||
self.device_config: dict[str, Any] = {}
|
||||
self.entry: ConfigEntry | None = None
|
||||
self.client_id: str = ""
|
||||
self.nickname: str = ""
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -62,8 +60,13 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
self.client = BraviaTV(host=host, session=session)
|
||||
|
||||
async def async_create_device(self) -> FlowResult:
|
||||
"""Initialize and create Bravia TV device from config."""
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
uuid = await instance_id.async_get(self.hass)
|
||||
return uuid, f"{NICKNAME_PREFIX} {uuid[:6]}"
|
||||
|
||||
async def async_connect_device(self) -> None:
|
||||
"""Connect to Bravia TV device from config."""
|
||||
assert self.client
|
||||
|
||||
pin = self.device_config[CONF_PIN]
|
||||
@@ -72,13 +75,16 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if use_psk:
|
||||
await self.client.connect(psk=pin)
|
||||
else:
|
||||
self.device_config[CONF_CLIENT_ID] = self.client_id
|
||||
self.device_config[CONF_NICKNAME] = self.nickname
|
||||
await self.client.connect(
|
||||
pin=pin, clientid=self.client_id, nickname=self.nickname
|
||||
)
|
||||
client_id = self.device_config[CONF_CLIENT_ID]
|
||||
nickname = self.device_config[CONF_NICKNAME]
|
||||
await self.client.connect(pin=pin, clientid=client_id, nickname=nickname)
|
||||
await self.client.set_wol_mode(True)
|
||||
|
||||
async def async_create_device(self) -> FlowResult:
|
||||
"""Create Bravia TV device from config."""
|
||||
assert self.client
|
||||
await self.async_connect_device()
|
||||
|
||||
system_info = await self.client.get_system_info()
|
||||
cid = system_info[ATTR_CID].lower()
|
||||
title = system_info[ATTR_MODEL]
|
||||
@@ -90,6 +96,16 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_create_entry(title=title, data=self.device_config)
|
||||
|
||||
async def async_reauth_device(self) -> FlowResult:
|
||||
"""Reauthorize Bravia TV device from config."""
|
||||
assert self.entry
|
||||
assert self.client
|
||||
await self.async_connect_device()
|
||||
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=self.device_config)
|
||||
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -100,28 +116,51 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
host = user_input[CONF_HOST]
|
||||
if is_host_valid(host):
|
||||
self.device_config[CONF_HOST] = host
|
||||
self.create_client()
|
||||
return await self.async_step_authorize()
|
||||
|
||||
errors[CONF_HOST] = "invalid_host"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}),
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Authorize Bravia TV device."""
|
||||
"""Handle authorize step."""
|
||||
self.create_client()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
if user_input[CONF_USE_PSK]:
|
||||
return await self.async_step_psk()
|
||||
return await self.async_step_pin()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="authorize",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_pin(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle PIN authorize step."""
|
||||
errors: dict[str, str] = {}
|
||||
self.client_id, self.nickname = await self.gen_instance_ids()
|
||||
client_id, nickname = await self.gen_instance_ids()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_PIN] = user_input[CONF_PIN]
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
self.device_config[CONF_CLIENT_ID] = client_id
|
||||
self.device_config[CONF_NICKNAME] = nickname
|
||||
try:
|
||||
if self.entry:
|
||||
return await self.async_reauth_device()
|
||||
return await self.async_create_device()
|
||||
except BraviaTVAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -133,16 +172,44 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
assert self.client
|
||||
|
||||
try:
|
||||
await self.client.pair(self.client_id, self.nickname)
|
||||
await self.client.pair(client_id, nickname)
|
||||
except BraviaTVError:
|
||||
return self.async_abort(reason="no_ip_control")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="authorize",
|
||||
step_id="pin",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN, default=""): str,
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
vol.Required(CONF_PIN): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_psk(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle PSK authorize step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_PIN] = user_input[CONF_PIN]
|
||||
try:
|
||||
if self.entry:
|
||||
return await self.async_reauth_device()
|
||||
return await self.async_create_device()
|
||||
except BraviaTVAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except BraviaTVNotSupported:
|
||||
errors["base"] = "unsupported_model"
|
||||
except BraviaTVError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="psk",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
@@ -181,7 +248,6 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Allow the user to confirm adding the device."""
|
||||
if user_input is not None:
|
||||
self.create_client()
|
||||
return await self.async_step_authorize()
|
||||
|
||||
return self.async_show_form(step_id="confirm")
|
||||
@@ -190,59 +256,7 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle configuration by re-auth."""
|
||||
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
self.device_config = {**entry_data}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
self.create_client()
|
||||
client_id, nickname = await self.gen_instance_ids()
|
||||
|
||||
assert self.client is not None
|
||||
assert self.entry is not None
|
||||
|
||||
if user_input is not None:
|
||||
pin = user_input[CONF_PIN]
|
||||
use_psk = user_input[CONF_USE_PSK]
|
||||
try:
|
||||
if use_psk:
|
||||
await self.client.connect(psk=pin)
|
||||
else:
|
||||
self.device_config[CONF_CLIENT_ID] = client_id
|
||||
self.device_config[CONF_NICKNAME] = nickname
|
||||
await self.client.connect(
|
||||
pin=pin, clientid=client_id, nickname=nickname
|
||||
)
|
||||
await self.client.set_wol_mode(True)
|
||||
except BraviaTVError:
|
||||
return self.async_abort(reason="reauth_unsuccessful")
|
||||
else:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.entry, data={**self.device_config, **user_input}
|
||||
)
|
||||
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
try:
|
||||
await self.client.pair(client_id, nickname)
|
||||
except BraviaTVError:
|
||||
return self.async_abort(reason="reauth_unsuccessful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN, default=""): str,
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
uuid = await instance_id.async_get(self.hass)
|
||||
return uuid, f"{NICKNAME_PREFIX} {uuid[:6]}"
|
||||
return await self.async_step_authorize()
|
||||
|
||||
|
||||
class BraviaTVOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "braviatv",
|
||||
"name": "Sony Bravia TV",
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"requirements": ["pybravia==0.2.3"],
|
||||
"requirements": ["pybravia==0.2.5"],
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -9,21 +9,27 @@
|
||||
},
|
||||
"authorize": {
|
||||
"title": "Authorize Sony Bravia TV",
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check «Use PSK authentication» box and enter your PSK instead of PIN.",
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"use_psk": "Use PSK authentication"
|
||||
}
|
||||
},
|
||||
"pin": {
|
||||
"title": "Authorize Sony Bravia TV",
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device.",
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
}
|
||||
},
|
||||
"psk": {
|
||||
"title": "Authorize Sony Bravia TV",
|
||||
"description": "To set up PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Set «Authentication» to «Normal and Pre-Shared Key» or «Pre-Shared Key» and define your Pre-Shared-Key string (e.g. sony). \n\nThen enter your PSK here.",
|
||||
"data": {
|
||||
"pin": "PSK"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check «Use PSK authentication» box and enter your PSK instead of PIN.",
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"use_psk": "Use PSK authentication"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -36,8 +42,7 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_ip_control": "IP Control is disabled on your TV or the TV is not supported.",
|
||||
"not_bravia_device": "The device is not a Bravia TV.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"already_configured": "Device is already configured",
|
||||
"no_ip_control": "IP Control is disabled on your TV or the TV is not supported.",
|
||||
"not_bravia_device": "The device is not a Bravia TV.",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again."
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
@@ -16,21 +15,27 @@
|
||||
"step": {
|
||||
"authorize": {
|
||||
"data": {
|
||||
"pin": "PIN Code",
|
||||
"use_psk": "Use PSK authentication"
|
||||
},
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check \u00abUse PSK authentication\u00bb box and enter your PSK instead of PIN.",
|
||||
"description": "Make sure that \u00abControl remotely\u00bb is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"confirm": {
|
||||
"description": "Do you want to start setup?"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"pin": {
|
||||
"data": {
|
||||
"pin": "PIN Code",
|
||||
"use_psk": "Use PSK authentication"
|
||||
"pin": "PIN Code"
|
||||
},
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check \u00abUse PSK authentication\u00bb box and enter your PSK instead of PIN."
|
||||
"description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"psk": {
|
||||
"data": {
|
||||
"pin": "PSK"
|
||||
},
|
||||
"description": "To set up PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Set \u00abAuthentication\u00bb to \u00abNormal and Pre-Shared Key\u00bb or \u00abPre-Shared Key\u00bb and define your Pre-Shared-Key string (e.g. sony). \n\nThen enter your PSK here.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb"
|
||||
}
|
||||
],
|
||||
"requirements": ["bthome-ble==2.4.0"],
|
||||
"requirements": ["bthome-ble==2.4.1"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@Ernst79"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for WebDav Calendar."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -185,8 +185,8 @@ class WebDavCalendarData:
|
||||
event_list.append(
|
||||
CalendarEvent(
|
||||
summary=self.get_attr_value(vevent, "summary") or "",
|
||||
start=vevent.dtstart.value,
|
||||
end=self.get_end_date(vevent),
|
||||
start=self.to_local(vevent.dtstart.value),
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
location=self.get_attr_value(vevent, "location"),
|
||||
description=self.get_attr_value(vevent, "description"),
|
||||
)
|
||||
@@ -269,8 +269,8 @@ class WebDavCalendarData:
|
||||
)
|
||||
self.event = CalendarEvent(
|
||||
summary=summary,
|
||||
start=vevent.dtstart.value,
|
||||
end=self.get_end_date(vevent),
|
||||
start=self.to_local(vevent.dtstart.value),
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
location=self.get_attr_value(vevent, "location"),
|
||||
description=self.get_attr_value(vevent, "description"),
|
||||
)
|
||||
@@ -308,15 +308,23 @@ class WebDavCalendarData:
|
||||
def to_datetime(obj):
|
||||
"""Return a datetime."""
|
||||
if isinstance(obj, datetime):
|
||||
if obj.tzinfo is None:
|
||||
# floating value, not bound to any time zone in particular
|
||||
# represent same time regardless of which time zone is currently being observed
|
||||
return obj.replace(tzinfo=dt.DEFAULT_TIME_ZONE)
|
||||
return obj
|
||||
return WebDavCalendarData.to_local(obj)
|
||||
return dt.dt.datetime.combine(obj, dt.dt.time.min).replace(
|
||||
tzinfo=dt.DEFAULT_TIME_ZONE
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_local(obj: datetime | date) -> datetime | date:
|
||||
"""Return a datetime as a local datetime, leaving dates unchanged.
|
||||
|
||||
This handles giving floating times a timezone for comparison
|
||||
with all day events and dropping the custom timezone object
|
||||
used by the caldav client and dateutil so the datetime can be copied.
|
||||
"""
|
||||
if isinstance(obj, datetime):
|
||||
return dt.as_local(obj)
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def get_attr_value(obj, attribute):
|
||||
"""Return the value of the attribute if defined."""
|
||||
|
||||
@@ -174,7 +174,10 @@ async def async_get_trigger_capabilities(
|
||||
if trigger_type == "hvac_mode_changed":
|
||||
return {
|
||||
"extra_fields": vol.Schema(
|
||||
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
|
||||
{
|
||||
vol.Required(state_trigger.CONF_TO): vol.In(const.HVAC_MODES),
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from pycfdns.exceptions import (
|
||||
CloudflareAuthenticationException,
|
||||
CloudflareConnectionException,
|
||||
CloudflareException,
|
||||
CloudflareZoneException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -47,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
zone_id = await cfupdate.get_zone_id()
|
||||
except CloudflareAuthenticationException as error:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
except CloudflareConnectionException as error:
|
||||
except (CloudflareConnectionException, CloudflareZoneException) as error:
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
async def update_records(now):
|
||||
|
||||
@@ -75,7 +75,6 @@ async def async_setup(hass):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "config/device_registry/update",
|
||||
vol.Optional("aliases"): list,
|
||||
vol.Optional("area_id"): vol.Any(str, None),
|
||||
vol.Required("device_id"): str,
|
||||
# We only allow setting disabled_by user via API.
|
||||
@@ -96,10 +95,6 @@ def websocket_update_device(
|
||||
msg.pop("type")
|
||||
msg_id = msg.pop("id")
|
||||
|
||||
if "aliases" in msg:
|
||||
# Convert aliases to a set
|
||||
msg["aliases"] = set(msg["aliases"])
|
||||
|
||||
if msg.get("disabled_by") is not None:
|
||||
msg["disabled_by"] = DeviceEntryDisabler(msg["disabled_by"])
|
||||
|
||||
@@ -165,7 +160,6 @@ async def websocket_remove_config_entry_from_device(
|
||||
def _entry_dict(entry):
|
||||
"""Convert entry to API format."""
|
||||
return {
|
||||
"aliases": entry.aliases,
|
||||
"area_id": entry.area_id,
|
||||
"configuration_url": entry.configuration_url,
|
||||
"config_entries": list(entry.config_entries),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "deCONZ",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/deconz",
|
||||
"requirements": ["pydeconz==105"],
|
||||
"requirements": ["pydeconz==106"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -21,7 +21,6 @@ from .devolo_device import DevoloDeviceEntity
|
||||
DEVICE_CLASS_MAPPING = {
|
||||
"battery": SensorDeviceClass.BATTERY,
|
||||
"temperature": SensorDeviceClass.TEMPERATURE,
|
||||
"light": SensorDeviceClass.ILLUMINANCE,
|
||||
"humidity": SensorDeviceClass.HUMIDITY,
|
||||
"current": SensorDeviceClass.POWER,
|
||||
"total": SensorDeviceClass.ENERGY,
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
UnitOfEnergy,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import CoreState, Event, HomeAssistant, callback
|
||||
@@ -401,7 +402,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
@Throttle(min_time_between_updates)
|
||||
def update_entities_telegram(telegram: dict[str, DSMRObject]) -> None:
|
||||
def update_entities_telegram(telegram: dict[str, DSMRObject] | None) -> None:
|
||||
"""Update entities with latest telegram and trigger state update."""
|
||||
# Make all device entities aware of new telegram
|
||||
for entity in entities:
|
||||
@@ -445,6 +446,11 @@ async def async_setup_entry(
|
||||
|
||||
while hass.state == CoreState.not_running or hass.is_running:
|
||||
# Start DSMR asyncio.Protocol reader
|
||||
|
||||
# Reflect connected state in devices state by setting an
|
||||
# empty telegram resulting in `unknown` states
|
||||
update_entities_telegram({})
|
||||
|
||||
try:
|
||||
transport, protocol = await hass.loop.create_task(reader_factory())
|
||||
|
||||
@@ -472,8 +478,8 @@ async def async_setup_entry(
|
||||
protocol = None
|
||||
|
||||
# Reflect disconnect state in devices state by setting an
|
||||
# empty telegram resulting in `unknown` states
|
||||
update_entities_telegram({})
|
||||
# None telegram resulting in `unavailable` states
|
||||
update_entities_telegram(None)
|
||||
|
||||
# throttle reconnect attempts
|
||||
await asyncio.sleep(
|
||||
@@ -487,11 +493,19 @@ async def async_setup_entry(
|
||||
transport = None
|
||||
protocol = None
|
||||
|
||||
# Reflect disconnect state in devices state by setting an
|
||||
# None telegram resulting in `unavailable` states
|
||||
update_entities_telegram(None)
|
||||
|
||||
# throttle reconnect attempts
|
||||
await asyncio.sleep(
|
||||
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
|
||||
)
|
||||
except CancelledError:
|
||||
# Reflect disconnect state in devices state by setting an
|
||||
# None telegram resulting in `unavailable` states
|
||||
update_entities_telegram(None)
|
||||
|
||||
if stop_listener and (
|
||||
hass.state == CoreState.not_running or hass.is_running
|
||||
):
|
||||
@@ -534,7 +548,7 @@ class DSMREntity(SensorEntity):
|
||||
"""Initialize entity."""
|
||||
self.entity_description = entity_description
|
||||
self._entry = entry
|
||||
self.telegram: dict[str, DSMRObject] = {}
|
||||
self.telegram: dict[str, DSMRObject] | None = {}
|
||||
|
||||
device_serial = entry.data[CONF_SERIAL_ID]
|
||||
device_name = DEVICE_NAME_ELECTRICITY
|
||||
@@ -551,16 +565,21 @@ class DSMREntity(SensorEntity):
|
||||
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
|
||||
|
||||
@callback
|
||||
def update_data(self, telegram: dict[str, DSMRObject]) -> None:
|
||||
def update_data(self, telegram: dict[str, DSMRObject] | None) -> None:
|
||||
"""Update data."""
|
||||
self.telegram = telegram
|
||||
if self.hass and self.entity_description.obis_reference in self.telegram:
|
||||
if self.hass and (
|
||||
telegram is None or self.entity_description.obis_reference in telegram
|
||||
):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_dsmr_object_attr(self, attribute: str) -> str | None:
|
||||
"""Read attribute from last received telegram for this DSMR object."""
|
||||
# Make sure telegram contains an object for this entities obis
|
||||
if self.entity_description.obis_reference not in self.telegram:
|
||||
if (
|
||||
self.telegram is None
|
||||
or self.entity_description.obis_reference not in self.telegram
|
||||
):
|
||||
return None
|
||||
|
||||
# Get the attribute value if the object has it
|
||||
@@ -568,6 +587,26 @@ class DSMREntity(SensorEntity):
|
||||
attr: str | None = getattr(dsmr_object, attribute)
|
||||
return attr
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Entity is only available if there is a telegram."""
|
||||
return self.telegram is not None
|
||||
|
||||
@property
|
||||
def device_class(self) -> SensorDeviceClass | None:
|
||||
"""Return the device class of this entity."""
|
||||
device_class = super().device_class
|
||||
|
||||
# Override device class for gas sensors providing energy units, like
|
||||
# kWh, MWh, GJ, etc. In those cases, the class should be energy, not gas
|
||||
with suppress(ValueError):
|
||||
if device_class == SensorDeviceClass.GAS and UnitOfEnergy(
|
||||
str(self.native_unit_of_measurement)
|
||||
):
|
||||
return SensorDeviceClass.ENERGY
|
||||
|
||||
return device_class
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of sensor, if available, translate if needed."""
|
||||
|
||||
@@ -560,8 +560,8 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/consumption/quarter-hour-peak-electricity/average_delivered",
|
||||
name="Previous quarter-hour peak usage",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/consumption/quarter-hour-peak-electricity/read_at_start",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Rheem EcoNet Products",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||
"requirements": ["pyeconet==0.1.15"],
|
||||
"requirements": ["pyeconet==0.1.18"],
|
||||
"codeowners": ["@vangorra", "@w1ll1am23"],
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "emulated_kasa",
|
||||
"name": "Emulated Kasa",
|
||||
"documentation": "https://www.home-assistant.io/integrations/emulated_kasa",
|
||||
"requirements": ["sense_energy==0.11.0"],
|
||||
"requirements": ["sense_energy==0.11.1"],
|
||||
"codeowners": ["@kbickar"],
|
||||
"quality_scale": "internal",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -41,20 +41,20 @@ SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
}
|
||||
VALID_ENERGY_UNITS: set[str] = {
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
}
|
||||
VALID_ENERGY_UNITS_GAS = {
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
*VALID_ENERGY_UNITS,
|
||||
}
|
||||
VALID_VOLUME_UNITS_WATER: set[str] = {
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
UnitOfVolume.GALLONS,
|
||||
UnitOfVolume.LITERS,
|
||||
|
||||
@@ -22,10 +22,10 @@ from .const import DOMAIN
|
||||
ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
)
|
||||
}
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
@@ -39,12 +39,16 @@ GAS_USAGE_DEVICE_CLASSES = (
|
||||
)
|
||||
GAS_USAGE_UNITS = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
sensor.SensorDeviceClass.GAS: (
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
),
|
||||
sensor.SensorDeviceClass.GAS: (UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET),
|
||||
}
|
||||
GAS_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in GAS_USAGE_UNITS.values() for unit in units
|
||||
@@ -54,8 +58,9 @@ GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price"
|
||||
WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,)
|
||||
WATER_USAGE_UNITS = {
|
||||
sensor.SensorDeviceClass.WATER: (
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
UnitOfVolume.GALLONS,
|
||||
UnitOfVolume.LITERS,
|
||||
),
|
||||
|
||||
@@ -228,7 +228,6 @@ AQHI_SENSOR = ECSensorEntityDescription(
|
||||
key="aqhi",
|
||||
name="AQHI",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
native_unit_of_measurement="AQI",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=_get_aqhi_value,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"zeroconf": ["_esphomelib._tcp.local."],
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"codeowners": ["@OttoWinter", "@jesserockz"],
|
||||
"after_dependencies": ["bluetooth", "zeroconf", "tag"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"after_dependencies": ["zeroconf", "tag"],
|
||||
"iot_class": "local_push",
|
||||
"integration_type": "device",
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20221228.0"],
|
||||
"requirements": ["home-assistant-frontend==20230110.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -47,10 +47,19 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
for target in call.data[ATTR_DEVICE_ID]:
|
||||
device = registry.async_get(target)
|
||||
if device:
|
||||
coordinator = hass.data[DOMAIN][list(device.config_entries)[0]]
|
||||
# fully_method(coordinator.fully, *args, **kwargs) would make
|
||||
# test_services.py fail.
|
||||
await getattr(coordinator.fully, fully_method.__name__)(*args, **kwargs)
|
||||
for key in device.config_entries:
|
||||
entry = hass.config_entries.async_get_entry(key)
|
||||
if not entry:
|
||||
continue
|
||||
if entry.domain != DOMAIN:
|
||||
continue
|
||||
coordinator = hass.data[DOMAIN][key]
|
||||
# fully_method(coordinator.fully, *args, **kwargs) would make
|
||||
# test_services.py fail.
|
||||
await getattr(coordinator.fully, fully_method.__name__)(
|
||||
*args, **kwargs
|
||||
)
|
||||
break
|
||||
|
||||
async def async_load_url(call: ServiceCall) -> None:
|
||||
"""Load a URL on the Fully Kiosk Browser."""
|
||||
|
||||
@@ -221,8 +221,7 @@ async def async_setup_entry(
|
||||
)
|
||||
if (
|
||||
search := data.get(CONF_SEARCH)
|
||||
or calendar_item.access_role == AccessRole.FREE_BUSY_READER
|
||||
):
|
||||
) or calendar_item.access_role == AccessRole.FREE_BUSY_READER:
|
||||
coordinator = CalendarQueryUpdateCoordinator(
|
||||
hass,
|
||||
calendar_service,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
||||
"requirements": ["gcal-sync==4.1.0", "oauth2client==4.1.3"],
|
||||
"requirements": ["gcal-sync==4.1.2", "oauth2client==4.1.3"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"]
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""application_credentials platform for Google Assistant SDK."""
|
||||
import oauth2client
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -8,8 +6,8 @@ from homeassistant.core import HomeAssistant
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
oauth2client.GOOGLE_AUTH_URI,
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Helper classes for Google Assistant SDK integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -12,6 +14,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
|
||||
from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LANGUAGE_CODES = {
|
||||
"de": "de-DE",
|
||||
"en": "en-US",
|
||||
@@ -39,7 +43,8 @@ async def async_send_text_commands(commands: list[str], hass: HomeAssistant) ->
|
||||
language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass))
|
||||
with TextAssistant(credentials, language_code) as assistant:
|
||||
for command in commands:
|
||||
assistant.assist(command)
|
||||
text_response = assistant.assist(command)[0]
|
||||
_LOGGER.debug("command: %s\nresponse: %s", command, text_response)
|
||||
|
||||
|
||||
def default_language_code(hass: HomeAssistant):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/",
|
||||
"requirements": ["gassist-text==0.0.5"],
|
||||
"requirements": ["gassist-text==0.0.7"],
|
||||
"codeowners": ["@tronikos"],
|
||||
"iot_class": "cloud_polling",
|
||||
"integration_type": "service"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""application_credentials platform for Google Sheets."""
|
||||
import oauth2client
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -8,17 +6,15 @@ from homeassistant.core import HomeAssistant
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
oauth2client.GOOGLE_AUTH_URI,
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
)
|
||||
|
||||
|
||||
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return description placeholders for the credentials dialog."""
|
||||
return {
|
||||
"oauth_consent_url": (
|
||||
"https://console.cloud.google.com/apis/credentials/consent"
|
||||
),
|
||||
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
|
||||
"more_info_url": "https://www.home-assistant.io/integrations/google_sheets/",
|
||||
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"connectable": false
|
||||
}
|
||||
],
|
||||
"requirements": ["govee-ble==0.21.0"],
|
||||
"requirements": ["govee-ble==0.21.1"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@bdraco", "@PierreAronnax"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -22,7 +22,7 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise growatt server flow."""
|
||||
self.api = growattServer.GrowattApi()
|
||||
self.api = None
|
||||
self.user_id = None
|
||||
self.data = {}
|
||||
|
||||
@@ -46,6 +46,10 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not user_input:
|
||||
return self._async_show_user_form()
|
||||
|
||||
# Initialise the library with the username & a random id each time it is started
|
||||
self.api = growattServer.GrowattApi(
|
||||
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
|
||||
)
|
||||
self.api.server_url = user_input[CONF_URL]
|
||||
login_response = await self.hass.async_add_executor_job(
|
||||
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
|
||||
@@ -80,14 +80,8 @@ async def async_setup_entry(
|
||||
config[CONF_URL] = url
|
||||
hass.config_entries.async_update_entry(config_entry, data=config)
|
||||
|
||||
# Initialise the library with a random user id each time it is started,
|
||||
# also extend the library's default identifier to include 'home-assistant'
|
||||
api = growattServer.GrowattApi(
|
||||
add_random_user_id=True,
|
||||
agent_identifier=(
|
||||
f"{growattServer.GrowattApi.agent_identifier} - home-assistant"
|
||||
),
|
||||
)
|
||||
# Initialise the library with the username & a random id each time it is started
|
||||
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
|
||||
api.server_url = url
|
||||
|
||||
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
|
||||
|
||||
@@ -70,6 +70,7 @@ def api_error(
|
||||
class AddonInfo:
|
||||
"""Represent the current add-on info state."""
|
||||
|
||||
available: bool
|
||||
hostname: str | None
|
||||
options: dict[str, Any]
|
||||
state: AddonState
|
||||
@@ -144,6 +145,7 @@ class AddonManager:
|
||||
self._logger.debug("Add-on store info: %s", addon_store_info)
|
||||
if not addon_store_info["installed"]:
|
||||
return AddonInfo(
|
||||
available=addon_store_info["available"],
|
||||
hostname=None,
|
||||
options={},
|
||||
state=AddonState.NOT_INSTALLED,
|
||||
@@ -154,6 +156,7 @@ class AddonManager:
|
||||
addon_info = await async_get_addon_info(self._hass, self.addon_slug)
|
||||
addon_state = self.async_get_addon_state(addon_info)
|
||||
return AddonInfo(
|
||||
available=addon_info["available"],
|
||||
hostname=addon_info["hostname"],
|
||||
options=addon_info["options"],
|
||||
state=addon_state,
|
||||
@@ -184,6 +187,11 @@ class AddonManager:
|
||||
@api_error("Failed to install the {addon_name} add-on")
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
if not addon_info.available:
|
||||
raise AddonError(f"{self.addon_name} add-on is not available anymore")
|
||||
|
||||
await async_install_addon(self._hass, self.addon_slug)
|
||||
|
||||
@api_error("Failed to uninstall the {addon_name} add-on")
|
||||
@@ -196,6 +204,9 @@ class AddonManager:
|
||||
"""Update the managed add-on if needed."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
if not addon_info.available:
|
||||
raise AddonError(f"{self.addon_name} add-on is not available anymore")
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
raise AddonError(f"{self.addon_name} add-on is not installed")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import USER_AGENT
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -160,7 +159,7 @@ class HaveIBeenPwnedData:
|
||||
"""Get the latest data for current email from REST service."""
|
||||
try:
|
||||
url = f"{URL}{self._email}?truncateResponse=false"
|
||||
header = {USER_AGENT: HA_USER_AGENT, "hibp-api-key": self._api_key}
|
||||
header = {"User-Agent": HA_USER_AGENT, "hibp-api-key": self._api_key}
|
||||
_LOGGER.debug("Checking for breaches for email: %s", self._email)
|
||||
req = requests.get(url, headers=header, allow_redirects=True, timeout=5)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
get_zigbee_socket,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -25,12 +25,10 @@ from .util import get_usb_service_info
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _multi_pan_addon_info(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> AddonInfo | None:
|
||||
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
|
||||
async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Wait for multi-PAN info to be available."""
|
||||
if not is_hassio(hass):
|
||||
return None
|
||||
return
|
||||
|
||||
addon_manager: AddonManager = get_addon_manager(hass)
|
||||
try:
|
||||
@@ -50,7 +48,18 @@ async def _multi_pan_addon_info(
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
|
||||
async def _multi_pan_addon_info(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> AddonInfo | None:
|
||||
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
|
||||
if not is_hassio(hass):
|
||||
return None
|
||||
|
||||
addon_manager: AddonManager = get_addon_manager(hass)
|
||||
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||
|
||||
if addon_info.state != AddonState.RUNNING:
|
||||
return None
|
||||
|
||||
usb_dev = entry.data["device"]
|
||||
@@ -62,8 +71,8 @@ async def _multi_pan_addon_info(
|
||||
return addon_info
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant Sky Connect config entry."""
|
||||
async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Finish Home Assistant Sky Connect config entry setup."""
|
||||
matcher = usb.USBCallbackMatcher(
|
||||
domain=DOMAIN,
|
||||
vid=entry.data["vid"].upper(),
|
||||
@@ -74,8 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
if not usb.async_is_plugged_in(hass, matcher):
|
||||
# The USB dongle is not plugged in
|
||||
raise ConfigEntryNotReady
|
||||
# The USB dongle is not plugged in, remove the config entry
|
||||
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
|
||||
return
|
||||
|
||||
addon_info = await _multi_pan_addon_info(hass, entry)
|
||||
|
||||
@@ -86,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
context={"source": "usb"},
|
||||
data=usb_info,
|
||||
)
|
||||
return True
|
||||
return
|
||||
|
||||
hw_discovery_data = {
|
||||
"name": "Sky Connect Multi-PAN",
|
||||
@@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
data=hw_discovery_data,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant Sky Connect config entry."""
|
||||
|
||||
await _wait_multi_pan_addon(hass, entry)
|
||||
|
||||
@callback
|
||||
def async_usb_scan_done() -> None:
|
||||
"""Handle usb discovery started."""
|
||||
hass.async_create_task(_async_usb_scan_done(hass, entry))
|
||||
|
||||
unsub_usb = usb.async_register_initial_scan_callback(hass, async_usb_scan_done)
|
||||
entry.async_on_unload(unsub_usb)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==2.4.3"],
|
||||
"requirements": ["aiohomekit==2.4.4"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
|
||||
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
|
||||
"dependencies": ["bluetooth", "zeroconf"],
|
||||
|
||||
@@ -250,6 +250,24 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: url})
|
||||
|
||||
def _is_supported_device() -> bool:
|
||||
"""
|
||||
See if we are looking at a possibly supported device.
|
||||
|
||||
Matching solely on SSDP data does not yield reliable enough results.
|
||||
"""
|
||||
try:
|
||||
with Connection(url=url, timeout=CONNECTION_TIMEOUT) as conn:
|
||||
basic_info = Client(conn).device.basic_information()
|
||||
except ResponseErrorException: # API compatible error
|
||||
return True
|
||||
except Exception: # API incompatible error # pylint: disable=broad-except
|
||||
return False
|
||||
return isinstance(basic_info, dict) # Crude content check
|
||||
|
||||
if not await self.hass.async_add_executor_job(_is_supported_device):
|
||||
return self.async_abort(reason="unsupported_device")
|
||||
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
|
||||
"requirements": [
|
||||
"huawei-lte-api==1.6.7",
|
||||
"huawei-lte-api==1.6.11",
|
||||
"stringcase==1.2.0",
|
||||
"url-normalize==1.4.3"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unsupported_device": "Unsupported device"
|
||||
},
|
||||
"error": {
|
||||
"connection_timeout": "Connection timeout",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"not_huawei_lte": "Not a Huawei LTE device",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"unsupported_device": "Unsupported device"
|
||||
},
|
||||
"error": {
|
||||
"connection_timeout": "Connection timeout",
|
||||
|
||||
@@ -28,7 +28,7 @@ DATA_HYDRAWISE = "hydrawise"
|
||||
DOMAIN = "hydrawise"
|
||||
DEFAULT_WATERING_TIME = 15
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
SCAN_INTERVAL = timedelta(seconds=120)
|
||||
|
||||
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""The Media Source implementation for the Jellyfin integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from jellyfin_apiclient_python.api import jellyfin_url
|
||||
@@ -41,6 +43,8 @@ from .const import (
|
||||
)
|
||||
from .models import JellyfinData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up Jellyfin media source."""
|
||||
@@ -75,6 +79,9 @@ class JellyfinSource(MediaSource):
|
||||
stream_url = self._get_stream_url(media_item)
|
||||
mime_type = _media_mime_type(media_item)
|
||||
|
||||
# Media Sources without a mime type have been filtered out during library creation
|
||||
assert mime_type is not None
|
||||
|
||||
return PlayMedia(stream_url, mime_type)
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
@@ -240,7 +247,11 @@ class JellyfinSource(MediaSource):
|
||||
k.get(ITEM_KEY_INDEX_NUMBER, None),
|
||||
),
|
||||
)
|
||||
return [self._build_track(track) for track in tracks]
|
||||
return [
|
||||
self._build_track(track)
|
||||
for track in tracks
|
||||
if _media_mime_type(track) is not None
|
||||
]
|
||||
|
||||
def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource:
|
||||
"""Return a single track as a browsable media source."""
|
||||
@@ -289,7 +300,11 @@ class JellyfinSource(MediaSource):
|
||||
"""Return all movies in the movie library."""
|
||||
movies = await self._get_children(library_id, ITEM_TYPE_MOVIE)
|
||||
movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return]
|
||||
return [self._build_movie(movie) for movie in movies]
|
||||
return [
|
||||
self._build_movie(movie)
|
||||
for movie in movies
|
||||
if _media_mime_type(movie) is not None
|
||||
]
|
||||
|
||||
def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource:
|
||||
"""Return a single movie as a browsable media source."""
|
||||
@@ -349,20 +364,24 @@ class JellyfinSource(MediaSource):
|
||||
raise BrowseError(f"Unsupported media type {media_type}")
|
||||
|
||||
|
||||
def _media_mime_type(media_item: dict[str, Any]) -> str:
|
||||
def _media_mime_type(media_item: dict[str, Any]) -> str | None:
|
||||
"""Return the mime type of a media item."""
|
||||
if not media_item.get(ITEM_KEY_MEDIA_SOURCES):
|
||||
raise BrowseError("Unable to determine mime type for item without media source")
|
||||
_LOGGER.debug("Unable to determine mime type for item without media source")
|
||||
return None
|
||||
|
||||
media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0]
|
||||
|
||||
if MEDIA_SOURCE_KEY_PATH not in media_source:
|
||||
raise BrowseError("Unable to determine mime type for media source without path")
|
||||
_LOGGER.debug("Unable to determine mime type for media source without path")
|
||||
return None
|
||||
|
||||
path = media_source[MEDIA_SOURCE_KEY_PATH]
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
|
||||
if mime_type is None:
|
||||
raise BrowseError(f"Unable to determine mime type for path {path}")
|
||||
_LOGGER.debug(
|
||||
"Unable to determine mime type for path %s", os.path.basename(path)
|
||||
)
|
||||
|
||||
return mime_type
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""DataUpdateCoordinator for LaCrosse View."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from time import time
|
||||
|
||||
from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor
|
||||
|
||||
@@ -30,7 +31,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
|
||||
) -> None:
|
||||
"""Initialize DataUpdateCoordinator for LaCrosse View."""
|
||||
self.api = api
|
||||
self.last_update = datetime.utcnow()
|
||||
self.last_update = time()
|
||||
self.username = entry.data["username"]
|
||||
self.password = entry.data["password"]
|
||||
self.hass = hass
|
||||
@@ -45,26 +46,22 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
|
||||
|
||||
async def _async_update_data(self) -> list[Sensor]:
|
||||
"""Get the data for LaCrosse View."""
|
||||
now = datetime.utcnow()
|
||||
now = int(time())
|
||||
|
||||
if self.last_update < now - timedelta(minutes=59): # Get new token
|
||||
if self.last_update < now - 59 * 60: # Get new token once in a hour
|
||||
self.last_update = now
|
||||
try:
|
||||
await self.api.login(self.username, self.password)
|
||||
except LoginError as error:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
|
||||
# Get the timestamp for yesterday at 6 PM (this is what is used in the app, i noticed it when proxying the request)
|
||||
yesterday = now - timedelta(days=1)
|
||||
yesterday = yesterday.replace(hour=18, minute=0, second=0, microsecond=0)
|
||||
yesterday_timestamp = datetime.timestamp(yesterday)
|
||||
|
||||
try:
|
||||
# Fetch last hour of data
|
||||
sensors = await self.api.get_sensors(
|
||||
location=Location(id=self.id, name=self.name),
|
||||
tz=self.hass.config.time_zone,
|
||||
start=str(int(yesterday_timestamp)),
|
||||
end=str(int(datetime.timestamp(now))),
|
||||
start=str(now - 3600),
|
||||
end=str(now),
|
||||
)
|
||||
except HTTPError as error:
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LCN",
|
||||
"config_flow": false,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"requirements": ["pypck==0.7.15"],
|
||||
"requirements": ["pypck==0.7.16"],
|
||||
"codeowners": ["@alengwenus"],
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"]
|
||||
|
||||
@@ -194,7 +194,7 @@ class VarAbs(LcnServiceCall):
|
||||
vol.Required(CONF_VARIABLE): vol.All(
|
||||
vol.Upper, vol.In(VARIABLES + SETPOINTS)
|
||||
),
|
||||
vol.Optional(CONF_VALUE, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_VALUE, default=0): vol.Coerce(float),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT, default="native"): vol.All(
|
||||
vol.Upper, vol.In(VAR_UNITS)
|
||||
),
|
||||
@@ -234,7 +234,7 @@ class VarRel(LcnServiceCall):
|
||||
vol.Required(CONF_VARIABLE): vol.All(
|
||||
vol.Upper, vol.In(VARIABLES + SETPOINTS + THRESHOLDS)
|
||||
),
|
||||
vol.Optional(CONF_VALUE, default=0): int,
|
||||
vol.Optional(CONF_VALUE, default=0): vol.Coerce(float),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT, default="native"): vol.All(
|
||||
vol.Upper, vol.In(VAR_UNITS)
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/life360",
|
||||
"codeowners": ["@pnbruckner"],
|
||||
"requirements": ["life360==5.3.0"],
|
||||
"requirements": ["life360==5.5.0"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["life360"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Litter-Robot",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
|
||||
"requirements": ["pylitterbot==2022.12.0"],
|
||||
"requirements": ["pylitterbot==2023.1.1"],
|
||||
"codeowners": ["@natekspencer", "@tkdrob"],
|
||||
"dhcp": [{ "hostname": "litter-robot4" }],
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Support for Litter-Robot updates."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pylitterbot import LitterRobot4
|
||||
@@ -17,12 +16,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import LitterRobotEntity, LitterRobotHub
|
||||
|
||||
SCAN_INTERVAL = timedelta(days=1)
|
||||
|
||||
FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription(
|
||||
key="firmware",
|
||||
name="Firmware",
|
||||
@@ -43,7 +42,7 @@ async def async_setup_entry(
|
||||
for robot in robots
|
||||
if isinstance(robot, LitterRobot4)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
|
||||
@@ -53,16 +52,6 @@ class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
robot: LitterRobot4,
|
||||
hub: LitterRobotHub,
|
||||
description: UpdateEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Litter-Robot update entity."""
|
||||
super().__init__(robot, hub, description)
|
||||
self._poll_unsub: Callable[[], None] | None = None
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str:
|
||||
"""Version installed and in use."""
|
||||
@@ -73,39 +62,27 @@ class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
|
||||
"""Update installation progress."""
|
||||
return self.robot.firmware_update_triggered
|
||||
|
||||
async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None:
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Set polling to True."""
|
||||
return True
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
self._poll_unsub = None
|
||||
|
||||
if await self.robot.has_firmware_update():
|
||||
latest_version = await self.robot.get_latest_firmware()
|
||||
else:
|
||||
latest_version = self.installed_version
|
||||
|
||||
if self._attr_latest_version != self.installed_version:
|
||||
# If the robot has a firmware update already in progress, checking for the
|
||||
# latest firmware informs that an update has already been triggered, no
|
||||
# firmware information is returned and we won't know the latest version.
|
||||
if not self.robot.firmware_update_triggered:
|
||||
latest_version = await self.robot.get_latest_firmware(True)
|
||||
if not await self.robot.has_firmware_update():
|
||||
latest_version = self.robot.firmware
|
||||
self._attr_latest_version = latest_version
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._poll_unsub = async_call_later(
|
||||
self.hass, timedelta(days=1), self._async_update
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up a listener for the entity."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(async_at_start(self.hass, self._async_update))
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
if await self.robot.has_firmware_update():
|
||||
if await self.robot.has_firmware_update(True):
|
||||
if not await self.robot.update_firmware():
|
||||
message = f"Unable to start firmware update on {self.robot.name}"
|
||||
raise HomeAssistantError(message)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed."""
|
||||
if self._poll_unsub:
|
||||
self._poll_unsub()
|
||||
self._poll_unsub = None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Local Calendar",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"requirements": ["ical==4.2.8"],
|
||||
"requirements": ["ical==4.2.9"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"]
|
||||
|
||||
@@ -83,6 +83,7 @@ async def _async_send_historical_events(
|
||||
formatter: Callable[[int, Any], dict[str, Any]],
|
||||
event_processor: EventProcessor,
|
||||
partial: bool,
|
||||
force_send: bool = False,
|
||||
) -> dt | None:
|
||||
"""Select historical data from the database and deliver it to the websocket.
|
||||
|
||||
@@ -116,7 +117,7 @@ async def _async_send_historical_events(
|
||||
# if its the last one (not partial) so
|
||||
# consumers of the api know their request was
|
||||
# answered but there were no results
|
||||
if last_event_time or not partial:
|
||||
if last_event_time or not partial or force_send:
|
||||
connection.send_message(message)
|
||||
return last_event_time
|
||||
|
||||
@@ -150,7 +151,7 @@ async def _async_send_historical_events(
|
||||
# if its the last one (not partial) so
|
||||
# consumers of the api know their request was
|
||||
# answered but there were no results
|
||||
if older_query_last_event_time or not partial:
|
||||
if older_query_last_event_time or not partial or force_send:
|
||||
connection.send_message(older_message)
|
||||
|
||||
# Returns the time of the newest event
|
||||
@@ -384,6 +385,11 @@ async def ws_event_stream(
|
||||
messages.event_message,
|
||||
event_processor,
|
||||
partial=True,
|
||||
# Force a send since the wait for the sync task
|
||||
# can take a a while if the recorder is busy and
|
||||
# we want to make sure the client is not still spinning
|
||||
# because it is waiting for the first message
|
||||
force_send=True,
|
||||
)
|
||||
|
||||
live_stream.task = asyncio.create_task(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Matter to Home Assistant adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from chip.clusters import Objects as all_clusters
|
||||
from matter_server.common.models.events import EventType
|
||||
from matter_server.common.models.node_device import AbstractMatterNodeDevice
|
||||
from matter_server.common.models.node_device import (
|
||||
AbstractMatterNodeDevice,
|
||||
MatterBridgedNodeDevice,
|
||||
)
|
||||
from matter_server.common.models.server_information import ServerInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -13,8 +17,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER
|
||||
from .device_platform import DEVICE_PLATFORM
|
||||
from .helpers import get_device_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matter_server.client import MatterClient
|
||||
@@ -66,31 +71,56 @@ class MatterAdapter:
|
||||
bridge_unique_id: str | None = None
|
||||
|
||||
if node.aggregator_device_type_instance is not None and (
|
||||
node_info := node.root_device_type_instance.get_cluster(all_clusters.Basic)
|
||||
node.root_device_type_instance.get_cluster(all_clusters.Basic)
|
||||
):
|
||||
self._create_device_registry(
|
||||
node_info, node_info.nodeLabel or "Hub device", None
|
||||
# create virtual (parent) device for bridge node device
|
||||
bridge_device = MatterBridgedNodeDevice(
|
||||
node.aggregator_device_type_instance
|
||||
)
|
||||
bridge_unique_id = node_info.uniqueID
|
||||
self._create_device_registry(bridge_device)
|
||||
server_info = cast(ServerInfo, self.matter_client.server_info)
|
||||
bridge_unique_id = get_device_id(server_info, bridge_device)
|
||||
|
||||
for node_device in node.node_devices:
|
||||
self._setup_node_device(node_device, bridge_unique_id)
|
||||
|
||||
def _create_device_registry(
|
||||
self,
|
||||
info: all_clusters.Basic | all_clusters.BridgedDeviceBasic,
|
||||
name: str,
|
||||
bridge_unique_id: str | None,
|
||||
node_device: AbstractMatterNodeDevice,
|
||||
bridge_unique_id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a device registry entry."""
|
||||
server_info = cast(ServerInfo, self.matter_client.server_info)
|
||||
|
||||
basic_info = node_device.device_info()
|
||||
device_type_instances = node_device.device_type_instances()
|
||||
|
||||
name = basic_info.nodeLabel
|
||||
if not name and isinstance(node_device, MatterBridgedNodeDevice):
|
||||
# fallback name for Bridge
|
||||
name = "Hub device"
|
||||
elif not name and device_type_instances:
|
||||
# use the productName if no node label is present
|
||||
name = basic_info.productName
|
||||
|
||||
node_device_id = get_device_id(
|
||||
server_info,
|
||||
node_device,
|
||||
)
|
||||
identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
||||
# if available, we also add the serialnumber as identifier
|
||||
if basic_info.serialNumber and "test" not in basic_info.serialNumber.lower():
|
||||
# prefix identifier with 'serial_' to be able to filter it
|
||||
identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}"))
|
||||
|
||||
dr.async_get(self.hass).async_get_or_create(
|
||||
name=name,
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, info.uniqueID)},
|
||||
hw_version=info.hardwareVersionString,
|
||||
sw_version=info.softwareVersionString,
|
||||
manufacturer=info.vendorName,
|
||||
model=info.productName,
|
||||
identifiers=identifiers,
|
||||
hw_version=basic_info.hardwareVersionString,
|
||||
sw_version=basic_info.softwareVersionString,
|
||||
manufacturer=basic_info.vendorName,
|
||||
model=basic_info.productName,
|
||||
via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None,
|
||||
)
|
||||
|
||||
@@ -98,17 +128,9 @@ class MatterAdapter:
|
||||
self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None
|
||||
) -> None:
|
||||
"""Set up a node device."""
|
||||
node = node_device.node()
|
||||
basic_info = node_device.device_info()
|
||||
device_type_instances = node_device.device_type_instances()
|
||||
|
||||
name = basic_info.nodeLabel
|
||||
if not name and device_type_instances:
|
||||
name = f"{device_type_instances[0].device_type.__doc__[:-1]} {node.node_id}"
|
||||
|
||||
self._create_device_registry(basic_info, name, bridge_unique_id)
|
||||
|
||||
for instance in device_type_instances:
|
||||
self._create_device_registry(node_device, bridge_unique_id)
|
||||
# run platform discovery from device type instances
|
||||
for instance in node_device.device_type_instances():
|
||||
created = False
|
||||
|
||||
for platform, devices in DEVICE_PLATFORM.items():
|
||||
|
||||
@@ -8,3 +8,7 @@ CONF_USE_ADDON = "use_addon"
|
||||
|
||||
DOMAIN = "matter"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
# prefixes to identify device identifier id types
|
||||
ID_TYPE_DEVICE_ID = "deviceid"
|
||||
ID_TYPE_SERIAL = "serial"
|
||||
|
||||
@@ -5,16 +5,18 @@ from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance
|
||||
from matter_server.common.models.events import EventType
|
||||
from matter_server.common.models.node_device import AbstractMatterNodeDevice
|
||||
from matter_server.common.models.server_information import ServerInfo
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID
|
||||
from .helpers import get_device_id, get_operational_instance_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matter_server.client import MatterClient
|
||||
@@ -55,24 +57,21 @@ class MatterEntity(Entity):
|
||||
self._node_device = node_device
|
||||
self._device_type_instance = device_type_instance
|
||||
self.entity_description = entity_description
|
||||
node = device_type_instance.node
|
||||
self._unsubscribes: list[Callable] = []
|
||||
# for fast lookups we create a mapping to the attribute paths
|
||||
self._attributes_map: dict[type, str] = {}
|
||||
server_info = matter_client.server_info
|
||||
# The server info is set when the client connects to the server.
|
||||
assert server_info is not None
|
||||
self._attributes_map: dict[type, str] = {}
|
||||
server_info = cast(ServerInfo, self.matter_client.server_info)
|
||||
# create unique_id based on "Operational Instance Name" and endpoint/device type
|
||||
self._attr_unique_id = (
|
||||
f"{server_info.compressed_fabric_id}-"
|
||||
f"{node.unique_id}-"
|
||||
f"{get_operational_instance_id(server_info, self._node_device.node())}-"
|
||||
f"{device_type_instance.endpoint}-"
|
||||
f"{device_type_instance.device_type.device_type}"
|
||||
)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Return device info for device registry."""
|
||||
return {"identifiers": {(DOMAIN, self._node_device.device_info().uniqueID)}}
|
||||
node_device_id = get_device_id(server_info, node_device)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle being added to Home Assistant."""
|
||||
@@ -115,7 +114,7 @@ class MatterEntity(Entity):
|
||||
|
||||
@callback
|
||||
def get_matter_attribute(self, attribute: type) -> MatterAttribute | None:
|
||||
"""Lookup MatterAttribute instance on device instance by providing the attribute class."""
|
||||
"""Lookup MatterAttribute on device by providing the attribute class."""
|
||||
return next(
|
||||
(
|
||||
x
|
||||
|
||||
@@ -10,6 +10,10 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matter_server.common.models.node import MatterNode
|
||||
from matter_server.common.models.node_device import AbstractMatterNodeDevice
|
||||
from matter_server.common.models.server_information import ServerInfo
|
||||
|
||||
from .adapter import MatterAdapter
|
||||
|
||||
|
||||
@@ -25,7 +29,32 @@ class MatterEntryData:
|
||||
def get_matter(hass: HomeAssistant) -> MatterAdapter:
|
||||
"""Return MatterAdapter instance."""
|
||||
# NOTE: This assumes only one Matter connection/fabric can exist.
|
||||
# Shall we support connecting to multiple servers in the client or by config entries?
|
||||
# In case of the config entry we need to fix this.
|
||||
# Shall we support connecting to multiple servers in the client or by
|
||||
# config entries? In case of the config entry we need to fix this.
|
||||
matter_entry_data: MatterEntryData = next(iter(hass.data[DOMAIN].values()))
|
||||
return matter_entry_data.adapter
|
||||
|
||||
|
||||
def get_operational_instance_id(
|
||||
server_info: ServerInfo,
|
||||
node: MatterNode,
|
||||
) -> str:
|
||||
"""Return `Operational Instance Name` for given MatterNode."""
|
||||
fabric_id_hex = f"{server_info.compressed_fabric_id:016X}"
|
||||
node_id_hex = f"{node.node_id:016X}"
|
||||
# Operational instance id matches the mDNS advertisement for the node
|
||||
# this is the recommended ID to recognize a unique matter node (within a fabric).
|
||||
return f"{fabric_id_hex}-{node_id_hex}"
|
||||
|
||||
|
||||
def get_device_id(
|
||||
server_info: ServerInfo,
|
||||
node_device: AbstractMatterNodeDevice,
|
||||
) -> str:
|
||||
"""Return HA device_id for the given MatterNodeDevice."""
|
||||
operational_instance_id = get_operational_instance_id(
|
||||
server_info, node_device.node()
|
||||
)
|
||||
# Append nodedevice(type) to differentiate between a root node
|
||||
# and bridge within Home Assistant devices.
|
||||
return f"{operational_instance_id}-{node_device.__class__.__name__}"
|
||||
|
||||
@@ -352,6 +352,13 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
return None
|
||||
return self._blind.angle * 100 / 180
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed or not."""
|
||||
if self._blind.position is None:
|
||||
return None
|
||||
return self._blind.position >= 95
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
async with self._api_lock:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Motion Blinds",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
|
||||
"requirements": ["motionblinds==0.6.13"],
|
||||
"requirements": ["motionblinds==0.6.15"],
|
||||
"dependencies": ["network"],
|
||||
"dhcp": [
|
||||
{ "registered_devices": true },
|
||||
|
||||
@@ -59,7 +59,7 @@ CONF_EXPIRE_AFTER = "expire_after"
|
||||
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
|
||||
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
|
||||
@@ -6,7 +6,7 @@ import functools
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import button
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -39,7 +39,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_DEVICE_CLASS): button.DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_PRESS, default=DEFAULT_PAYLOAD_PRESS): cv.string,
|
||||
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
|
||||
@@ -161,7 +161,7 @@ def validate_options(config: ConfigType) -> ConfigType:
|
||||
_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_GET_POSITION_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
|
||||
|
||||
@@ -87,7 +87,7 @@ def validate_config(config: ConfigType) -> ConfigType:
|
||||
_PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float),
|
||||
vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float),
|
||||
vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce(NumberMode),
|
||||
|
||||
@@ -115,6 +115,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity):
|
||||
discovery_data: DiscoveryInfoType | None,
|
||||
) -> None:
|
||||
"""Initialize the MQTT select."""
|
||||
self._attr_current_option = None
|
||||
SelectEntity.__init__(self)
|
||||
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
|
||||
|
||||
@@ -125,7 +126,6 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity):
|
||||
|
||||
def _setup_from_config(self, config: ConfigType) -> None:
|
||||
"""(Re)Setup the entity."""
|
||||
self._attr_current_option = None
|
||||
self._optimistic = config[CONF_OPTIMISTIC]
|
||||
self._attr_options = config[CONF_OPTIONS]
|
||||
|
||||
|
||||
@@ -98,13 +98,13 @@ def validate_options(conf: ConfigType) -> ConfigType:
|
||||
|
||||
_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
|
||||
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
|
||||
vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
@@ -62,7 +62,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
|
||||
vol.Optional(CONF_STATE_OFF): cv.string,
|
||||
vol.Optional(CONF_STATE_ON): cv.string,
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ CONF_TITLE = "title"
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
|
||||
vol.Optional(CONF_ENTITY_PICTURE): cv.string,
|
||||
vol.Optional(CONF_LATEST_VERSION_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Nanoleaf",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/nanoleaf",
|
||||
"requirements": ["aionanoleaf==0.2.0"],
|
||||
"requirements": ["aionanoleaf==0.2.1"],
|
||||
"zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."],
|
||||
"homekit": {
|
||||
"models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59"]
|
||||
|
||||
@@ -211,6 +211,7 @@ class NestFlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Complete OAuth setup and finish pubsub or finish."""
|
||||
_LOGGER.debug("Finishing post-oauth configuration")
|
||||
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
||||
self._data.update(data)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
@@ -459,6 +460,7 @@ class NestFlowHandler(
|
||||
|
||||
async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Create an entry for the SDM flow."""
|
||||
_LOGGER.debug("Creating/updating configuration entry")
|
||||
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
||||
# Update existing config entry when in the reauth flow.
|
||||
if entry := self._async_reauth_entry():
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"dependencies": ["ffmpeg", "http", "application_credentials"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.0"],
|
||||
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.2"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"quality_scale": "platinum",
|
||||
"dhcp": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Netatmo",
|
||||
"integration_type": "hub",
|
||||
"documentation": "https://www.home-assistant.io/integrations/netatmo",
|
||||
"requirements": ["pyatmo==7.4.0"],
|
||||
"requirements": ["pyatmo==7.5.0"],
|
||||
"after_dependencies": ["cloud", "media_source"],
|
||||
"dependencies": ["application_credentials", "webhook"],
|
||||
"codeowners": ["@cgtobi"],
|
||||
|
||||
@@ -58,8 +58,8 @@ SENSOR_TYPES = {
|
||||
key="signal",
|
||||
name="signal strength",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
icon="mdi:wifi",
|
||||
),
|
||||
"ssid": SensorEntityDescription(
|
||||
key="ssid",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "nissan_leaf",
|
||||
"name": "Nissan Leaf",
|
||||
"documentation": "https://www.home-assistant.io/integrations/nissan_leaf",
|
||||
"requirements": ["pycarwings2==2.13"],
|
||||
"requirements": ["pycarwings2==2.14"],
|
||||
"codeowners": ["@filcole"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pycarwings2"]
|
||||
|
||||
@@ -91,7 +91,7 @@ class NumberDeviceClass(StrEnum):
|
||||
CURRENT = "current"
|
||||
"""Current.
|
||||
|
||||
Unit of measurement: `A`
|
||||
Unit of measurement: `A`, `mA`
|
||||
"""
|
||||
|
||||
DATA_RATE = "data_rate"
|
||||
@@ -213,7 +213,7 @@ class NumberDeviceClass(StrEnum):
|
||||
POWER_FACTOR = "power_factor"
|
||||
"""Power factor.
|
||||
|
||||
Unit of measurement: `%`
|
||||
Unit of measurement: `%`, `None`
|
||||
"""
|
||||
|
||||
POWER = "power"
|
||||
@@ -296,7 +296,7 @@ class NumberDeviceClass(StrEnum):
|
||||
VOLTAGE = "voltage"
|
||||
"""Voltage.
|
||||
|
||||
Unit of measurement: `V`
|
||||
Unit of measurement: `V`, `mV`
|
||||
"""
|
||||
|
||||
VOLUME = "volume"
|
||||
|
||||
@@ -31,7 +31,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import InvalidApiKeyMonitor, OpenUvCoordinator
|
||||
from .coordinator import OpenUvCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
@@ -45,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry.data.get(CONF_LONGITUDE, hass.config.longitude),
|
||||
altitude=entry.data.get(CONF_ELEVATION, hass.config.elevation),
|
||||
session=websession,
|
||||
check_status_before_request=True,
|
||||
)
|
||||
|
||||
async def async_update_protection_data() -> dict[str, Any]:
|
||||
@@ -53,16 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
high = entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW)
|
||||
return await client.uv_protection_window(low=low, high=high)
|
||||
|
||||
invalid_api_key_monitor = InvalidApiKeyMonitor(hass, entry)
|
||||
|
||||
coordinators: dict[str, OpenUvCoordinator] = {
|
||||
coordinator_name: OpenUvCoordinator(
|
||||
hass,
|
||||
entry=entry,
|
||||
name=coordinator_name,
|
||||
latitude=client.latitude,
|
||||
longitude=client.longitude,
|
||||
update_method=update_method,
|
||||
invalid_api_key_monitor=invalid_api_key_monitor,
|
||||
)
|
||||
for coordinator_name, update_method in (
|
||||
(DATA_UV, client.uv_index),
|
||||
@@ -70,16 +69,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
}
|
||||
|
||||
# We disable the client's request retry abilities here to avoid a lengthy (and
|
||||
# blocking) startup; then, if the initial update is successful, we re-enable client
|
||||
# request retries:
|
||||
client.disable_request_retries()
|
||||
init_tasks = [
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators.values()
|
||||
]
|
||||
await asyncio.gather(*init_tasks)
|
||||
client.enable_request_retries()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinators
|
||||
|
||||
@@ -103,7 +103,6 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Verify the credentials and create/re-auth the entry."""
|
||||
websession = aiohttp_client.async_get_clientsession(self.hass)
|
||||
client = Client(data.api_key, 0, 0, session=websession)
|
||||
client.disable_request_retries()
|
||||
|
||||
try:
|
||||
await client.uv_index()
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Define an update coordinator for OpenUV."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from pyopenuv.errors import InvalidApiKeyError, OpenUvError
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -18,64 +17,6 @@ from .const import LOGGER
|
||||
DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60
|
||||
|
||||
|
||||
class InvalidApiKeyMonitor:
|
||||
"""Define a monitor for failed API calls (due to bad keys) across coordinators."""
|
||||
|
||||
DEFAULT_FAILED_API_CALL_THRESHOLD = 5
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._count = 1
|
||||
self._lock = asyncio.Lock()
|
||||
self._reauth_flow_manager = ReauthFlowManager(hass, entry)
|
||||
self.entry = entry
|
||||
|
||||
async def async_increment(self) -> None:
|
||||
"""Increment the counter."""
|
||||
async with self._lock:
|
||||
self._count += 1
|
||||
if self._count > self.DEFAULT_FAILED_API_CALL_THRESHOLD:
|
||||
LOGGER.info("Starting reauth after multiple failed API calls")
|
||||
self._reauth_flow_manager.start_reauth()
|
||||
|
||||
async def async_reset(self) -> None:
|
||||
"""Reset the counter."""
|
||||
async with self._lock:
|
||||
self._count = 0
|
||||
self._reauth_flow_manager.cancel_reauth()
|
||||
|
||||
|
||||
class ReauthFlowManager:
|
||||
"""Define an OpenUV reauth flow manager."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self.entry = entry
|
||||
self.hass = hass
|
||||
|
||||
@callback
|
||||
def _get_active_reauth_flow(self) -> FlowResult | None:
|
||||
"""Get an active reauth flow (if it exists)."""
|
||||
return next(
|
||||
iter(self.entry.async_get_active_flows(self.hass, {SOURCE_REAUTH})),
|
||||
None,
|
||||
)
|
||||
|
||||
@callback
|
||||
def cancel_reauth(self) -> None:
|
||||
"""Cancel a reauth flow (if appropriate)."""
|
||||
if reauth_flow := self._get_active_reauth_flow():
|
||||
LOGGER.debug("API seems to have recovered; canceling reauth flow")
|
||||
self.hass.config_entries.flow.async_abort(reauth_flow["flow_id"])
|
||||
|
||||
@callback
|
||||
def start_reauth(self) -> None:
|
||||
"""Start a reauth flow (if appropriate)."""
|
||||
if not self._get_active_reauth_flow():
|
||||
LOGGER.debug("Multiple API failures in a row; starting reauth flow")
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
|
||||
|
||||
class OpenUvCoordinator(DataUpdateCoordinator):
|
||||
"""Define an OpenUV data coordinator."""
|
||||
|
||||
@@ -86,11 +27,11 @@ class OpenUvCoordinator(DataUpdateCoordinator):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry: ConfigEntry,
|
||||
name: str,
|
||||
latitude: str,
|
||||
longitude: str,
|
||||
update_method: Callable[[], Awaitable[dict[str, Any]]],
|
||||
invalid_api_key_monitor: InvalidApiKeyMonitor,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -106,7 +47,7 @@ class OpenUvCoordinator(DataUpdateCoordinator):
|
||||
),
|
||||
)
|
||||
|
||||
self._invalid_api_key_monitor = invalid_api_key_monitor
|
||||
self._entry = entry
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
|
||||
@@ -115,10 +56,18 @@ class OpenUvCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
data = await self.update_method()
|
||||
except InvalidApiKeyError as err:
|
||||
await self._invalid_api_key_monitor.async_increment()
|
||||
raise UpdateFailed(str(err)) from err
|
||||
raise ConfigEntryAuthFailed("Invalid API key") from err
|
||||
except OpenUvError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
await self._invalid_api_key_monitor.async_reset()
|
||||
# OpenUV uses HTTP 403 to indicate both an invalid API key and an API key that
|
||||
# has hit its daily/monthly limit; both cases will result in a reauth flow. If
|
||||
# coordinator update succeeds after a reauth flow has been started, terminate
|
||||
# it:
|
||||
if reauth_flow := next(
|
||||
iter(self._entry.async_get_active_flows(self.hass, {SOURCE_REAUTH})),
|
||||
None,
|
||||
):
|
||||
self.hass.config_entries.flow.async_abort(reauth_flow["flow_id"])
|
||||
|
||||
return cast(dict[str, Any], data["result"])
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "OpenUV",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/openuv",
|
||||
"requirements": ["pyopenuv==2022.04.0"],
|
||||
"requirements": ["pyopenuv==2023.01.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyopenuv"],
|
||||
|
||||
@@ -210,7 +210,7 @@ class PhilipsTVMediaPlayer(
|
||||
async def async_media_play_pause(self) -> None:
|
||||
"""Send pause command to media player."""
|
||||
if self._tv.quirk_playpause_spacebar:
|
||||
await self._tv.sendUnicode(" ")
|
||||
await self._tv.sendKey("Confirm")
|
||||
else:
|
||||
await self._tv.sendKey("PlayPause")
|
||||
await self._async_update_soon()
|
||||
@@ -509,6 +509,8 @@ class PhilipsTVMediaPlayer(
|
||||
self._media_title = self._sources.get(self._tv.source_id)
|
||||
self._media_channel = None
|
||||
|
||||
self._attr_assumed_state = True
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
@@ -17,7 +17,8 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
@@ -64,6 +65,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Pi-hole integration."""
|
||||
@@ -103,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
location = entry.data[CONF_LOCATION]
|
||||
api_key = entry.data.get(CONF_API_KEY)
|
||||
|
||||
# For backward compatibility
|
||||
if CONF_STATISTICS_ONLY not in entry.data:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_STATISTICS_ONLY: not api_key}
|
||||
)
|
||||
# remove obsolet CONF_STATISTICS_ONLY from entry.data
|
||||
if CONF_STATISTICS_ONLY in entry.data:
|
||||
entry_data = entry.data.copy()
|
||||
entry_data.pop(CONF_STATISTICS_ONLY)
|
||||
hass.config_entries.async_update_entry(entry, data=entry_data)
|
||||
|
||||
# start reauth to force api key is present
|
||||
if CONF_API_KEY not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
_LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host)
|
||||
|
||||
@@ -125,8 +137,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
await api.get_data()
|
||||
await api.get_versions()
|
||||
_LOGGER.debug("async_update_data() api.data: %s", api.data)
|
||||
except HoleError as err:
|
||||
raise UpdateFailed(f"Failed to communicate with API: {err}") from err
|
||||
if not isinstance(api.data, dict):
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
@@ -142,30 +157,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _async_platforms(entry))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Pi-hole entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, _async_platforms(entry)
|
||||
)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
@callback
|
||||
def _async_platforms(entry: ConfigEntry) -> list[Platform]:
|
||||
"""Return platforms to be loaded / unloaded."""
|
||||
platforms = [Platform.BINARY_SENSOR, Platform.UPDATE, Platform.SENSOR]
|
||||
if not entry.data[CONF_STATISTICS_ONLY]:
|
||||
platforms.append(Platform.SWITCH)
|
||||
return platforms
|
||||
|
||||
|
||||
class PiHoleEntity(CoordinatorEntity):
|
||||
"""Representation of a Pi-hole entity."""
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import PiHoleEntity
|
||||
from .const import (
|
||||
BINARY_SENSOR_TYPES,
|
||||
BINARY_SENSOR_TYPES_STATISTICS_ONLY,
|
||||
CONF_STATISTICS_ONLY,
|
||||
DATA_KEY_API,
|
||||
DATA_KEY_COORDINATOR,
|
||||
DOMAIN as PIHOLE_DOMAIN,
|
||||
@@ -42,18 +40,6 @@ async def async_setup_entry(
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
]
|
||||
|
||||
if entry.data[CONF_STATISTICS_ONLY]:
|
||||
binary_sensors += [
|
||||
PiHoleBinarySensor(
|
||||
hole_data[DATA_KEY_API],
|
||||
hole_data[DATA_KEY_COORDINATOR],
|
||||
name,
|
||||
entry.entry_id,
|
||||
description,
|
||||
)
|
||||
for description in BINARY_SENSOR_TYPES_STATISTICS_ONLY
|
||||
]
|
||||
|
||||
async_add_entities(binary_sensors, True)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow to configure the Pi-hole integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -26,7 +27,6 @@ from .const import (
|
||||
DEFAULT_LOCATION,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_STATISTICS_ONLY,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -47,65 +47,29 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
return await self.async_step_init(user_input)
|
||||
|
||||
async def async_step_import(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by import."""
|
||||
return await self.async_step_init(user_input, is_import=True)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None, is_import: bool = False
|
||||
) -> FlowResult:
|
||||
"""Handle init step of a flow."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = (
|
||||
user_input[CONF_HOST]
|
||||
if is_import
|
||||
else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
||||
)
|
||||
name = user_input[CONF_NAME]
|
||||
location = user_input[CONF_LOCATION]
|
||||
tls = user_input[CONF_SSL]
|
||||
verify_tls = user_input[CONF_VERIFY_SSL]
|
||||
endpoint = f"{host}/{location}"
|
||||
self._config = {
|
||||
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
|
||||
CONF_NAME: user_input[CONF_NAME],
|
||||
CONF_LOCATION: user_input[CONF_LOCATION],
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
}
|
||||
|
||||
if await self._async_endpoint_existed(endpoint):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
try:
|
||||
await self._async_try_connect(host, location, tls, verify_tls)
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
if is_import:
|
||||
_LOGGER.error("Failed to import: %s", ex)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self._config = {
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
CONF_LOCATION: location,
|
||||
CONF_SSL: tls,
|
||||
CONF_VERIFY_SSL: verify_tls,
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
|
||||
CONF_LOCATION: user_input[CONF_LOCATION],
|
||||
}
|
||||
if is_import:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_STATISTICS_ONLY: api_key is None,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
)
|
||||
self._config[CONF_STATISTICS_ONLY] = user_input[CONF_STATISTICS_ONLY]
|
||||
if self._config[CONF_STATISTICS_ONLY]:
|
||||
return self.async_create_entry(title=name, data=self._config)
|
||||
return await self.async_step_api_key()
|
||||
)
|
||||
|
||||
if not (errors := await self._async_try_connect()):
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=self._config
|
||||
)
|
||||
|
||||
user_input = user_input or {}
|
||||
return self.async_show_form(
|
||||
@@ -116,6 +80,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
CONF_PORT, default=user_input.get(CONF_PORT, 80)
|
||||
): vol.Coerce(int),
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(
|
||||
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
|
||||
): str,
|
||||
@@ -123,12 +88,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_LOCATION,
|
||||
default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION),
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_STATISTICS_ONLY,
|
||||
default=user_input.get(
|
||||
CONF_STATISTICS_ONLY, DEFAULT_STATISTICS_ONLY
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SSL,
|
||||
default=user_input.get(CONF_SSL, DEFAULT_SSL),
|
||||
@@ -142,24 +101,94 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_api_key(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
|
||||
"""Handle a flow initiated by import."""
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
name = user_input[CONF_NAME]
|
||||
location = user_input[CONF_LOCATION]
|
||||
tls = user_input[CONF_SSL]
|
||||
verify_tls = user_input[CONF_VERIFY_SSL]
|
||||
endpoint = f"{host}/{location}"
|
||||
|
||||
if await self._async_endpoint_existed(endpoint):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
try:
|
||||
await self._async_try_connect_legacy(host, location, tls, verify_tls)
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
_LOGGER.error("Failed to import: %s", ex)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
self._config = {
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
CONF_LOCATION: location,
|
||||
CONF_SSL: tls,
|
||||
CONF_VERIFY_SSL: verify_tls,
|
||||
}
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_STATISTICS_ONLY: api_key is None,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
self._config = dict(entry_data)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Handle step to setup API key."""
|
||||
"""Perform reauth confirm upon an API authentication error."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._config[CONF_NAME],
|
||||
data={
|
||||
**self._config,
|
||||
CONF_API_KEY: user_input.get(CONF_API_KEY, ""),
|
||||
},
|
||||
)
|
||||
self._config = {**self._config, CONF_API_KEY: user_input[CONF_API_KEY]}
|
||||
if not (errors := await self._async_try_connect()):
|
||||
entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
assert entry
|
||||
self.hass.config_entries.async_update_entry(entry, data=self._config)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.context["entry_id"])
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="api_key",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_API_KEY): str}),
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={
|
||||
CONF_HOST: self._config[CONF_HOST],
|
||||
CONF_LOCATION: self._config[CONF_LOCATION],
|
||||
},
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_try_connect(self) -> dict[str, str]:
|
||||
session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL])
|
||||
pi_hole = Hole(
|
||||
self._config[CONF_HOST],
|
||||
session,
|
||||
location=self._config[CONF_LOCATION],
|
||||
tls=self._config[CONF_SSL],
|
||||
api_token=self._config[CONF_API_KEY],
|
||||
)
|
||||
try:
|
||||
await pi_hole.get_data()
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
return {"base": "cannot_connect"}
|
||||
if not isinstance(pi_hole.data, dict):
|
||||
return {CONF_API_KEY: "invalid_auth"}
|
||||
return {}
|
||||
|
||||
async def _async_endpoint_existed(self, endpoint: str) -> bool:
|
||||
existing_endpoints = [
|
||||
f"{entry.data.get(CONF_HOST)}/{entry.data.get(CONF_LOCATION)}"
|
||||
@@ -167,7 +196,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
]
|
||||
return endpoint in existing_endpoints
|
||||
|
||||
async def _async_try_connect(
|
||||
async def _async_try_connect_legacy(
|
||||
self, host: str, location: str, tls: bool, verify_tls: bool
|
||||
) -> None:
|
||||
session = async_get_clientsession(self.hass, verify_tls)
|
||||
|
||||
@@ -154,9 +154,6 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = (
|
||||
},
|
||||
state_value=lambda api: bool(api.versions["FTL_update"]),
|
||||
),
|
||||
)
|
||||
|
||||
BINARY_SENSOR_TYPES_STATISTICS_ONLY: tuple[PiHoleBinarySensorEntityDescription, ...] = (
|
||||
PiHoleBinarySensorEntityDescription(
|
||||
key="status",
|
||||
name="Status",
|
||||
|
||||
@@ -8,28 +8,25 @@
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"statistics_only": "Statistics Only",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
},
|
||||
"api_key": {
|
||||
"reauth_confirm": {
|
||||
"title": "PI-Hole [%key:common::config_flow::title::reauth%]",
|
||||
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The PI-Hole YAML configuration is being removed",
|
||||
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured"
|
||||
"already_configured": "Service is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect"
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
},
|
||||
"step": {
|
||||
"api_key": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "API Key"
|
||||
}
|
||||
},
|
||||
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
|
||||
"title": "PI-Hole Reauthenticate Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -20,16 +24,9 @@
|
||||
"name": "Name",
|
||||
"port": "Port",
|
||||
"ssl": "Uses an SSL certificate",
|
||||
"statistics_only": "Statistics Only",
|
||||
"verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The PI-Hole YAML configuration is being removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,10 @@ from aiopurpleair.models.sensors import SensorModel
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .config_flow import async_remove_sensor_by_device_id
|
||||
from .const import CONF_LAST_UPDATE_SENSOR_ADD, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .coordinator import PurpleAirDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
@@ -32,26 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle an options update."""
|
||||
if entry.options.get(CONF_LAST_UPDATE_SENSOR_ADD) is True:
|
||||
# If the last options update was to add a sensor, we reload the config entry:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
new_entry_options = async_remove_sensor_by_device_id(
|
||||
hass,
|
||||
config_entry,
|
||||
device_entry.id,
|
||||
# remove_device is set to False because in this instance, the device has
|
||||
# already been removed:
|
||||
remove_device=False,
|
||||
)
|
||||
return hass.config_entries.async_update_entry(
|
||||
config_entry, options=new_entry_options
|
||||
)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow for PurpleAir integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
@@ -14,13 +15,15 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -28,7 +31,7 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_LAST_UPDATE_SENSOR_ADD, CONF_SENSOR_INDICES, DOMAIN, LOGGER
|
||||
from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER
|
||||
|
||||
CONF_DISTANCE = "distance"
|
||||
CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options"
|
||||
@@ -74,8 +77,7 @@ def async_get_nearby_sensors_options(
|
||||
"""Return a set of nearby sensors as SelectOptionDict objects."""
|
||||
return [
|
||||
SelectOptionDict(
|
||||
value=str(result.sensor.sensor_index),
|
||||
label=f"{result.sensor.name} ({round(result.distance, 1)} km away)",
|
||||
value=str(result.sensor.sensor_index), label=cast(str, result.sensor.name)
|
||||
)
|
||||
for result in nearby_sensor_results
|
||||
]
|
||||
@@ -118,50 +120,6 @@ def async_get_remove_sensor_schema(sensors: list[SelectOptionDict]) -> vol.Schem
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_sensor_index(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> int:
|
||||
"""Get the sensor index related to a config and device entry.
|
||||
|
||||
Note that this method expects that there will always be a single sensor index per
|
||||
DeviceEntry.
|
||||
"""
|
||||
[sensor_index] = [
|
||||
sensor_index
|
||||
for sensor_index in config_entry.options[CONF_SENSOR_INDICES]
|
||||
if (DOMAIN, str(sensor_index)) in device_entry.identifiers
|
||||
]
|
||||
|
||||
return cast(int, sensor_index)
|
||||
|
||||
|
||||
@callback
|
||||
def async_remove_sensor_by_device_id(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
device_id: str,
|
||||
*,
|
||||
remove_device: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Remove a sensor and return update config entry options."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
assert device_entry
|
||||
|
||||
removed_sensor_index = async_get_sensor_index(hass, config_entry, device_entry)
|
||||
options = deepcopy({**config_entry.options})
|
||||
options[CONF_LAST_UPDATE_SENSOR_ADD] = False
|
||||
options[CONF_SENSOR_INDICES].remove(removed_sensor_index)
|
||||
|
||||
if remove_device:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Define a validation result."""
|
||||
@@ -408,7 +366,6 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
options = deepcopy({**self.config_entry.options})
|
||||
options[CONF_LAST_UPDATE_SENSOR_ADD] = True
|
||||
options[CONF_SENSOR_INDICES].append(sensor_index)
|
||||
return self.async_create_entry(title="", data=options)
|
||||
|
||||
@@ -433,8 +390,50 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
),
|
||||
)
|
||||
|
||||
new_entry_options = async_remove_sensor_by_device_id(
|
||||
self.hass, self.config_entry, user_input[CONF_SENSOR_DEVICE_ID]
|
||||
device_registry = dr.async_get(self.hass)
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
device_id = user_input[CONF_SENSOR_DEVICE_ID]
|
||||
device_entry = cast(dr.DeviceEntry, device_registry.async_get(device_id))
|
||||
|
||||
# Determine the entity entries that belong to this device.
|
||||
entity_entries = er.async_entries_for_device(
|
||||
entity_registry, device_id, include_disabled_entities=True
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="", data=new_entry_options)
|
||||
device_entities_removed_event = asyncio.Event()
|
||||
|
||||
@callback
|
||||
def async_device_entity_state_changed(_: Event) -> None:
|
||||
"""Listen and respond when all device entities are removed."""
|
||||
if all(
|
||||
self.hass.states.get(entity_entry.entity_id) is None
|
||||
for entity_entry in entity_entries
|
||||
):
|
||||
device_entities_removed_event.set()
|
||||
|
||||
# Track state changes for this device's entities and when they're removed,
|
||||
# finish the flow:
|
||||
cancel_state_track = async_track_state_change_event(
|
||||
self.hass,
|
||||
[entity_entry.entity_id for entity_entry in entity_entries],
|
||||
async_device_entity_state_changed,
|
||||
)
|
||||
device_registry.async_update_device(
|
||||
device_id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
await device_entities_removed_event.wait()
|
||||
|
||||
# Once we're done, we can cancel the state change tracker callback:
|
||||
cancel_state_track()
|
||||
|
||||
# Build new config entry options:
|
||||
removed_sensor_index = next(
|
||||
sensor_index
|
||||
for sensor_index in self.config_entry.options[CONF_SENSOR_INDICES]
|
||||
if (DOMAIN, str(sensor_index)) in device_entry.identifiers
|
||||
)
|
||||
options = deepcopy({**self.config_entry.options})
|
||||
options[CONF_SENSOR_INDICES].remove(removed_sensor_index)
|
||||
|
||||
return self.async_create_entry(title="", data=options)
|
||||
|
||||
@@ -5,6 +5,5 @@ DOMAIN = "purpleair"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_LAST_UPDATE_SENSOR_ADD = "last_update_sensor_add"
|
||||
CONF_READ_KEY = "read_key"
|
||||
CONF_SENSOR_INDICES = "sensor_indices"
|
||||
|
||||
@@ -166,7 +166,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
name="Uptime",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:timer",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda sensor: sensor.uptime,
|
||||
@@ -174,8 +174,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
PurpleAirSensorEntityDescription(
|
||||
key="voc",
|
||||
name="VOC",
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
native_unit_of_measurement=CONCENTRATION_IAQ,
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda sensor: sensor.voc,
|
||||
),
|
||||
|
||||
@@ -9,19 +9,23 @@ import logging
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
import async_timeout
|
||||
from reolink_ip.exceptions import ApiError, InvalidContentTypeError
|
||||
from reolink_aio.exceptions import ApiError, InvalidContentTypeError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DEVICE_UPDATE_INTERVAL, DOMAIN, PLATFORMS
|
||||
from .const import DOMAIN
|
||||
from .exceptions import UserNotAdmin
|
||||
from .host import ReolinkHost
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CAMERA]
|
||||
DEVICE_UPDATE_INTERVAL = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReolinkData:
|
||||
@@ -31,33 +35,39 @@ class ReolinkData:
|
||||
device_coordinator: DataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up Reolink from a config entry."""
|
||||
host = ReolinkHost(hass, dict(entry.data), dict(entry.options))
|
||||
host = ReolinkHost(hass, config_entry.data, config_entry.options)
|
||||
|
||||
try:
|
||||
if not await host.async_init():
|
||||
await host.stop()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while trying to setup {host.api.host}:{host.api.port}: failed to obtain data from device."
|
||||
f"Error while trying to setup {host.api.host}:{host.api.port}: "
|
||||
"failed to obtain data from device."
|
||||
)
|
||||
except UserNotAdmin as err:
|
||||
raise ConfigEntryAuthFailed(err) from UserNotAdmin
|
||||
except (
|
||||
ClientConnectorError,
|
||||
asyncio.TimeoutError,
|
||||
ApiError,
|
||||
InvalidContentTypeError,
|
||||
) as err:
|
||||
await host.stop()
|
||||
raise ConfigEntryNotReady(
|
||||
f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".'
|
||||
) from err
|
||||
|
||||
entry.async_on_unload(
|
||||
config_entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
|
||||
)
|
||||
|
||||
async def async_device_config_update():
|
||||
"""Perform the update of the host config-state cache, and renew the ONVIF-subscription."""
|
||||
"""Update the host state cache and renew the ONVIF-subscription."""
|
||||
async with async_timeout.timeout(host.api.timeout):
|
||||
await host.update_states() # Login session is implicitly updated here, so no need to explicitly do it in a timer
|
||||
# Login session is implicitly updated here
|
||||
await host.update_states()
|
||||
|
||||
coordinator_device_config_update = DataUpdateCoordinator(
|
||||
hass,
|
||||
@@ -69,30 +79,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator_device_config_update.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ReolinkData(
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData(
|
||||
host=host,
|
||||
device_coordinator=coordinator_device_config_update,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(entry_update_listener))
|
||||
config_entry.async_on_unload(
|
||||
config_entry.add_update_listener(entry_update_listener)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def entry_update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
"""Update the configuration of the host entity."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
host: ReolinkHost = hass.data[DOMAIN][entry.entry_id].host
|
||||
host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host
|
||||
|
||||
await host.stop()
|
||||
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -8,9 +8,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import ReolinkData
|
||||
from .const import DOMAIN
|
||||
from .entity import ReolinkCoordinatorEntity
|
||||
from .host import ReolinkHost
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,10 +18,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_devices: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Reolink IP Camera."""
|
||||
host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host
|
||||
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
host = reolink_data.host
|
||||
|
||||
cameras = []
|
||||
for channel in host.api.channels:
|
||||
@@ -30,25 +31,31 @@ async def async_setup_entry(
|
||||
streams.append("ext")
|
||||
|
||||
for stream in streams:
|
||||
cameras.append(ReolinkCamera(hass, config_entry, channel, stream))
|
||||
cameras.append(ReolinkCamera(reolink_data, config_entry, channel, stream))
|
||||
|
||||
async_add_devices(cameras, update_before_add=True)
|
||||
async_add_entities(cameras, update_before_add=True)
|
||||
|
||||
|
||||
class ReolinkCamera(ReolinkCoordinatorEntity, Camera):
|
||||
"""An implementation of a Reolink IP camera."""
|
||||
|
||||
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, hass, config, channel, stream):
|
||||
def __init__(
|
||||
self,
|
||||
reolink_data: ReolinkData,
|
||||
config_entry: ConfigEntry,
|
||||
channel: int,
|
||||
stream: str,
|
||||
) -> None:
|
||||
"""Initialize Reolink camera stream."""
|
||||
ReolinkCoordinatorEntity.__init__(self, hass, config)
|
||||
ReolinkCoordinatorEntity.__init__(self, reolink_data, config_entry, channel)
|
||||
Camera.__init__(self)
|
||||
|
||||
self._channel = channel
|
||||
self._stream = stream
|
||||
|
||||
self._attr_name = f"{self._host.api.camera_name(self._channel)} {self._stream}"
|
||||
self._attr_name = self._stream
|
||||
self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}"
|
||||
self._attr_entity_registry_enabled_default = stream == "sub"
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
"""Config flow for the Reolink camera component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any
|
||||
|
||||
from reolink_ip.exceptions import ApiError, CredentialsInvalidError
|
||||
from reolink_aio.exceptions import ApiError, CredentialsInvalidError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN
|
||||
from .exceptions import UserNotAdmin
|
||||
from .host import ReolinkHost
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL}
|
||||
|
||||
|
||||
class ReolinkOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle Reolink options."""
|
||||
@@ -26,10 +30,12 @@ class ReolinkOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Initialize ReolinkOptionsFlowHandler."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None) -> FlowResult:
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the Reolink options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -37,9 +43,7 @@ class ReolinkOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PROTOCOL,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_PROTOCOL, DEFAULT_PROTOCOL
|
||||
),
|
||||
default=self.config_entry.options[CONF_PROTOCOL],
|
||||
): vol.In(["rtsp", "rtmp"]),
|
||||
}
|
||||
),
|
||||
@@ -51,7 +55,12 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
host: ReolinkHost | None = None
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._host: str | None = None
|
||||
self._username: str = "admin"
|
||||
self._password: str | None = None
|
||||
self._reauth: bool = False
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -61,14 +70,37 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Options callback for Reolink."""
|
||||
return ReolinkOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None) -> FlowResult:
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an authentication error or no admin privileges."""
|
||||
self._host = entry_data[CONF_HOST]
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
self._password = entry_data[CONF_PASSWORD]
|
||||
self._reauth = True
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
placeholders = {}
|
||||
placeholders = {"error": ""}
|
||||
|
||||
if user_input is not None:
|
||||
host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS)
|
||||
try:
|
||||
await self.async_obtain_host_settings(self.hass, user_input)
|
||||
await async_obtain_host_settings(host)
|
||||
except UserNotAdmin:
|
||||
errors[CONF_USERNAME] = "not_admin"
|
||||
placeholders["username"] = host.api.username
|
||||
placeholders["userlevel"] = host.api.user_level
|
||||
except CannotConnect:
|
||||
errors[CONF_HOST] = "cannot_connect"
|
||||
except CredentialsInvalidError:
|
||||
@@ -81,26 +113,34 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
placeholders["error"] = str(err)
|
||||
errors[CONF_HOST] = "unknown"
|
||||
|
||||
self.host = cast(ReolinkHost, self.host)
|
||||
|
||||
if not errors:
|
||||
user_input[CONF_PORT] = self.host.api.port
|
||||
user_input[CONF_USE_HTTPS] = self.host.api.use_https
|
||||
user_input[CONF_PORT] = host.api.port
|
||||
user_input[CONF_USE_HTTPS] = host.api.use_https
|
||||
|
||||
await self.async_set_unique_id(
|
||||
self.host.unique_id, raise_on_progress=False
|
||||
existing_entry = await self.async_set_unique_id(
|
||||
host.unique_id, raise_on_progress=False
|
||||
)
|
||||
if existing_entry and self._reauth:
|
||||
if self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data=user_input
|
||||
):
|
||||
await self.hass.config_entries.async_reload(
|
||||
existing_entry.entry_id
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=str(self.host.api.nvr_name), data=user_input
|
||||
title=str(host.api.nvr_name),
|
||||
data=user_input,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default="admin"): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME, default=self._username): str,
|
||||
vol.Required(CONF_PASSWORD, default=self._password): str,
|
||||
vol.Required(CONF_HOST, default=self._host): str,
|
||||
}
|
||||
)
|
||||
if errors:
|
||||
@@ -118,19 +158,14 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_obtain_host_settings(
|
||||
self, hass: core.HomeAssistant, user_input: dict
|
||||
):
|
||||
"""Initialize the Reolink host and get the host information."""
|
||||
host = ReolinkHost(hass, user_input, {})
|
||||
|
||||
try:
|
||||
if not await host.async_init():
|
||||
raise CannotConnect
|
||||
finally:
|
||||
await host.stop()
|
||||
|
||||
self.host = host
|
||||
async def async_obtain_host_settings(host: ReolinkHost) -> None:
|
||||
"""Initialize the Reolink host and get the host information."""
|
||||
try:
|
||||
if not await host.async_init():
|
||||
raise CannotConnect
|
||||
finally:
|
||||
await host.stop()
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"""Constants for the Reolink Camera integration."""
|
||||
|
||||
DOMAIN = "reolink"
|
||||
PLATFORMS = ["camera"]
|
||||
|
||||
CONF_USE_HTTPS = "use_https"
|
||||
CONF_PROTOCOL = "protocol"
|
||||
|
||||
DEFAULT_PROTOCOL = "rtsp"
|
||||
DEFAULT_TIMEOUT = 60
|
||||
|
||||
HOST = "host"
|
||||
DEVICE_UPDATE_INTERVAL = 60
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Reolink parent entity class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -11,24 +13,20 @@ from .const import DOMAIN
|
||||
class ReolinkCoordinatorEntity(CoordinatorEntity):
|
||||
"""Parent class for Reolink Entities."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
def __init__(
|
||||
self, reolink_data: ReolinkData, config_entry: ConfigEntry, channel: int | None
|
||||
) -> None:
|
||||
"""Initialize ReolinkCoordinatorEntity."""
|
||||
self._hass = hass
|
||||
entry_data: ReolinkData = self._hass.data[DOMAIN][config.entry_id]
|
||||
coordinator = entry_data.device_coordinator
|
||||
coordinator = reolink_data.device_coordinator
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._host = entry_data.host
|
||||
self._channel = None
|
||||
self._host = reolink_data.host
|
||||
self._channel = channel
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Information about this entity/device."""
|
||||
http_s = "https" if self._host.api.use_https else "http"
|
||||
conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
|
||||
|
||||
if self._host.api.is_nvr and self._channel is not None:
|
||||
return DeviceInfo(
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")},
|
||||
via_device=(DOMAIN, self._host.unique_id),
|
||||
name=self._host.api.camera_name(self._channel),
|
||||
@@ -36,19 +34,19 @@ class ReolinkCoordinatorEntity(CoordinatorEntity):
|
||||
manufacturer=self._host.api.manufacturer,
|
||||
configuration_url=conf_url,
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._host.unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)},
|
||||
name=self._host.api.nvr_name,
|
||||
model=self._host.api.model,
|
||||
manufacturer=self._host.api.manufacturer,
|
||||
hw_version=self._host.api.hardware_version,
|
||||
sw_version=self._host.api.sw_version,
|
||||
configuration_url=conf_url,
|
||||
)
|
||||
else:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._host.unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)},
|
||||
name=self._host.api.nvr_name,
|
||||
model=self._host.api.model,
|
||||
manufacturer=self._host.api.manufacturer,
|
||||
hw_version=self._host.api.hardware_version,
|
||||
sw_version=self._host.api.sw_version,
|
||||
configuration_url=conf_url,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._host.api.session_active
|
||||
return self._host.api.session_active and super().available
|
||||
|
||||
6
homeassistant/components/reolink/exceptions.py
Normal file
6
homeassistant/components/reolink/exceptions.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Exceptions for the Reolink Camera integration."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class UserNotAdmin(HomeAssistantError):
|
||||
"""Raised when user is not admin."""
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from reolink_ip.api import Host
|
||||
from reolink_ip.exceptions import (
|
||||
from reolink_aio.api import Host
|
||||
from reolink_aio.exceptions import (
|
||||
ApiError,
|
||||
CredentialsInvalidError,
|
||||
InvalidContentTypeError,
|
||||
@@ -16,7 +18,8 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DEFAULT_TIMEOUT
|
||||
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT
|
||||
from .exceptions import UserNotAdmin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,18 +30,14 @@ class ReolinkHost:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: dict,
|
||||
options: dict,
|
||||
config: Mapping[str, Any],
|
||||
options: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Initialize Reolink Host. Could be either NVR, or Camera."""
|
||||
self._hass: HomeAssistant = hass
|
||||
|
||||
self._clientsession: aiohttp.ClientSession | None = None
|
||||
self._unique_id: str | None = None
|
||||
|
||||
cur_protocol = (
|
||||
DEFAULT_PROTOCOL if CONF_PROTOCOL not in options else options[CONF_PROTOCOL]
|
||||
)
|
||||
self._unique_id: str = ""
|
||||
|
||||
self._api = Host(
|
||||
config[CONF_HOST],
|
||||
@@ -46,12 +45,12 @@ class ReolinkHost:
|
||||
config[CONF_PASSWORD],
|
||||
port=config.get(CONF_PORT),
|
||||
use_https=config.get(CONF_USE_HTTPS),
|
||||
protocol=cur_protocol,
|
||||
protocol=options[CONF_PROTOCOL],
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
def unique_id(self) -> str:
|
||||
"""Create the unique ID, base for all entities."""
|
||||
return self._unique_id
|
||||
|
||||
@@ -70,6 +69,12 @@ class ReolinkHost:
|
||||
if self._api.mac_address is None:
|
||||
return False
|
||||
|
||||
if not self._api.is_admin:
|
||||
await self.stop()
|
||||
raise UserNotAdmin(
|
||||
f"User '{self._api.username}' has authorization level '{self._api.user_level}', only admin users can change camera settings"
|
||||
)
|
||||
|
||||
enable_onvif = None
|
||||
enable_rtmp = None
|
||||
enable_rtsp = None
|
||||
@@ -99,23 +104,22 @@ class ReolinkHost:
|
||||
):
|
||||
if enable_onvif:
|
||||
_LOGGER.error(
|
||||
"Unable to switch on ONVIF on %s. You need it to be ON to receive notifications",
|
||||
"Failed to enable ONVIF on %s. Set it to ON to receive notifications",
|
||||
self._api.nvr_name,
|
||||
)
|
||||
|
||||
if enable_rtmp:
|
||||
_LOGGER.error(
|
||||
"Unable to switch on RTMP on %s. You need it to be ON",
|
||||
"Failed to enable RTMP on %s. Set it to ON",
|
||||
self._api.nvr_name,
|
||||
)
|
||||
elif enable_rtsp:
|
||||
_LOGGER.error(
|
||||
"Unable to switch on RTSP on %s. You need it to be ON",
|
||||
"Failed to enable RTSP on %s. Set it to ON",
|
||||
self._api.nvr_name,
|
||||
)
|
||||
|
||||
if self._unique_id is None:
|
||||
self._unique_id = format_mac(self._api.mac_address)
|
||||
self._unique_id = format_mac(self._api.mac_address)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
"name": "Reolink IP NVR/camera",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"requirements": ["reolink-ip==0.0.40"],
|
||||
"dependencies": ["webhook"],
|
||||
"after_dependencies": ["http"],
|
||||
"codeowners": ["@starkillerOG", "@JimStar"],
|
||||
"requirements": ["reolink-aio==0.2.1"],
|
||||
"codeowners": ["@starkillerOG"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["reolink-ip"]
|
||||
"loggers": ["reolink-aio"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user