forked from home-assistant/core
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df747276f | |||
| 77b06bc158 | |||
| db6d176658 | |||
| 973eb4f6d4 | |||
| 14401aa840 | |||
| b82ddb77bc | |||
| 22530f72f3 | |||
| a16bf358aa | |||
| 0924874d4b | |||
| a3ff783bc1 | |||
| e7d06e3f6a | |||
| a2fc870266 | |||
| ffcb107716 | |||
| 604a2ac327 | |||
| 1042f23a0a | |||
| 608ce2d5a0 | |||
| 2e989bdfcf | |||
| f10bfc961d | |||
| 1f57c8ed1a | |||
| dd47f0b698 | |||
| 08eabfd056 | |||
| 7b64eabde1 | |||
| 46808b1fc1 | |||
| 23cb75fe20 | |||
| d20496a1bc | |||
| 4496aeb327 | |||
| 519ec18a04 | |||
| c14269d09d | |||
| 81b1b04210 | |||
| 18768ad8a0 | |||
| d038db01ed | |||
| c51c18781d | |||
| e483c16d59 | |||
| 87b50fff54 | |||
| da9fbde83a | |||
| 6785e32683 | |||
| 7208cb49f1 | |||
| 5476b23d8b | |||
| 5d65db5168 | |||
| 509ebbc743 | |||
| abe6f1ab5b | |||
| ae26e60740 | |||
| f8f381afa3 | |||
| 7cc2af2a46 | |||
| 29aab7ad7a | |||
| 3b2b116c10 | |||
| 07438c07c9 | |||
| 0203228a11 | |||
| fe7521b503 | |||
| 24a6e90042 | |||
| 7387640524 | |||
| d7708d58ba | |||
| 10a4037ed3 | |||
| aefd675737 | |||
| 9a4a09b2f2 | |||
| 793bdebc13 | |||
| e66f0a68e7 | |||
| 5c70ddb7cb | |||
| 79501289f0 | |||
| 7ee148c650 | |||
| 1ddb0d255a | |||
| 89eed9c31e | |||
| 0cb0136b2f | |||
| 36eca38be2 | |||
| 0b470bb8fb | |||
| 030ac3d762 | |||
| b5b2c3cc0d | |||
| 7940aab4c5 | |||
| 2513347e27 | |||
| e6b784e4f2 | |||
| d080c31583 | |||
| e68dcff3f3 | |||
| 66fa6dff93 | |||
| d533aba4f9 | |||
| 700eaf8794 | |||
| 7583d9a409 | |||
| dc3ece447b | |||
| 2c0e406c1b | |||
| 67c808bde9 | |||
| 2fa2a2e6d4 | |||
| 8735395144 | |||
| 428129cad7 | |||
| 64c52aecef | |||
| 04a2e1fd7b | |||
| bdc37e9353 | |||
| a581095bd0 | |||
| 707e501511 | |||
| 9f1701f557 | |||
| 61545edd96 | |||
| e09c85c591 | |||
| fecfbba442 | |||
| 13ce6edc68 | |||
| 816b5af883 | |||
| 78ada630c0 | |||
| 4ad904f3b7 | |||
| fa447332c6 | |||
| 8da3756602 | |||
| 01adc6a042 | |||
| d105e9f99e | |||
| 348079f069 | |||
| 86f5165e4c | |||
| b6d012222a | |||
| 0532c22069 | |||
| d1672a1e9a | |||
| 725e3046db | |||
| 325aa66b8c | |||
| 3ba07ce395 | |||
| 21463121a7 | |||
| ef0f3f7ce9 | |||
| cb371ef27c | |||
| 878700e26f | |||
| e09245eb14 | |||
| 20fb06484c | |||
| f4a38c0190 | |||
| fa33464217 | |||
| bd239bcbed | |||
| d5f3e2a761 | |||
| ec88a42948 | |||
| a3ede8f895 | |||
| 23ebde58cd | |||
| 0c87885f41 | |||
| c159790caf | |||
| 056575f491 | |||
| e4d9d0d83e | |||
| 34f728e5d2 | |||
| 377046bff5 | |||
| dd95b9b1e4 | |||
| a976ed2c72 | |||
| 90442d9e9e | |||
| c67b250be2 | |||
| 974cc94f87 | |||
| 528d4bc6ce | |||
| 7a4f1c3147 | |||
| c70f833069 | |||
| eaf53c10ed | |||
| 576362bfe1 | |||
| 4e957b1dbe | |||
| c4fe3d05f2 | |||
| f81055dc09 | |||
| 70814130c3 | |||
| 0e70121a6f | |||
| 62a60f1cf6 | |||
| 6a1dce852e | |||
| af1ad0e6f8 | |||
| dd2e250c66 | |||
| 18f36b9c0b | |||
| 2ba7f9c584 | |||
| 5a3dd71bde | |||
| 823a4578d7 | |||
| b5bfa728e9 | |||
| 11b343a513 | |||
| fe46b2664a | |||
| 53e2ebc688 | |||
| 4023d55229 | |||
| 823e46ea26 | |||
| 0b9efc2a06 | |||
| 6af9471710 | |||
| f78e59842d |
@@ -597,7 +597,6 @@ omit =
|
||||
homeassistant/components/lookin/models.py
|
||||
homeassistant/components/lookin/sensor.py
|
||||
homeassistant/components/lookin/climate.py
|
||||
homeassistant/components/loopenergy/sensor.py
|
||||
homeassistant/components/luci/device_tracker.py
|
||||
homeassistant/components/luftdaten/__init__.py
|
||||
homeassistant/components/luftdaten/sensor.py
|
||||
|
||||
@@ -293,7 +293,6 @@ homeassistant/components/local_ip/* @issacg
|
||||
homeassistant/components/logger/* @home-assistant/core
|
||||
homeassistant/components/logi_circle/* @evanjd
|
||||
homeassistant/components/lookin/* @ANMalko
|
||||
homeassistant/components/loopenergy/* @pavoni
|
||||
homeassistant/components/lovelace/* @home-assistant/frontend
|
||||
homeassistant/components/luci/* @mzdrale
|
||||
homeassistant/components/luftdaten/* @fabaff
|
||||
|
||||
@@ -252,8 +252,7 @@ async def async_from_config_dict(
|
||||
f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will "
|
||||
f"be removed in Home Assistant {REQUIRED_NEXT_PYTHON_HA_RELEASE}. "
|
||||
"Please upgrade Python to "
|
||||
f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or "
|
||||
"higher."
|
||||
f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])}."
|
||||
)
|
||||
_LOGGER.warning(msg)
|
||||
hass.components.persistent_notification.async_create(
|
||||
|
||||
@@ -695,6 +695,7 @@ class AlexaSpeaker(AlexaCapability):
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"es-MX",
|
||||
"fr-FR", # Not documented as of 2021-12-04, see PR #60489
|
||||
"it-IT",
|
||||
"ja-JP",
|
||||
}
|
||||
@@ -752,6 +753,7 @@ class AlexaStepSpeaker(AlexaCapability):
|
||||
"en-IN",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR", # Not documented as of 2021-12-04, see PR #60489
|
||||
"it-IT",
|
||||
}
|
||||
|
||||
|
||||
@@ -182,12 +182,13 @@ async def async_send_add_or_update_message(hass, config, entity_ids):
|
||||
endpoints = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
|
||||
if domain not in ENTITY_ADAPTERS:
|
||||
if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id))
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
continue
|
||||
|
||||
alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state)
|
||||
endpoints.append(alexa_entity.serialize_discovery())
|
||||
|
||||
payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}}
|
||||
|
||||
@@ -131,10 +131,9 @@ class BboxUptimeSensor(SensorEntity):
|
||||
def update(self):
|
||||
"""Get the latest data from Bbox and update the state."""
|
||||
self.bbox_data.update()
|
||||
uptime = utcnow() - timedelta(
|
||||
self._attr_native_value = utcnow() - timedelta(
|
||||
seconds=self.bbox_data.router_infos["device"]["uptime"]
|
||||
)
|
||||
self._attr_native_value = uptime.replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
class BboxSensor(SensorEntity):
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
"no_smoke": "{entity_name} stopped detecting smoke",
|
||||
"sound": "{entity_name} started detecting sound",
|
||||
"no_sound": "{entity_name} stopped detecting sound",
|
||||
"is_tampered": "{entity_name} started detecting tampering",
|
||||
"is_not_tampered": "{entity_name} stopped detecting tampering",
|
||||
"tampered": "{entity_name} started detecting tampering",
|
||||
"not_tampered": "{entity_name} stopped detecting tampering",
|
||||
"update": "{entity_name} got an update available",
|
||||
"no_update": "{entity_name} became up-to-date",
|
||||
"vibration": "{entity_name} started detecting vibration",
|
||||
|
||||
@@ -35,7 +35,6 @@ from .const import (
|
||||
CONF_ACCOUNT,
|
||||
CONF_ALLOWED_REGIONS,
|
||||
CONF_READ_ONLY,
|
||||
CONF_USE_LOCATION,
|
||||
DATA_ENTRIES,
|
||||
DATA_HASS_CONFIG,
|
||||
)
|
||||
@@ -65,7 +64,6 @@ SERVICE_SCHEMA = vol.Schema(
|
||||
|
||||
DEFAULT_OPTIONS = {
|
||||
CONF_READ_ONLY: False,
|
||||
CONF_USE_LOCATION: False,
|
||||
}
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -215,13 +213,10 @@ def setup_account(
|
||||
password: str = entry.data[CONF_PASSWORD]
|
||||
region: str = entry.data[CONF_REGION]
|
||||
read_only: bool = entry.options[CONF_READ_ONLY]
|
||||
use_location: bool = entry.options[CONF_USE_LOCATION]
|
||||
|
||||
_LOGGER.debug("Adding new account %s", name)
|
||||
|
||||
pos = (
|
||||
(hass.config.latitude, hass.config.longitude) if use_location else (None, None)
|
||||
)
|
||||
pos = (hass.config.latitude, hass.config.longitude)
|
||||
cd_account = BMWConnectedDriveAccount(
|
||||
username, password, region, name, read_only, *pos
|
||||
)
|
||||
@@ -258,6 +253,13 @@ def setup_account(
|
||||
function_call = getattr(vehicle.remote_services, function_name)
|
||||
function_call()
|
||||
|
||||
if call.service in [
|
||||
"find_vehicle",
|
||||
"activate_air_conditioning",
|
||||
"deactivate_air_conditioning",
|
||||
]:
|
||||
cd_account.update()
|
||||
|
||||
if not read_only:
|
||||
# register the remote services
|
||||
for service in _SERVICE_MAP:
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_USE_LOCATION
|
||||
from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -115,10 +115,6 @@ class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow):
|
||||
CONF_READ_ONLY,
|
||||
default=self.config_entry.options.get(CONF_READ_ONLY, False),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_USE_LOCATION,
|
||||
default=self.config_entry.options.get(CONF_USE_LOCATION, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
|
||||
for vehicle in account.account.vehicles:
|
||||
entities.append(BMWDeviceTracker(account, vehicle))
|
||||
if not vehicle.status.is_vehicle_tracking_enabled:
|
||||
if not vehicle.is_vehicle_tracking_enabled:
|
||||
_LOGGER.info(
|
||||
"Tracking is (currently) disabled for vehicle %s (%s), defaulting to unknown",
|
||||
vehicle.name,
|
||||
@@ -83,6 +83,6 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
||||
self._attr_extra_state_attributes = self._attrs
|
||||
self._location = (
|
||||
self._vehicle.status.gps_position
|
||||
if self._vehicle.status.is_vehicle_tracking_enabled
|
||||
if self._vehicle.is_vehicle_tracking_enabled
|
||||
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.8.2"],
|
||||
"requirements": ["bimmer_connected==0.8.5"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
"step": {
|
||||
"account_options": {
|
||||
"data": {
|
||||
"read_only": "Read-only (only sensors and notify, no execution of services, no lock)",
|
||||
"use_location": "Use Home Assistant location for car location polls (required for non i3/i8 vehicles produced before 7/2014)"
|
||||
"read_only": "Read-only (only sensors and notify, no execution of services, no lock)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return
|
||||
|
||||
self._discovered[CONF_ACCESS_TOKEN] = token
|
||||
_, hub_name = await _validate_input(self.hass, self._discovered)
|
||||
try:
|
||||
_, hub_name = await _validate_input(self.hass, self._discovered)
|
||||
except InputValidationError:
|
||||
return
|
||||
self._discovered[CONF_NAME] = hub_name
|
||||
|
||||
async def async_step_zeroconf(
|
||||
|
||||
@@ -134,9 +134,6 @@ def get_data(hass: HomeAssistant, config: dict) -> CO2SignalResponse:
|
||||
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
raise UnknownError from err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
raise UnknownError from err
|
||||
|
||||
else:
|
||||
if "error" in data:
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError, get_data
|
||||
from . import APIRatelimitExceeded, CO2Error, InvalidAuth, get_data
|
||||
from .const import CONF_COUNTRY_CODE, DOMAIN
|
||||
from .util import get_extra_name
|
||||
|
||||
@@ -172,7 +172,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except APIRatelimitExceeded:
|
||||
errors["base"] = "api_ratelimit"
|
||||
except UnknownError:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -366,13 +366,11 @@ async def ignore_config_flow(hass, connection, msg):
|
||||
def entry_json(entry: config_entries.ConfigEntry) -> dict:
|
||||
"""Return JSON value of a config entry."""
|
||||
handler = config_entries.HANDLERS.get(entry.domain)
|
||||
supports_options = (
|
||||
# Guard in case handler is no longer registered (custom component etc)
|
||||
handler is not None
|
||||
# pylint: disable=comparison-with-callable
|
||||
and handler.async_get_options_flow
|
||||
!= config_entries.ConfigFlow.async_get_options_flow
|
||||
# work out if handler has support for options flow
|
||||
supports_options = handler is not None and handler.async_supports_options_flow(
|
||||
entry
|
||||
)
|
||||
|
||||
return {
|
||||
"entry_id": entry.entry_id,
|
||||
"domain": entry.domain,
|
||||
|
||||
@@ -10,7 +10,10 @@ from homeassistant.components.websocket_api.decorators import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_registry import DISABLED_USER, async_get_registry
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
RegistryEntryDisabler,
|
||||
async_get_registry,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass):
|
||||
@@ -75,7 +78,12 @@ async def websocket_get_entity(hass, connection, msg):
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("new_entity_id"): str,
|
||||
# We only allow setting disabled_by user via API.
|
||||
vol.Optional("disabled_by"): vol.Any(DISABLED_USER, None),
|
||||
vol.Optional("disabled_by"): vol.Any(
|
||||
None,
|
||||
vol.All(
|
||||
vol.Coerce(RegistryEntryDisabler), RegistryEntryDisabler.USER.value
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
async def websocket_update_entity(hass, connection, msg):
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.const import (
|
||||
POWER_KILO_WATT,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}"
|
||||
PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}"
|
||||
@@ -202,6 +203,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
name="Telegram timestamp",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
||||
state=dt_util.parse_datetime,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/consumption/gas/delivered",
|
||||
@@ -222,6 +224,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
name="Gas meter read",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
||||
state=dt_util.parse_datetime,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/electricity1",
|
||||
|
||||
@@ -274,14 +274,16 @@ async def ws_get_fossil_energy_consumption(
|
||||
) -> dict[datetime, float]:
|
||||
"""Combine multiple statistics, returns a dict indexed by start time."""
|
||||
result: defaultdict[datetime, float] = defaultdict(float)
|
||||
seen: defaultdict[datetime, set[str]] = defaultdict(set)
|
||||
|
||||
for statistics_id, stat in stats.items():
|
||||
if statistics_id not in statistic_ids:
|
||||
continue
|
||||
for period in stat:
|
||||
if period["sum"] is None:
|
||||
if period["sum"] is None or statistics_id in seen[period["start"]]:
|
||||
continue
|
||||
result[period["start"]] += period["sum"]
|
||||
seen[period["start"]].add(statistics_id)
|
||||
|
||||
return {key: result[key] for key in sorted(result)}
|
||||
|
||||
@@ -303,6 +305,8 @@ async def ws_get_fossil_energy_consumption(
|
||||
"""Reduce hourly deltas to daily or monthly deltas."""
|
||||
result: list[dict[str, Any]] = []
|
||||
deltas: list[float] = []
|
||||
if not stat_list:
|
||||
return result
|
||||
prev_stat: dict[str, Any] = stat_list[0]
|
||||
|
||||
# Loop over the hourly deltas + a fake entry to end the period
|
||||
|
||||
@@ -75,6 +75,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
envoy_reader.get_inverters = False
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if not entry.unique_id:
|
||||
try:
|
||||
serial = await envoy_reader.get_full_serial_number()
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
else:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=serial)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||
COORDINATOR: coordinator,
|
||||
NAME: name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow for Enphase Envoy integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +32,7 @@ ENVOY = "Envoy"
|
||||
CONF_SERIAL = "serial"
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader:
|
||||
"""Validate the user input allows us to connect."""
|
||||
envoy_reader = EnvoyReader(
|
||||
data[CONF_HOST],
|
||||
@@ -48,6 +49,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
except (RuntimeError, httpx.HTTPError) as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
return envoy_reader
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Enphase Envoy."""
|
||||
@@ -59,7 +62,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self.ip_address = None
|
||||
self.name = None
|
||||
self.username = None
|
||||
self.serial = None
|
||||
self._reauth_entry = None
|
||||
|
||||
@callback
|
||||
@@ -104,8 +106,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
self.serial = discovery_info.properties["serialnum"]
|
||||
await self.async_set_unique_id(self.serial)
|
||||
serial = discovery_info.properties["serialnum"]
|
||||
await self.async_set_unique_id(serial)
|
||||
self.ip_address = discovery_info.host
|
||||
self._abort_if_unique_id_configured({CONF_HOST: self.ip_address})
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
@@ -114,9 +116,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
and CONF_HOST in entry.data
|
||||
and entry.data[CONF_HOST] == self.ip_address
|
||||
):
|
||||
title = f"{ENVOY} {self.serial}" if entry.title == ENVOY else ENVOY
|
||||
title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry, title=title, unique_id=self.serial
|
||||
entry, title=title, unique_id=serial
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -132,6 +134,24 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
def _async_envoy_name(self) -> str:
|
||||
"""Return the name of the envoy."""
|
||||
if self.name:
|
||||
return self.name
|
||||
if self.unique_id:
|
||||
return f"{ENVOY} {self.unique_id}"
|
||||
return ENVOY
|
||||
|
||||
async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool:
|
||||
"""Set the unique id by fetching it from the envoy."""
|
||||
serial = None
|
||||
with contextlib.suppress(httpx.HTTPError):
|
||||
serial = await envoy_reader.get_full_serial_number()
|
||||
if serial:
|
||||
await self.async_set_unique_id(serial)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -145,7 +165,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
envoy_reader = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
@@ -155,21 +175,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
data = user_input.copy()
|
||||
if self.serial:
|
||||
data[CONF_NAME] = f"{ENVOY} {self.serial}"
|
||||
else:
|
||||
data[CONF_NAME] = self.name or ENVOY
|
||||
data[CONF_NAME] = self._async_envoy_name()
|
||||
|
||||
if self._reauth_entry:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._reauth_entry,
|
||||
data=data,
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
if not self.unique_id and await self._async_set_unique_id_from_envoy(
|
||||
envoy_reader
|
||||
):
|
||||
data[CONF_NAME] = self._async_envoy_name()
|
||||
|
||||
if self.unique_id:
|
||||
self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]})
|
||||
|
||||
return self.async_create_entry(title=data[CONF_NAME], data=data)
|
||||
|
||||
if self.serial:
|
||||
if self.unique_id:
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_SERIAL: self.serial,
|
||||
CONF_SERIAL: self.unique_id,
|
||||
CONF_HOST: self.ip_address,
|
||||
}
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Enphase Envoy",
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"requirements": [
|
||||
"envoy_reader==0.20.0"
|
||||
"envoy_reader==0.20.1"
|
||||
],
|
||||
"codeowners": [
|
||||
"@gtdiehl"
|
||||
@@ -15,4 +15,4 @@
|
||||
}
|
||||
],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"flow_title": "{serial} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"host": "Host",
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
}
|
||||
},
|
||||
"description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Sensor platform for the Flipr's pool_sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
@@ -60,7 +58,4 @@ class FliprSensor(FliprEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""State of the sensor."""
|
||||
state = self.coordinator.data[self.entity_description.key]
|
||||
if isinstance(state, datetime):
|
||||
return state.isoformat()
|
||||
return state
|
||||
return self.coordinator.data[self.entity_description.key]
|
||||
|
||||
@@ -115,8 +115,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(mac)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if entry.data[CONF_HOST] == host and not entry.unique_id:
|
||||
async_update_entry_from_discovery(self.hass, entry, device)
|
||||
if entry.data[CONF_HOST] == host:
|
||||
if not entry.unique_id:
|
||||
async_update_entry_from_discovery(self.hass, entry, device)
|
||||
return self.async_abort(reason="already_configured")
|
||||
self.context[CONF_HOST] = host
|
||||
for progress in self._async_in_progress():
|
||||
@@ -237,7 +238,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FluxLEDDiscovery:
|
||||
"""Try to connect."""
|
||||
self._async_abort_entries_match({CONF_HOST: host})
|
||||
if device := await async_discover_device(self.hass, host):
|
||||
if (device := await async_discover_device(self.hass, host)) and device[
|
||||
ATTR_MODEL_DESCRIPTION
|
||||
]:
|
||||
# Older models do not return enough information
|
||||
# to build the model description via UDP so we have
|
||||
# to fallback to making a tcp connection to avoid
|
||||
# identifying the device as the chip model number
|
||||
# AKA `HF-LPB100-ZJ200`
|
||||
return device
|
||||
bulb = async_wifi_bulb_for_host(host)
|
||||
try:
|
||||
|
||||
@@ -280,10 +280,11 @@ class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity):
|
||||
|
||||
async def _async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the specified or all lights on."""
|
||||
if not self.is_on:
|
||||
await self._device.async_turn_on()
|
||||
if not kwargs:
|
||||
return
|
||||
if self._device.requires_turn_on or not kwargs:
|
||||
if not self.is_on:
|
||||
await self._device.async_turn_on()
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
if MODE_ATTRS.intersection(kwargs):
|
||||
await self._async_set_mode(**kwargs)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Flux LED/MagicHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/flux_led",
|
||||
"requirements": ["flux_led==0.25.10"],
|
||||
"requirements": ["flux_led==0.26.7"],
|
||||
"quality_scale": "platinum",
|
||||
"codeowners": ["@icemanch"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -784,15 +784,22 @@ class MeterSensor(_FroniusSensorEntity):
|
||||
self._entity_id_prefix = f"meter_{solar_net_id}"
|
||||
super().__init__(coordinator, key, solar_net_id)
|
||||
meter_data = self._device_data()
|
||||
# S0 meters connected directly to inverters respond "n.a." as serial number
|
||||
# `model` contains the inverter id: "S0 Meter at inverter 1"
|
||||
if (meter_uid := meter_data["serial"]["value"]) == "n.a.":
|
||||
meter_uid = (
|
||||
f"{coordinator.solar_net.solar_net_device_id}:"
|
||||
f'{meter_data["model"]["value"]}'
|
||||
)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, meter_data["serial"]["value"])},
|
||||
identifiers={(DOMAIN, meter_uid)},
|
||||
manufacturer=meter_data["manufacturer"]["value"],
|
||||
model=meter_data["model"]["value"],
|
||||
name=meter_data["model"]["value"],
|
||||
via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id),
|
||||
)
|
||||
self._attr_unique_id = f'{meter_data["serial"]["value"]}-{key}'
|
||||
self._attr_unique_id = f"{meter_uid}-{key}"
|
||||
|
||||
|
||||
class OhmpilotSensor(_FroniusSensorEntity):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20211203.0"
|
||||
"home-assistant-frontend==20211212.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -544,7 +544,7 @@ class GTFSDepartureSensor(SensorEntity):
|
||||
self._available = False
|
||||
self._icon = ICON
|
||||
self._name = ""
|
||||
self._state: str | None = None
|
||||
self._state: datetime.datetime | None = None
|
||||
self._attributes: dict[str, Any] = {}
|
||||
|
||||
self._agency = None
|
||||
@@ -563,7 +563,7 @@ class GTFSDepartureSensor(SensorEntity):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
def native_value(self) -> datetime.datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@@ -619,9 +619,7 @@ class GTFSDepartureSensor(SensorEntity):
|
||||
if not self._departure:
|
||||
self._state = None
|
||||
else:
|
||||
self._state = dt_util.as_utc(
|
||||
self._departure["departure_time"]
|
||||
).isoformat()
|
||||
self._state = self._departure["departure_time"]
|
||||
|
||||
# Fetch trip and route details once, unless updated
|
||||
if not self._departure:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aioguardian import Client
|
||||
from aioguardian.errors import GuardianError
|
||||
@@ -11,6 +11,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_FILENAME,
|
||||
CONF_IP_ADDRESS,
|
||||
@@ -18,7 +20,11 @@ from homeassistant.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
@@ -63,20 +69,41 @@ SERVICES = (
|
||||
SERVICE_NAME_UPGRADE_FIRMWARE,
|
||||
)
|
||||
|
||||
SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
vol.Required(CONF_UID): cv.string,
|
||||
}
|
||||
SERVICE_BASE_SCHEMA = vol.All(
|
||||
cv.deprecated(ATTR_ENTITY_ID),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
|
||||
)
|
||||
|
||||
SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
vol.Optional(CONF_URL): cv.url,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_FILENAME): cv.string,
|
||||
},
|
||||
SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.All(
|
||||
cv.deprecated(ATTR_ENTITY_ID),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_UID): cv.string,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
|
||||
)
|
||||
|
||||
SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.All(
|
||||
cv.deprecated(ATTR_ENTITY_ID),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_URL): cv.url,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_FILENAME): cv.string,
|
||||
},
|
||||
),
|
||||
cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +113,14 @@ PLATFORMS = ["binary_sensor", "sensor", "switch"]
|
||||
@callback
|
||||
def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str:
|
||||
"""Get the entry ID related to a service call (by device ID)."""
|
||||
if ATTR_ENTITY_ID in call.data:
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_registry_entry = entity_registry.async_get(call.data[ATTR_ENTITY_ID])
|
||||
if TYPE_CHECKING:
|
||||
assert entity_registry_entry
|
||||
assert entity_registry_entry.config_entry_id
|
||||
return entity_registry_entry.config_entry_id
|
||||
|
||||
device_id = call.data[CONF_DEVICE_ID]
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -221,15 +256,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
for service_name, schema, method in (
|
||||
(SERVICE_NAME_DISABLE_AP, None, async_disable_ap),
|
||||
(SERVICE_NAME_ENABLE_AP, None, async_enable_ap),
|
||||
(SERVICE_NAME_DISABLE_AP, SERVICE_BASE_SCHEMA, async_disable_ap),
|
||||
(SERVICE_NAME_ENABLE_AP, SERVICE_BASE_SCHEMA, async_enable_ap),
|
||||
(
|
||||
SERVICE_NAME_PAIR_SENSOR,
|
||||
SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA,
|
||||
async_pair_sensor,
|
||||
),
|
||||
(SERVICE_NAME_REBOOT, None, async_reboot),
|
||||
(SERVICE_NAME_RESET_VALVE_DIAGNOSTICS, None, async_reset_valve_diagnostics),
|
||||
(SERVICE_NAME_REBOOT, SERVICE_BASE_SCHEMA, async_reboot),
|
||||
(
|
||||
SERVICE_NAME_RESET_VALVE_DIAGNOSTICS,
|
||||
SERVICE_BASE_SCHEMA,
|
||||
async_reset_valve_diagnostics,
|
||||
),
|
||||
(
|
||||
SERVICE_NAME_UNPAIR_SENSOR,
|
||||
SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA,
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.const import (
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_NAME,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
HASSIO_USER_NAME,
|
||||
SERVICE_HOMEASSISTANT_RESTART,
|
||||
SERVICE_HOMEASSISTANT_STOP,
|
||||
)
|
||||
@@ -439,11 +440,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
# Migrate old name
|
||||
if user.name == "Hass.io":
|
||||
await hass.auth.async_update_user(user, name="Supervisor")
|
||||
await hass.auth.async_update_user(user, name=HASSIO_USER_NAME)
|
||||
|
||||
if refresh_token is None:
|
||||
user = await hass.auth.async_create_system_user(
|
||||
"Supervisor", group_ids=[GROUP_ID_ADMIN]
|
||||
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
refresh_token = await hass.auth.async_create_refresh_token(user)
|
||||
data["hassio_user"] = user.id
|
||||
|
||||
@@ -255,3 +255,5 @@ async def _websocket_forward(ws_from, ws_to):
|
||||
await ws_to.close(code=ws_to.close_code, message=msg.extra)
|
||||
except RuntimeError:
|
||||
_LOGGER.debug("Ingress Websocket runtime error")
|
||||
except ConnectionResetError:
|
||||
_LOGGER.debug("Ingress Websocket Connection Reset")
|
||||
|
||||
@@ -63,16 +63,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
elif (
|
||||
self._state is not None
|
||||
and self._sign == 1
|
||||
and dt_util.parse_datetime(self._state) < dt_util.utcnow()
|
||||
and self._state < dt_util.utcnow()
|
||||
):
|
||||
# if the date is supposed to be in the future but we're
|
||||
# already past it, set state to None.
|
||||
self._state = None
|
||||
else:
|
||||
seconds = self._sign * float(status[self._key][ATTR_VALUE])
|
||||
self._state = (
|
||||
dt_util.utcnow() + timedelta(seconds=seconds)
|
||||
).isoformat()
|
||||
self._state = dt_util.utcnow() + timedelta(seconds=seconds)
|
||||
else:
|
||||
self._state = status[self._key].get(ATTR_VALUE)
|
||||
if self._key == BSH_OPERATION_STATE:
|
||||
|
||||
@@ -249,14 +249,17 @@ class OpeningDeviceBase(HomeAccessory):
|
||||
def async_update_state(self, new_state):
|
||||
"""Update cover position and tilt after state changed."""
|
||||
# update tilt
|
||||
if not self._supports_tilt:
|
||||
return
|
||||
current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
|
||||
if isinstance(current_tilt, (float, int)):
|
||||
# HomeKit sends values between -90 and 90.
|
||||
# We'll have to normalize to [0,100]
|
||||
current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
|
||||
current_tilt = int(current_tilt)
|
||||
self.char_current_tilt.set_value(current_tilt)
|
||||
self.char_target_tilt.set_value(current_tilt)
|
||||
if not isinstance(current_tilt, (float, int)):
|
||||
return
|
||||
# HomeKit sends values between -90 and 90.
|
||||
# We'll have to normalize to [0,100]
|
||||
current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
|
||||
current_tilt = int(current_tilt)
|
||||
self.char_current_tilt.set_value(current_tilt)
|
||||
self.char_target_tilt.set_value(current_tilt)
|
||||
|
||||
|
||||
class OpeningDevice(OpeningDeviceBase, HomeAccessory):
|
||||
|
||||
@@ -50,6 +50,14 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return HueOptionsFlowHandler(config_entry)
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_supports_options_flow(
|
||||
cls, config_entry: config_entries.ConfigEntry
|
||||
) -> bool:
|
||||
"""Return options flow support for this handler."""
|
||||
return config_entry.data.get(CONF_API_VERSION, 1) == 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Hue flow."""
|
||||
self.bridge: DiscoveredHueBridge | None = None
|
||||
@@ -216,16 +224,17 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not url.hostname:
|
||||
return self.async_abort(reason="not_hue_bridge")
|
||||
|
||||
bridge = await self._get_bridge(
|
||||
# abort if we already have exactly this bridge id/host
|
||||
# reload the integration if the host got updated
|
||||
bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL])
|
||||
await self.async_set_unique_id(bridge_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: url.hostname}, reload_on_update=True
|
||||
)
|
||||
|
||||
self.bridge = await self._get_bridge(
|
||||
url.hostname, discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(bridge.id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: bridge.host}, reload_on_update=False
|
||||
)
|
||||
|
||||
self.bridge = bridge
|
||||
return await self.async_step_link()
|
||||
|
||||
async def async_step_zeroconf(
|
||||
@@ -236,17 +245,18 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
This flow is triggered by the Zeroconf component. It will check if the
|
||||
host is already configured and delegate to the import step if not.
|
||||
"""
|
||||
bridge = await self._get_bridge(
|
||||
discovery_info.host,
|
||||
discovery_info.properties["bridgeid"],
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(bridge.id)
|
||||
# abort if we already have exactly this bridge id/host
|
||||
# reload the integration if the host got updated
|
||||
bridge_id = normalize_bridge_id(discovery_info.properties["bridgeid"])
|
||||
await self.async_set_unique_id(bridge_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: bridge.host}, reload_on_update=False
|
||||
updates={CONF_HOST: discovery_info.host}, reload_on_update=True
|
||||
)
|
||||
|
||||
self.bridge = bridge
|
||||
# we need to query the other capabilities too
|
||||
self.bridge = await self._get_bridge(
|
||||
discovery_info.host, discovery_info.properties["bridgeid"]
|
||||
)
|
||||
return await self.async_step_link()
|
||||
|
||||
async def async_step_homekit(
|
||||
@@ -290,10 +300,6 @@ class HueOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
if self.config_entry.data.get(CONF_API_VERSION, 1) > 1:
|
||||
# Options for Hue are only applicable to V1 bridges.
|
||||
return self.async_show_form(step_id="init")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Philips Hue",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hue",
|
||||
"requirements": ["aiohue==3.0.1"],
|
||||
"requirements": ["aiohue==3.0.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from aiohue import HueBridgeV2
|
||||
from aiohue.discovery import is_v2_bridge
|
||||
from aiohue.v2.models.device import DeviceArchetypes
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
|
||||
from homeassistant import core
|
||||
@@ -18,7 +19,10 @@ from homeassistant.const import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
)
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.device_registry import async_get as async_get_device_registry
|
||||
from homeassistant.helpers.device_registry import (
|
||||
async_entries_for_config_entry as devices_for_config_entries,
|
||||
async_get as async_get_device_registry,
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
async_entries_for_config_entry as entities_for_config_entry,
|
||||
async_entries_for_device,
|
||||
@@ -82,6 +86,18 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N
|
||||
dev_reg = async_get_device_registry(hass)
|
||||
ent_reg = async_get_entity_registry(hass)
|
||||
LOGGER.info("Start of migration of devices and entities to support API schema 2")
|
||||
|
||||
# Create mapping of mac address to HA device id's.
|
||||
# Identifier in dev reg should be mac-address,
|
||||
# but in some cases it has a postfix like `-0b` or `-01`.
|
||||
dev_ids = {}
|
||||
for hass_dev in devices_for_config_entries(dev_reg, entry.entry_id):
|
||||
for domain, mac in hass_dev.identifiers:
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
normalized_mac = mac.split("-")[0]
|
||||
dev_ids[normalized_mac] = hass_dev.id
|
||||
|
||||
# initialize bridge connection just for the migration
|
||||
async with HueBridgeV2(host, api_key, websession) as api:
|
||||
|
||||
@@ -92,81 +108,93 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N
|
||||
DEVICE_CLASS_TEMPERATURE: ResourceTypes.TEMPERATURE,
|
||||
}
|
||||
|
||||
# handle entities attached to device
|
||||
# migrate entities attached to a device
|
||||
for hue_dev in api.devices:
|
||||
zigbee = api.devices.get_zigbee_connectivity(hue_dev.id)
|
||||
if not zigbee:
|
||||
# not a zigbee device
|
||||
if not zigbee or not zigbee.mac_address:
|
||||
# not a zigbee device or invalid mac
|
||||
continue
|
||||
mac = zigbee.mac_address
|
||||
# get/update existing device by V1 identifier (mac address)
|
||||
# the device will now have both the old and the new identifier
|
||||
identifiers = {(DOMAIN, hue_dev.id), (DOMAIN, mac)}
|
||||
hass_dev = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers=identifiers
|
||||
)
|
||||
LOGGER.info("Migrated device %s (%s)", hass_dev.name, hass_dev.id)
|
||||
# loop through al entities for device and find match
|
||||
for ent in async_entries_for_device(ent_reg, hass_dev.id, True):
|
||||
# migrate light
|
||||
if ent.entity_id.startswith("light"):
|
||||
# should always return one lightid here
|
||||
new_unique_id = next(iter(hue_dev.lights))
|
||||
if ent.unique_id == new_unique_id:
|
||||
continue # just in case
|
||||
LOGGER.info(
|
||||
"Migrating %s from unique id %s to %s",
|
||||
ent.entity_id,
|
||||
ent.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
ent_reg.async_update_entity(
|
||||
ent.entity_id, new_unique_id=new_unique_id
|
||||
)
|
||||
continue
|
||||
# migrate sensors
|
||||
matched_dev_class = sensor_class_mapping.get(
|
||||
ent.original_device_class or "unknown"
|
||||
|
||||
# get existing device by V1 identifier (mac address)
|
||||
if hue_dev.product_data.product_archetype == DeviceArchetypes.BRIDGE_V2:
|
||||
hass_dev_id = dev_ids.get(api.config.bridge_id.upper())
|
||||
else:
|
||||
hass_dev_id = dev_ids.get(zigbee.mac_address)
|
||||
if hass_dev_id is None:
|
||||
# can be safely ignored, this device does not exist in current config
|
||||
LOGGER.debug(
|
||||
"Ignoring device %s (%s) as it does not (yet) exist in the device registry",
|
||||
hue_dev.metadata.name,
|
||||
hue_dev.id,
|
||||
)
|
||||
if matched_dev_class is None:
|
||||
continue
|
||||
dev_reg.async_update_device(
|
||||
hass_dev_id, new_identifiers={(DOMAIN, hue_dev.id)}
|
||||
)
|
||||
LOGGER.info("Migrated device %s (%s)", hue_dev.metadata.name, hass_dev_id)
|
||||
|
||||
# loop through all entities for device and find match
|
||||
for ent in async_entries_for_device(ent_reg, hass_dev_id, True):
|
||||
|
||||
if ent.entity_id.startswith("light"):
|
||||
# migrate light
|
||||
# should always return one lightid here
|
||||
new_unique_id = next(iter(hue_dev.lights), None)
|
||||
else:
|
||||
# migrate sensors
|
||||
matched_dev_class = sensor_class_mapping.get(
|
||||
ent.original_device_class or "unknown"
|
||||
)
|
||||
new_unique_id = next(
|
||||
(
|
||||
sensor.id
|
||||
for sensor in api.devices.get_sensors(hue_dev.id)
|
||||
if sensor.type == matched_dev_class
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if new_unique_id is None:
|
||||
# this may happen if we're looking at orphaned or unsupported entity
|
||||
LOGGER.warning(
|
||||
"Skip migration of %s because it no longer exists on the bridge",
|
||||
ent.entity_id,
|
||||
)
|
||||
continue
|
||||
for sensor in api.devices.get_sensors(hue_dev.id):
|
||||
if sensor.type != matched_dev_class:
|
||||
continue
|
||||
new_unique_id = sensor.id
|
||||
if ent.unique_id == new_unique_id:
|
||||
break # just in case
|
||||
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
ent.entity_id, new_unique_id=new_unique_id
|
||||
)
|
||||
except ValueError:
|
||||
# assume edge case where the entity was already migrated in a previous run
|
||||
# which got aborted somehow and we do not want
|
||||
# to crash the entire integration init
|
||||
LOGGER.warning(
|
||||
"Skip migration of %s because it already exists",
|
||||
ent.entity_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.info(
|
||||
"Migrating %s from unique id %s to %s",
|
||||
"Migrated entity %s from unique id %s to %s",
|
||||
ent.entity_id,
|
||||
ent.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
ent.entity_id, new_unique_id=sensor.id
|
||||
)
|
||||
except ValueError:
|
||||
# assume edge case where the entity was already migrated in a previous run
|
||||
# which got aborted somehow and we do not want
|
||||
# to crash the entire integration init
|
||||
LOGGER.warning(
|
||||
"Skip migration of %s because it already exists",
|
||||
ent.entity_id,
|
||||
)
|
||||
break
|
||||
|
||||
# migrate entities that are not connected to a device (groups)
|
||||
for ent in entities_for_config_entry(ent_reg, entry.entry_id):
|
||||
if ent.device_id is not None:
|
||||
continue
|
||||
v1_id = f"/groups/{ent.unique_id}"
|
||||
hue_group = api.groups.room.get_by_v1_id(v1_id)
|
||||
if "-" in ent.unique_id:
|
||||
# handle case where unique id is v2-id of group/zone
|
||||
hue_group = api.groups.get(ent.unique_id)
|
||||
else:
|
||||
# handle case where the unique id is just the v1 id
|
||||
v1_id = f"/groups/{ent.unique_id}"
|
||||
hue_group = api.groups.room.get_by_v1_id(
|
||||
v1_id
|
||||
) or api.groups.zone.get_by_v1_id(v1_id)
|
||||
if hue_group is None or hue_group.grouped_light is None:
|
||||
# this may happen if we're looking at some orphaned entity
|
||||
LOGGER.warning(
|
||||
|
||||
@@ -96,8 +96,8 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
|
||||
"""Activate Hue scene."""
|
||||
transition = kwargs.get("transition")
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
# hue transition duration is in milliseconds
|
||||
transition = int(transition * 1000)
|
||||
dynamic = kwargs.get("dynamic", self.is_dynamic)
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.recall,
|
||||
|
||||
@@ -49,7 +49,8 @@ async def async_setup_devices(bridge: "HueBridge"):
|
||||
params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id))
|
||||
else:
|
||||
params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id)
|
||||
if zigbee := dev_controller.get_zigbee_connectivity(hue_device.id):
|
||||
zigbee = dev_controller.get_zigbee_connectivity(hue_device.id)
|
||||
if zigbee and zigbee.mac_address:
|
||||
params[ATTR_CONNECTIONS] = {
|
||||
(device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,19 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_BUTTON_EVENT_TYPES = (
|
||||
# all except `DOUBLE_SHORT_RELEASE`
|
||||
ButtonEvent.INITIAL_PRESS,
|
||||
ButtonEvent.REPEAT,
|
||||
ButtonEvent.SHORT_RELEASE,
|
||||
ButtonEvent.LONG_RELEASE,
|
||||
)
|
||||
|
||||
DEVICE_SPECIFIC_EVENT_TYPES = {
|
||||
# device specific overrides of specific supported button events
|
||||
"Hue tap switch": (ButtonEvent.INITIAL_PRESS,),
|
||||
}
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
bridge: "HueBridge",
|
||||
@@ -84,18 +97,21 @@ async def async_get_triggers(bridge: "HueBridge", device_entry: DeviceEntry):
|
||||
hue_dev_id = get_hue_device_id(device_entry)
|
||||
# extract triggers from all button resources of this Hue device
|
||||
triggers = []
|
||||
model_id = api.devices[hue_dev_id].product_data.product_name
|
||||
for resource in api.devices.get_sensors(hue_dev_id):
|
||||
if resource.type != ResourceTypes.BUTTON:
|
||||
continue
|
||||
for event_type in (x.value for x in ButtonEvent if x != ButtonEvent.UNKNOWN):
|
||||
for event_type in DEVICE_SPECIFIC_EVENT_TYPES.get(
|
||||
model_id, DEFAULT_BUTTON_EVENT_TYPES
|
||||
):
|
||||
triggers.append(
|
||||
{
|
||||
CONF_DEVICE_ID: device_entry.id,
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_PLATFORM: "device",
|
||||
CONF_TYPE: event_type,
|
||||
CONF_TYPE: event_type.value,
|
||||
CONF_SUBTYPE: resource.metadata.control_id,
|
||||
CONF_UNIQUE_ID: device_entry.id,
|
||||
CONF_UNIQUE_ID: resource.id,
|
||||
}
|
||||
)
|
||||
return triggers
|
||||
|
||||
@@ -64,7 +64,7 @@ class HueBaseEntity(Entity):
|
||||
type_title = RESOURCE_TYPE_NAMES.get(
|
||||
self.resource.type, self.resource.type.value.replace("_", " ").title()
|
||||
)
|
||||
return f"{dev_name}: {type_title}"
|
||||
return f"{dev_name} {type_title}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added."""
|
||||
@@ -103,6 +103,9 @@ class HueBaseEntity(Entity):
|
||||
if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY:
|
||||
# the zigbee connectivity sensor itself should be always available
|
||||
return True
|
||||
if self.device.product_data.manufacturer_name != "Signify Netherlands B.V.":
|
||||
# availability status for non-philips brand lights is unreliable
|
||||
return True
|
||||
if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id):
|
||||
# all device-attached entities get availability from the zigbee connectivity
|
||||
return zigbee.status == ConnectivityServiceStatus.CONNECTED
|
||||
|
||||
@@ -7,7 +7,6 @@ from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.groups import GroupedLight, Room, Zone
|
||||
|
||||
from homeassistant.components.group.light import LightGroup
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
@@ -18,13 +17,14 @@ from homeassistant.components.light import (
|
||||
COLOR_MODE_ONOFF,
|
||||
COLOR_MODE_XY,
|
||||
SUPPORT_TRANSITION,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from ..bridge import HueBridge
|
||||
from ..const import DOMAIN
|
||||
from ..const import CONF_ALLOW_HUE_GROUPS, DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
|
||||
ALLOWED_ERRORS = [
|
||||
@@ -73,11 +73,10 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class GroupedHueLight(HueBaseEntity, LightGroup):
|
||||
class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
"""Representation of a Grouped Hue light."""
|
||||
|
||||
# Entities for Hue groups are disabled by default
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_icon = "mdi:lightbulb-group"
|
||||
|
||||
def __init__(
|
||||
self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone
|
||||
@@ -91,6 +90,12 @@ class GroupedHueLight(HueBaseEntity, LightGroup):
|
||||
self.api: HueBridgeV2 = bridge.api
|
||||
self._attr_supported_features |= SUPPORT_TRANSITION
|
||||
|
||||
# Entities for Hue groups are disabled by default
|
||||
# unless they were enabled in old version (legacy option)
|
||||
self._attr_entity_registry_enabled_default = bridge.config_entry.data.get(
|
||||
CONF_ALLOW_HUE_GROUPS, False
|
||||
)
|
||||
|
||||
self._update_values()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -145,8 +150,8 @@ class GroupedHueLight(HueBaseEntity, LightGroup):
|
||||
# Hue uses a range of [0, 100] to control brightness.
|
||||
brightness = float((brightness / 255) * 100)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
# hue transition duration is in milliseconds
|
||||
transition = int(transition * 1000)
|
||||
|
||||
# NOTE: a grouped_light can only handle turn on/off
|
||||
# To set other features, you'll have to control the attached lights
|
||||
|
||||
@@ -158,8 +158,8 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
# Hue uses a range of [0, 100] to control brightness.
|
||||
brightness = float((brightness / 255) * 100)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
# hue transition duration is in milliseconds
|
||||
transition = int(transition * 1000)
|
||||
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
@@ -176,8 +176,8 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
"""Turn the light off."""
|
||||
transition = kwargs.get(ATTR_TRANSITION)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
# hue transition duration is in milliseconds
|
||||
transition = int(transition * 1000)
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "hunterdouglas_powerview",
|
||||
"name": "Hunter Douglas PowerView",
|
||||
"documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview",
|
||||
"requirements": ["aiopvapi==1.6.14"],
|
||||
"requirements": ["aiopvapi==1.6.19"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"homekit": {
|
||||
|
||||
@@ -116,7 +116,7 @@ class HVVDepartureSensor(SensorEntity):
|
||||
departure_time
|
||||
+ timedelta(minutes=departure["timeOffset"])
|
||||
+ timedelta(seconds=delay)
|
||||
).isoformat()
|
||||
)
|
||||
|
||||
self.attr.update(
|
||||
{
|
||||
|
||||
@@ -83,4 +83,4 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
_LOGGER.debug("New cycle time: %s", next_cycle)
|
||||
self._attr_native_value = dt.utc_from_timestamp(
|
||||
dt.as_timestamp(dt.now()) + next_cycle
|
||||
).isoformat()
|
||||
)
|
||||
|
||||
@@ -45,10 +45,8 @@ class IslamicPrayerTimeSensor(SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return (
|
||||
self.client.prayer_times_info.get(self.sensor_type)
|
||||
.astimezone(dt_util.UTC)
|
||||
.isoformat()
|
||||
return self.client.prayer_times_info.get(self.sensor_type).astimezone(
|
||||
dt_util.UTC
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
|
||||
@@ -390,6 +390,7 @@ class KNXModule:
|
||||
connection_type=ConnectionType.TUNNELING,
|
||||
gateway_ip=self.config[CONF_HOST],
|
||||
gateway_port=self.config[CONF_PORT],
|
||||
local_ip=self.config.get(ConnectionSchema.CONF_KNX_LOCAL_IP),
|
||||
route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False),
|
||||
auto_reconnect=True,
|
||||
)
|
||||
|
||||
@@ -121,6 +121,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK: user_input[
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK
|
||||
],
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP: user_input.get(
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP
|
||||
),
|
||||
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
|
||||
},
|
||||
)
|
||||
@@ -134,6 +137,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK, default=False
|
||||
): vol.Coerce(bool),
|
||||
vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -243,6 +247,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
**DEFAULT_ENTRY_DATA,
|
||||
CONF_HOST: config[CONF_KNX_TUNNELING][CONF_HOST],
|
||||
CONF_PORT: config[CONF_KNX_TUNNELING][CONF_PORT],
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP: config[CONF_KNX_TUNNELING].get(
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP
|
||||
),
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK: config[CONF_KNX_TUNNELING][
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK
|
||||
],
|
||||
@@ -299,6 +306,7 @@ class KNXOptionsFlowHandler(OptionsFlow):
|
||||
vol.Required(
|
||||
CONF_PORT, default=self.current_config.get(CONF_PORT, 3671)
|
||||
): cv.port,
|
||||
vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str,
|
||||
vol.Required(
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK,
|
||||
default=self.current_config.get(
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"individual_address": "Individual address for the connection",
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
"route_back": "Route Back / NAT Mode",
|
||||
"local_ip": "Local IP (leave empty if unsure)"
|
||||
}
|
||||
},
|
||||
"routing": {
|
||||
@@ -55,7 +56,8 @@
|
||||
"data": {
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
"route_back": "Route Back / NAT Mode",
|
||||
"local_ip": "Local IP (leave empty if unsure)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"individual_address": "Individual address for the connection",
|
||||
"local_ip": "Local IP (leave empty if unsure)",
|
||||
"port": "Port",
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
},
|
||||
@@ -54,6 +55,7 @@
|
||||
"tunnel": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"local_ip": "Local IP (leave empty if unsure)",
|
||||
"port": "Port",
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Support for Litter-Robot sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pylitterbot.robot import Robot
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.components.sensor import SensorEntity, StateType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -36,7 +38,7 @@ class LitterRobotPropertySensor(LitterRobotEntity, SensorEntity):
|
||||
self.sensor_attribute = sensor_attribute
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state."""
|
||||
return getattr(self.robot, self.sensor_attribute)
|
||||
|
||||
@@ -59,10 +61,10 @@ class LitterRobotSleepTimeSensor(LitterRobotPropertySensor):
|
||||
"""Litter-Robot sleep time sensor."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state."""
|
||||
if self.robot.sleep_mode_enabled:
|
||||
return super().native_value.isoformat()
|
||||
return super().native_value
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The lookin integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
@@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
lookin_device = await lookin_protocol.get_info()
|
||||
devices = await lookin_protocol.get_devices()
|
||||
except aiohttp.ClientError as ex:
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
meteo_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiolookin import Climate, MeteoSensor, SensorID
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate.const import (
|
||||
ATTR_HVAC_MODE,
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
@@ -151,6 +152,28 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity):
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
return
|
||||
self._climate.temp_celsius = int(temperature)
|
||||
lookin_index = LOOKIN_HVAC_MODE_IDX_TO_HASS
|
||||
if hvac_mode := kwargs.get(ATTR_HVAC_MODE):
|
||||
self._climate.hvac_mode = HASS_TO_LOOKIN_HVAC_MODE[hvac_mode]
|
||||
elif self._climate.hvac_mode == lookin_index.index(HVAC_MODE_OFF):
|
||||
#
|
||||
# If the device is off, and the user didn't specify an HVAC mode
|
||||
# (which is the default when using the HA UI), the device won't turn
|
||||
# on without having an HVAC mode passed.
|
||||
#
|
||||
# We picked the hvac mode based on the current temp if its available
|
||||
# since only some units support auto, but most support either heat
|
||||
# or cool otherwise we set auto since we don't have a way to make
|
||||
# an educated guess.
|
||||
#
|
||||
meteo_data: MeteoSensor = self._meteo_coordinator.data
|
||||
current_temp = meteo_data.temperature
|
||||
if not current_temp:
|
||||
self._climate.hvac_mode = lookin_index.index(HVAC_MODE_AUTO)
|
||||
elif current_temp >= self._climate.temp_celsius:
|
||||
self._climate.hvac_mode = lookin_index.index(HVAC_MODE_COOL)
|
||||
else:
|
||||
self._climate.hvac_mode = lookin_index.index(HVAC_MODE_HEAT)
|
||||
await self._async_update_conditioner()
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""The loopenergy component."""
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"domain": "loopenergy",
|
||||
"name": "Loop Energy",
|
||||
"documentation": "https://www.home-assistant.io/integrations/loopenergy",
|
||||
"requirements": ["pyloopenergy==0.2.1"],
|
||||
"codeowners": ["@pavoni"],
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Support for Loop Energy sensors."""
|
||||
import logging
|
||||
|
||||
import pyloopenergy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.const import (
|
||||
CONF_UNIT_SYSTEM_IMPERIAL,
|
||||
CONF_UNIT_SYSTEM_METRIC,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ELEC = "electricity"
|
||||
CONF_GAS = "gas"
|
||||
|
||||
CONF_ELEC_SERIAL = "electricity_serial"
|
||||
CONF_ELEC_SECRET = "electricity_secret"
|
||||
|
||||
CONF_GAS_SERIAL = "gas_serial"
|
||||
CONF_GAS_SECRET = "gas_secret"
|
||||
CONF_GAS_CALORIFIC = "gas_calorific"
|
||||
|
||||
CONF_GAS_TYPE = "gas_type"
|
||||
|
||||
DEFAULT_CALORIFIC = 39.11
|
||||
DEFAULT_UNIT = "kW"
|
||||
|
||||
ELEC_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ELEC_SERIAL): cv.string,
|
||||
vol.Required(CONF_ELEC_SECRET): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
GAS_TYPE_SCHEMA = vol.In([CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL])
|
||||
|
||||
GAS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GAS_SERIAL): cv.string,
|
||||
vol.Required(CONF_GAS_SECRET): cv.string,
|
||||
vol.Optional(CONF_GAS_TYPE, default=CONF_UNIT_SYSTEM_METRIC): GAS_TYPE_SCHEMA,
|
||||
vol.Optional(CONF_GAS_CALORIFIC, default=DEFAULT_CALORIFIC): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{vol.Required(CONF_ELEC): ELEC_SCHEMA, vol.Optional(CONF_GAS): GAS_SCHEMA}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Loop Energy sensors."""
|
||||
elec_config = config.get(CONF_ELEC)
|
||||
gas_config = config.get(CONF_GAS, {})
|
||||
|
||||
controller = pyloopenergy.LoopEnergy(
|
||||
elec_config.get(CONF_ELEC_SERIAL),
|
||||
elec_config.get(CONF_ELEC_SECRET),
|
||||
gas_config.get(CONF_GAS_SERIAL),
|
||||
gas_config.get(CONF_GAS_SECRET),
|
||||
gas_config.get(CONF_GAS_TYPE),
|
||||
gas_config.get(CONF_GAS_CALORIFIC),
|
||||
)
|
||||
|
||||
def stop_loopenergy(event):
|
||||
"""Shutdown loopenergy thread on exit."""
|
||||
_LOGGER.info("Shutting down loopenergy")
|
||||
controller.terminate()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_loopenergy)
|
||||
|
||||
sensors = [LoopEnergyElec(controller)]
|
||||
|
||||
if gas_config.get(CONF_GAS_SERIAL):
|
||||
sensors.append(LoopEnergyGas(controller))
|
||||
|
||||
add_entities(sensors)
|
||||
|
||||
|
||||
class LoopEnergySensor(SensorEntity):
|
||||
"""Implementation of an Loop Energy base sensor."""
|
||||
|
||||
def __init__(self, controller):
|
||||
"""Initialize the sensor."""
|
||||
self._state = None
|
||||
self._unit_of_measurement = DEFAULT_UNIT
|
||||
self._controller = controller
|
||||
self._name = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement of this entity, if any."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
def _callback(self):
|
||||
self.schedule_update_ha_state(True)
|
||||
|
||||
|
||||
class LoopEnergyElec(LoopEnergySensor):
|
||||
"""Implementation of an Loop Energy Electricity sensor."""
|
||||
|
||||
def __init__(self, controller):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(controller)
|
||||
self._name = "Power Usage"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to updates."""
|
||||
self._controller.subscribe_elecricity(self._callback)
|
||||
|
||||
def update(self):
|
||||
"""Get the cached Loop energy reading."""
|
||||
self._state = round(self._controller.electricity_useage, 2)
|
||||
|
||||
|
||||
class LoopEnergyGas(LoopEnergySensor):
|
||||
"""Implementation of an Loop Energy Gas sensor."""
|
||||
|
||||
def __init__(self, controller):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(controller)
|
||||
self._name = "Gas Usage"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to updates."""
|
||||
self._controller.subscribe_gas(self._callback)
|
||||
|
||||
def update(self):
|
||||
"""Get the cached Loop gas reading."""
|
||||
self._state = round(self._controller.gas_useage, 2)
|
||||
@@ -24,7 +24,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -175,11 +174,9 @@ async def async_setup_entry(hass, config_entry):
|
||||
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
|
||||
return False
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
luftdaten = LuftDatenData(
|
||||
Luftdaten(config_entry.data[CONF_SENSOR_ID], hass.loop, session),
|
||||
Luftdaten(config_entry.data[CONF_SENSOR_ID]),
|
||||
config_entry.data.get(CONF_SENSORS, {}).get(
|
||||
CONF_MONITORED_CONDITIONS, SENSOR_KEYS
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.const import (
|
||||
CONF_SHOW_ON_MAP,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
@@ -69,8 +68,7 @@ class LuftDatenFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if sensor_id in configured_sensors(self.hass):
|
||||
return self._show_form({CONF_SENSOR_ID: "already_configured"})
|
||||
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
luftdaten = Luftdaten(user_input[CONF_SENSOR_ID], self.hass.loop, session)
|
||||
luftdaten = Luftdaten(user_input[CONF_SENSOR_ID])
|
||||
try:
|
||||
await luftdaten.get_data()
|
||||
valid = await luftdaten.validate_sensor()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Luftdaten",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/luftdaten",
|
||||
"requirements": ["luftdaten==0.6.5"],
|
||||
"requirements": ["luftdaten==0.7.1"],
|
||||
"codeowners": ["@fabaff"],
|
||||
"quality_scale": "gold",
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for Lupusec Home Security system."""
|
||||
# pylint: disable=import-error
|
||||
import logging
|
||||
|
||||
import lupupy
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for Lupusec Security System binary sensors."""
|
||||
# pylint: disable=import-error
|
||||
from datetime import timedelta
|
||||
|
||||
import lupupy.constants as CONST
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"disabled": "Library has incompatible requirements.",
|
||||
"domain": "lupusec",
|
||||
"name": "Lupus Electronics LUPUSEC",
|
||||
"documentation": "https://www.home-assistant.io/integrations/lupusec",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for Lupusec Security System switches."""
|
||||
# pylint: disable=import-error
|
||||
from datetime import timedelta
|
||||
|
||||
import lupupy.constants as CONST
|
||||
|
||||
@@ -68,7 +68,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
hostname = discovery_info.hostname
|
||||
if hostname is None or not hostname.startswith("lutron-"):
|
||||
if hostname is None or not hostname.lower().startswith("lutron-"):
|
||||
return self.async_abort(reason="not_lutron_device")
|
||||
|
||||
self.lutron_id = hostname.split("-")[1].replace(".local.", "")
|
||||
|
||||
@@ -47,7 +47,7 @@ LYRIC_SETPOINT_STATUS_NAMES = {
|
||||
class LyricSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class describing Honeywell Lyric sensor entities."""
|
||||
|
||||
value: Callable[[LyricDevice], StateType] = round
|
||||
value: Callable[[LyricDevice], StateType | datetime] = round
|
||||
|
||||
|
||||
def get_datetime_from_future_time(time: str) -> datetime:
|
||||
@@ -133,7 +133,7 @@ async def async_setup_entry(
|
||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
||||
value=lambda device: get_datetime_from_future_time(
|
||||
device.changeableValues.nextPeriodTime
|
||||
).isoformat(),
|
||||
),
|
||||
),
|
||||
location,
|
||||
device,
|
||||
|
||||
@@ -142,11 +142,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor):
|
||||
(cadran for cadran in self.coordinator.data.forecast if cadran["rain"] > 1),
|
||||
None,
|
||||
)
|
||||
return (
|
||||
dt_util.utc_from_timestamp(next_rain["dt"]).isoformat()
|
||||
if next_rain
|
||||
else None
|
||||
)
|
||||
return dt_util.utc_from_timestamp(next_rain["dt"]) if next_rain else None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
|
||||
@@ -24,8 +24,6 @@ DOMAIN = "metoffice"
|
||||
DEFAULT_NAME = "Met Office"
|
||||
ATTRIBUTION = "Data provided by the Met Office"
|
||||
|
||||
ATTR_FORECAST_DAYTIME = "daytime"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
METOFFICE_COORDINATES = "metoffice_coordinates"
|
||||
|
||||
@@ -15,7 +15,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import get_device_info
|
||||
from .const import (
|
||||
ATTR_FORECAST_DAYTIME,
|
||||
ATTRIBUTION,
|
||||
CONDITION_CLASSES,
|
||||
DEFAULT_NAME,
|
||||
@@ -47,7 +46,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _build_forecast_data(timestep, use_3hourly):
|
||||
def _build_forecast_data(timestep):
|
||||
data = {}
|
||||
data[ATTR_FORECAST_TIME] = timestep.date.isoformat()
|
||||
if timestep.weather:
|
||||
@@ -60,9 +59,6 @@ def _build_forecast_data(timestep, use_3hourly):
|
||||
data[ATTR_FORECAST_WIND_BEARING] = timestep.wind_direction.value
|
||||
if timestep.wind_speed:
|
||||
data[ATTR_FORECAST_WIND_SPEED] = timestep.wind_speed.value
|
||||
if not use_3hourly:
|
||||
# if it's close to noon, mark as Day, otherwise as Night
|
||||
data[ATTR_FORECAST_DAYTIME] = abs(timestep.date.hour - 12) < 6
|
||||
return data
|
||||
|
||||
|
||||
@@ -86,7 +82,6 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity):
|
||||
)
|
||||
self._attr_name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]} {mode_label}"
|
||||
self._attr_unique_id = hass_data[METOFFICE_COORDINATES]
|
||||
self._use_3hourly = use_3hourly
|
||||
if not use_3hourly:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}"
|
||||
|
||||
@@ -160,7 +155,7 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity):
|
||||
if self.coordinator.data.forecast is None:
|
||||
return None
|
||||
return [
|
||||
_build_forecast_data(timestep, self._use_3hourly)
|
||||
_build_forecast_data(timestep)
|
||||
for timestep in self.coordinator.data.forecast
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""A entity class for mobile_app."""
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID
|
||||
from homeassistant.const import (
|
||||
ATTR_ICON,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_WEBHOOK_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -101,6 +107,11 @@ class MobileAppEntity(RestoreEntity):
|
||||
"""Return device registry information for this entity."""
|
||||
return device_info(self._registration)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE
|
||||
|
||||
@callback
|
||||
def _handle_update(self, data):
|
||||
"""Handle async event updates."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.const import (
|
||||
CONF_WEBHOOK_ID,
|
||||
DEVICE_CLASS_DATE,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -88,9 +89,11 @@ class MobileAppSensor(MobileAppEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN):
|
||||
return None
|
||||
|
||||
if (
|
||||
(state := self._config[ATTR_SENSOR_STATE]) is not None
|
||||
and self.device_class
|
||||
self.device_class
|
||||
in (
|
||||
DEVICE_CLASS_DATE,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
|
||||
@@ -73,7 +73,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor):
|
||||
self._attr_device_class = DEVICE_CLASS_TIMESTAMP
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
sleep_time: datetime = dt_util.utc_from_timestamp(
|
||||
self.coordinator.data.state.light_sleep_timer
|
||||
@@ -83,7 +83,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor):
|
||||
or (sleep_time - dt_util.utcnow()).total_seconds() < 0
|
||||
):
|
||||
return None
|
||||
return sleep_time.isoformat()
|
||||
return sleep_time
|
||||
|
||||
|
||||
class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor):
|
||||
@@ -103,7 +103,7 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor):
|
||||
self._attr_device_class = DEVICE_CLASS_TIMESTAMP
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
sleep_time: datetime = dt_util.utc_from_timestamp(
|
||||
self.coordinator.data.state.fan_sleep_timer
|
||||
@@ -115,4 +115,4 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor):
|
||||
):
|
||||
return None
|
||||
|
||||
return sleep_time.isoformat()
|
||||
return sleep_time
|
||||
|
||||
@@ -86,6 +86,11 @@ PLATFORMS = ["sensor", "camera", "climate"]
|
||||
WEB_AUTH_DOMAIN = DOMAIN
|
||||
INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed"
|
||||
|
||||
# Fetch media for events with an in memory cache. The largest media items
|
||||
# are mp4 clips at ~90kb each, so this totals a few MB per camera.
|
||||
# Note: Media for events can only be published within 30 seconds of the event
|
||||
EVENT_MEDIA_CACHE_SIZE = 64
|
||||
|
||||
|
||||
class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
|
||||
"""OAuth implementation using OAuth for web applications."""
|
||||
@@ -192,7 +197,7 @@ class SignalUpdateCallback:
|
||||
"device_id": device_entry.id,
|
||||
"type": event_type,
|
||||
"timestamp": event_message.timestamp,
|
||||
"nest_event_id": image_event.event_id,
|
||||
"nest_event_id": image_event.event_session_id,
|
||||
}
|
||||
self._hass.bus.async_fire(NEST_EVENT, message)
|
||||
|
||||
@@ -206,6 +211,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
subscriber = await api.new_subscriber(hass, entry)
|
||||
if not subscriber:
|
||||
return False
|
||||
# Keep media for last N events in memory
|
||||
subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE
|
||||
subscriber.cache_policy.fetch = True
|
||||
|
||||
callback = SignalUpdateCallback(hass)
|
||||
subscriber.set_update_callback(callback.async_handle_event)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ffmpeg", "http", "media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.0"],
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.6"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"quality_scale": "platinum",
|
||||
"dhcp": [
|
||||
|
||||
@@ -18,14 +18,13 @@ https://developers.google.com/nest/device-access/api/camera#handle_camera_events
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
|
||||
from google_nest_sdm.device import Device
|
||||
from google_nest_sdm.event import ImageEventBase
|
||||
from google_nest_sdm.event import EventImageType, ImageEventBase
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_CLASS_DIRECTORY,
|
||||
@@ -46,7 +45,8 @@ from homeassistant.components.nest.const import DATA_SUBSCRIBER, DOMAIN
|
||||
from homeassistant.components.nest.device_info import NestDeviceInfo
|
||||
from homeassistant.components.nest.events import MEDIA_SOURCE_EVENT_TITLE_MAP
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.template import DATE_STR_FORMAT
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,7 +137,7 @@ class NestMediaSource(MediaSource):
|
||||
raise Unresolvable(
|
||||
"Unable to find device with identifier: %s" % item.identifier
|
||||
)
|
||||
events = _get_events(device)
|
||||
events = await _get_events(device)
|
||||
if media_id.event_id not in events:
|
||||
raise Unresolvable(
|
||||
"Unable to find event with identifier: %s" % item.identifier
|
||||
@@ -180,16 +180,16 @@ class NestMediaSource(MediaSource):
|
||||
# Browse a specific device and return child events
|
||||
browse_device = _browse_device(media_id, device)
|
||||
browse_device.children = []
|
||||
events = _get_events(device)
|
||||
events = await _get_events(device)
|
||||
for child_event in events.values():
|
||||
event_id = MediaId(media_id.device_id, child_event.event_id)
|
||||
event_id = MediaId(media_id.device_id, child_event.event_session_id)
|
||||
browse_device.children.append(
|
||||
_browse_event(event_id, device, child_event)
|
||||
)
|
||||
return browse_device
|
||||
|
||||
# Browse a specific event
|
||||
events = _get_events(device)
|
||||
events = await _get_events(device)
|
||||
if not (event := events.get(media_id.event_id)):
|
||||
raise BrowseError(
|
||||
"Unable to find event with identiifer: %s" % item.identifier
|
||||
@@ -201,9 +201,10 @@ class NestMediaSource(MediaSource):
|
||||
return await get_media_source_devices(self.hass)
|
||||
|
||||
|
||||
def _get_events(device: Device) -> Mapping[str, ImageEventBase]:
|
||||
async def _get_events(device: Device) -> Mapping[str, ImageEventBase]:
|
||||
"""Return relevant events for the specified device."""
|
||||
return OrderedDict({e.event_id: e for e in device.event_media_manager.events})
|
||||
events = await device.event_media_manager.async_events()
|
||||
return {e.event_session_id: e for e in events}
|
||||
|
||||
|
||||
def _browse_root() -> BrowseMediaSource:
|
||||
@@ -250,9 +251,9 @@ def _browse_event(
|
||||
media_content_type=MEDIA_TYPE_IMAGE,
|
||||
title=CLIP_TITLE_FORMAT.format(
|
||||
event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"),
|
||||
event_time=dt_util.as_local(event.timestamp),
|
||||
event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT),
|
||||
),
|
||||
can_play=True,
|
||||
can_play=(event.event_image_type == EventImageType.CLIP_PREVIEW),
|
||||
can_expand=False,
|
||||
thumbnail=None,
|
||||
children=[],
|
||||
|
||||
@@ -135,9 +135,14 @@ async def async_setup_entry(
|
||||
entities = []
|
||||
for home_id in climate_topology.home_ids:
|
||||
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
|
||||
await data_handler.register_data_class(
|
||||
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
|
||||
)
|
||||
|
||||
try:
|
||||
await data_handler.register_data_class(
|
||||
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
|
||||
)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
climate_state = data_handler.data[signal_name]
|
||||
climate_topology.register_handler(home_id, climate_state.process_topology)
|
||||
|
||||
|
||||
@@ -194,7 +194,11 @@ class NetatmoDataHandler:
|
||||
self._auth, **kwargs
|
||||
)
|
||||
|
||||
await self.async_fetch_data(data_class_entry)
|
||||
try:
|
||||
await self.async_fetch_data(data_class_entry)
|
||||
except KeyError:
|
||||
self.data_classes.pop(data_class_entry)
|
||||
raise
|
||||
|
||||
self._queue.append(self.data_classes[data_class_entry])
|
||||
_LOGGER.debug("Data class %s added", data_class_entry)
|
||||
|
||||
@@ -48,6 +48,14 @@ async def async_setup_entry(
|
||||
entities = []
|
||||
for home_id in climate_topology.home_ids:
|
||||
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
|
||||
|
||||
try:
|
||||
await data_handler.register_data_class(
|
||||
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
|
||||
)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
await data_handler.register_data_class(
|
||||
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "netdata",
|
||||
"name": "Netdata",
|
||||
"documentation": "https://www.home-assistant.io/integrations/netdata",
|
||||
"requirements": ["netdata==0.2.0"],
|
||||
"requirements": ["netdata==1.0.1"],
|
||||
"codeowners": ["@fabaff"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
)
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
@@ -61,8 +60,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
port = config.get(CONF_PORT)
|
||||
resources = config.get(CONF_RESOURCES)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
netdata = NetdataData(Netdata(host, hass.loop, session, port=port))
|
||||
netdata = NetdataData(Netdata(host, port=port))
|
||||
await netdata.async_update()
|
||||
|
||||
if netdata.api.metrics is None:
|
||||
|
||||
@@ -218,6 +218,4 @@ class NextBusDepartureSensor(SensorEntity):
|
||||
)
|
||||
|
||||
latest_prediction = maybe_first(predictions)
|
||||
self._state = utc_from_timestamp(
|
||||
int(latest_prediction["epochTime"]) / 1000
|
||||
).isoformat()
|
||||
self._state = utc_from_timestamp(int(latest_prediction["epochTime"]) / 1000)
|
||||
|
||||
@@ -127,6 +127,6 @@ class NZBGetSensor(NZBGetEntity, SensorEntity):
|
||||
|
||||
if "UpTimeSec" in sensor_type and value > 0:
|
||||
uptime = utcnow() - timedelta(seconds=value)
|
||||
return uptime.replace(microsecond=0).isoformat()
|
||||
return uptime.replace(microsecond=0)
|
||||
|
||||
return value
|
||||
|
||||
@@ -120,7 +120,7 @@ class OASATelematicsSensor(SensorEntity):
|
||||
self._name_data = self.data.name_data
|
||||
next_arrival_data = self._times[0]
|
||||
if ATTR_NEXT_ARRIVAL in next_arrival_data:
|
||||
self._state = next_arrival_data[ATTR_NEXT_ARRIVAL].isoformat()
|
||||
self._state = next_arrival_data[ATTR_NEXT_ARRIVAL]
|
||||
|
||||
|
||||
class OASATelematicsData:
|
||||
|
||||
@@ -185,7 +185,7 @@ class MinutPointClient:
|
||||
|
||||
async def _sync(self):
|
||||
"""Update local list of devices."""
|
||||
if not await self._client.update() and self._is_available:
|
||||
if not await self._client.update():
|
||||
self._is_available = False
|
||||
_LOGGER.warning("Device is unavailable")
|
||||
async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_ENDPOINT = "http://pvoutput.org/service/r2/getstatus.jsp"
|
||||
_ENDPOINT = "https://pvoutput.org/service/r2/getstatus.jsp"
|
||||
|
||||
ATTR_ENERGY_GENERATION = "energy_generation"
|
||||
ATTR_POWER_GENERATION = "power_generation"
|
||||
|
||||
@@ -277,6 +277,8 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
if not self.coordinator.data[self.entity_description.uid]["active"]:
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
f"Cannot turn on an inactive program/zone: {self.name}"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable, Iterable
|
||||
import concurrent.futures
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import queue
|
||||
@@ -76,6 +77,7 @@ from .util import (
|
||||
session_scope,
|
||||
setup_connection_for_dialect,
|
||||
validate_or_move_away_sqlite_database,
|
||||
write_lock_db,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -123,6 +125,9 @@ KEEPALIVE_TIME = 30
|
||||
# States and Events objects
|
||||
EXPIRE_AFTER_COMMITS = 120
|
||||
|
||||
DB_LOCK_TIMEOUT = 30
|
||||
DB_LOCK_QUEUE_CHECK_TIMEOUT = 1
|
||||
|
||||
CONF_AUTO_PURGE = "auto_purge"
|
||||
CONF_DB_URL = "db_url"
|
||||
CONF_DB_MAX_RETRIES = "db_max_retries"
|
||||
@@ -370,6 +375,15 @@ class WaitTask:
|
||||
"""An object to insert into the recorder queue to tell it set the _queue_watch event."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseLockTask:
|
||||
"""An object to insert into the recorder queue to prevent writes to the database."""
|
||||
|
||||
database_locked: asyncio.Event
|
||||
database_unlock: threading.Event
|
||||
queue_overflow: bool
|
||||
|
||||
|
||||
class Recorder(threading.Thread):
|
||||
"""A threaded recorder class."""
|
||||
|
||||
@@ -419,6 +433,7 @@ class Recorder(threading.Thread):
|
||||
self.migration_in_progress = False
|
||||
self._queue_watcher = None
|
||||
self._db_supports_row_number = True
|
||||
self._database_lock_task: DatabaseLockTask | None = None
|
||||
|
||||
self.enabled = True
|
||||
|
||||
@@ -687,6 +702,8 @@ class Recorder(threading.Thread):
|
||||
def _process_one_event_or_recover(self, event):
|
||||
"""Process an event, reconnect, or recover a malformed database."""
|
||||
try:
|
||||
if self._process_one_task(event):
|
||||
return
|
||||
self._process_one_event(event)
|
||||
return
|
||||
except exc.DatabaseError as err:
|
||||
@@ -788,34 +805,63 @@ class Recorder(threading.Thread):
|
||||
# Schedule a new statistics task if this one didn't finish
|
||||
self.queue.put(ExternalStatisticsTask(metadata, stats))
|
||||
|
||||
def _process_one_event(self, event):
|
||||
def _lock_database(self, task: DatabaseLockTask):
|
||||
@callback
|
||||
def _async_set_database_locked(task: DatabaseLockTask):
|
||||
task.database_locked.set()
|
||||
|
||||
with write_lock_db(self):
|
||||
# Notify that lock is being held, wait until database can be used again.
|
||||
self.hass.add_job(_async_set_database_locked, task)
|
||||
while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT):
|
||||
if self.queue.qsize() > MAX_QUEUE_BACKLOG * 0.9:
|
||||
_LOGGER.warning(
|
||||
"Database queue backlog reached more than 90% of maximum queue "
|
||||
"length while waiting for backup to finish; recorder will now "
|
||||
"resume writing to database. The backup can not be trusted and "
|
||||
"must be restarted"
|
||||
)
|
||||
task.queue_overflow = True
|
||||
break
|
||||
_LOGGER.info(
|
||||
"Database queue backlog reached %d entries during backup",
|
||||
self.queue.qsize(),
|
||||
)
|
||||
|
||||
def _process_one_task(self, event) -> bool:
|
||||
"""Process one event."""
|
||||
if isinstance(event, PurgeTask):
|
||||
self._run_purge(event.purge_before, event.repack, event.apply_filter)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, PurgeEntitiesTask):
|
||||
self._run_purge_entities(event.entity_filter)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, PerodicCleanupTask):
|
||||
perodic_db_cleanups(self)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, StatisticsTask):
|
||||
self._run_statistics(event.start)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, ClearStatisticsTask):
|
||||
statistics.clear_statistics(self, event.statistic_ids)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, UpdateStatisticsMetadataTask):
|
||||
statistics.update_statistics_metadata(
|
||||
self, event.statistic_id, event.unit_of_measurement
|
||||
)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, ExternalStatisticsTask):
|
||||
self._run_external_statistics(event.metadata, event.statistics)
|
||||
return
|
||||
return True
|
||||
if isinstance(event, WaitTask):
|
||||
self._queue_watch.set()
|
||||
return
|
||||
return True
|
||||
if isinstance(event, DatabaseLockTask):
|
||||
self._lock_database(event)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _process_one_event(self, event):
|
||||
if event.event_type == EVENT_TIME_CHANGED:
|
||||
self._keepalive_count += 1
|
||||
if self._keepalive_count >= KEEPALIVE_TIME:
|
||||
@@ -982,6 +1028,42 @@ class Recorder(threading.Thread):
|
||||
self.queue.put(WaitTask())
|
||||
self._queue_watch.wait()
|
||||
|
||||
async def lock_database(self) -> bool:
|
||||
"""Lock database so it can be backed up safely."""
|
||||
if self._database_lock_task:
|
||||
_LOGGER.warning("Database already locked")
|
||||
return False
|
||||
|
||||
database_locked = asyncio.Event()
|
||||
task = DatabaseLockTask(database_locked, threading.Event(), False)
|
||||
self.queue.put(task)
|
||||
try:
|
||||
await asyncio.wait_for(database_locked.wait(), timeout=DB_LOCK_TIMEOUT)
|
||||
except asyncio.TimeoutError as err:
|
||||
task.database_unlock.set()
|
||||
raise TimeoutError(
|
||||
f"Could not lock database within {DB_LOCK_TIMEOUT} seconds."
|
||||
) from err
|
||||
self._database_lock_task = task
|
||||
return True
|
||||
|
||||
@callback
|
||||
def unlock_database(self) -> bool:
|
||||
"""Unlock database.
|
||||
|
||||
Returns true if database lock has been held throughout the process.
|
||||
"""
|
||||
if not self._database_lock_task:
|
||||
_LOGGER.warning("Database currently not locked")
|
||||
return False
|
||||
|
||||
self._database_lock_task.database_unlock.set()
|
||||
success = not self._database_lock_task.queue_overflow
|
||||
|
||||
self._database_lock_task = None
|
||||
|
||||
return success
|
||||
|
||||
def _setup_connection(self):
|
||||
"""Ensure database is ready to fly."""
|
||||
kwargs = {}
|
||||
|
||||
@@ -834,8 +834,12 @@ def statistics_during_period(
|
||||
return _reduce_statistics_per_month(result)
|
||||
|
||||
|
||||
def get_last_statistics(
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool
|
||||
def _get_last_statistics(
|
||||
hass: HomeAssistant,
|
||||
number_of_stats: int,
|
||||
statistic_id: str,
|
||||
convert_units: bool,
|
||||
table: type[Statistics | StatisticsShortTerm],
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Return the last number_of_stats statistics for a given statistic_id."""
|
||||
statistic_ids = [statistic_id]
|
||||
@@ -845,16 +849,19 @@ def get_last_statistics(
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
baked_query = hass.data[STATISTICS_SHORT_TERM_BAKERY](
|
||||
lambda session: session.query(*QUERY_STATISTICS_SHORT_TERM)
|
||||
)
|
||||
if table == StatisticsShortTerm:
|
||||
bakery = STATISTICS_SHORT_TERM_BAKERY
|
||||
base_query = QUERY_STATISTICS_SHORT_TERM
|
||||
else:
|
||||
bakery = STATISTICS_BAKERY
|
||||
base_query = QUERY_STATISTICS
|
||||
|
||||
baked_query = hass.data[bakery](lambda session: session.query(*base_query))
|
||||
|
||||
baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id"))
|
||||
metadata_id = metadata[statistic_id][0]
|
||||
|
||||
baked_query += lambda q: q.order_by(
|
||||
StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc()
|
||||
)
|
||||
baked_query += lambda q: q.order_by(table.metadata_id, table.start.desc())
|
||||
|
||||
baked_query += lambda q: q.limit(bindparam("number_of_stats"))
|
||||
|
||||
@@ -874,11 +881,29 @@ def get_last_statistics(
|
||||
statistic_ids,
|
||||
metadata,
|
||||
convert_units,
|
||||
StatisticsShortTerm,
|
||||
table,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def get_last_statistics(
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Return the last number_of_stats statistics for a statistic_id."""
|
||||
return _get_last_statistics(
|
||||
hass, number_of_stats, statistic_id, convert_units, Statistics
|
||||
)
|
||||
|
||||
|
||||
def get_last_short_term_statistics(
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Return the last number_of_stats short term statistics for a statistic_id."""
|
||||
return _get_last_statistics(
|
||||
hass, number_of_stats, statistic_id, convert_units, StatisticsShortTerm
|
||||
)
|
||||
|
||||
|
||||
def _statistics_at_time(
|
||||
session: scoped_session,
|
||||
metadata_ids: set[int],
|
||||
|
||||
@@ -457,6 +457,25 @@ def perodic_db_cleanups(instance: Recorder):
|
||||
connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);"))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def write_lock_db(instance: Recorder):
|
||||
"""Lock database for writes."""
|
||||
|
||||
if instance.engine.dialect.name == "sqlite":
|
||||
with instance.engine.connect() as connection:
|
||||
# Execute sqlite to create a wal checkpoint
|
||||
# This is optional but makes sure the backup is going to be minimal
|
||||
connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
|
||||
# Create write lock
|
||||
_LOGGER.debug("Lock database")
|
||||
connection.execute(text("BEGIN IMMEDIATE;"))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_LOGGER.debug("Unlock database")
|
||||
connection.execute(text("END;"))
|
||||
|
||||
|
||||
def async_migration_in_progress(hass: HomeAssistant) -> bool:
|
||||
"""Determine is a migration is in progress.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The Energy websocket API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -15,6 +16,8 @@ from .util import async_migration_in_progress
|
||||
if TYPE_CHECKING:
|
||||
from . import Recorder
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
@@ -23,6 +26,8 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_clear_statistics)
|
||||
websocket_api.async_register_command(hass, ws_update_statistics_metadata)
|
||||
websocket_api.async_register_command(hass, ws_info)
|
||||
websocket_api.async_register_command(hass, ws_backup_start)
|
||||
websocket_api.async_register_command(hass, ws_backup_end)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -106,3 +111,38 @@ def ws_info(
|
||||
"thread_running": thread_alive,
|
||||
}
|
||||
connection.send_result(msg["id"], recorder_info)
|
||||
|
||||
|
||||
@websocket_api.ws_require_user(only_supervisor=True)
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/start"})
|
||||
@websocket_api.async_response
|
||||
async def ws_backup_start(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Backup start notification."""
|
||||
|
||||
_LOGGER.info("Backup start notification, locking database for writes")
|
||||
instance: Recorder = hass.data[DATA_INSTANCE]
|
||||
try:
|
||||
await instance.lock_database()
|
||||
except TimeoutError as err:
|
||||
connection.send_error(msg["id"], "timeout_error", str(err))
|
||||
return
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.ws_require_user(only_supervisor=True)
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/end"})
|
||||
@websocket_api.async_response
|
||||
async def ws_backup_end(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Backup end notification."""
|
||||
|
||||
instance: Recorder = hass.data[DATA_INSTANCE]
|
||||
_LOGGER.info("Backup end notification, releasing write lock")
|
||||
if not instance.unlock_database():
|
||||
connection.send_error(
|
||||
msg["id"], "database_unlock_failed", "Failed to unlock database."
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -160,7 +160,7 @@ class RepetierJobEndSensor(RepetierSensor):
|
||||
print_time = data["print_time"]
|
||||
from_start = data["from_start"]
|
||||
time_end = start + round(print_time, 0)
|
||||
self._state = datetime.utcfromtimestamp(time_end).isoformat()
|
||||
self._state = datetime.utcfromtimestamp(time_end)
|
||||
remaining = print_time - from_start
|
||||
remaining_secs = int(round(remaining, 0))
|
||||
_LOGGER.debug(
|
||||
@@ -182,7 +182,7 @@ class RepetierJobStartSensor(RepetierSensor):
|
||||
job_name = data["job_name"]
|
||||
start = data["start"]
|
||||
from_start = data["from_start"]
|
||||
self._state = datetime.utcfromtimestamp(start).isoformat()
|
||||
self._state = datetime.utcfromtimestamp(start)
|
||||
elapsed_secs = int(round(from_start, 0))
|
||||
_LOGGER.debug(
|
||||
"Job %s elapsed %s",
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.const import (
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers import discovery, template
|
||||
from homeassistant.helpers.entity_component import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
EntityComponent,
|
||||
@@ -37,7 +37,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX
|
||||
from .data import RestData
|
||||
from .schema import CONFIG_SCHEMA # noqa: F401
|
||||
from .utils import inject_hass_in_templates_list
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,7 +160,8 @@ def create_rest_data_from_config(hass, config):
|
||||
resource_template.hass = hass
|
||||
resource = resource_template.async_render(parse_result=False)
|
||||
|
||||
inject_hass_in_templates_list(hass, [headers, params])
|
||||
template.attach(hass, headers)
|
||||
template.attach(hass, params)
|
||||
|
||||
if username and password:
|
||||
if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from homeassistant.components.rest.utils import render_templates
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
DEFAULT_TIMEOUT = 10
|
||||
@@ -52,8 +52,8 @@ class RestData:
|
||||
self._hass, verify_ssl=self._verify_ssl
|
||||
)
|
||||
|
||||
rendered_headers = render_templates(self._headers)
|
||||
rendered_params = render_templates(self._params)
|
||||
rendered_headers = template.render_complex(self._headers, parse_result=False)
|
||||
rendered_params = template.render_complex(self._params)
|
||||
|
||||
_LOGGER.debug("Updating from %s", self._resource)
|
||||
try:
|
||||
@@ -65,6 +65,7 @@ class RestData:
|
||||
auth=self._auth,
|
||||
data=self._request_data,
|
||||
timeout=self._timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
self.data = response.text
|
||||
self.headers = response.headers
|
||||
|
||||
@@ -11,8 +11,10 @@ from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor.helpers import async_parse_date_datetime
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_FORCE_UPDATE,
|
||||
@@ -186,4 +188,13 @@ class RestSensor(RestEntity, SensorEntity):
|
||||
value, None
|
||||
)
|
||||
|
||||
self._state = value
|
||||
if value is None or self.device_class not in (
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
):
|
||||
self._state = value
|
||||
return
|
||||
|
||||
self._state = async_parse_date_datetime(
|
||||
value, self.entity_id, self.device_class
|
||||
)
|
||||
|
||||
@@ -24,10 +24,8 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, template
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .utils import inject_hass_in_templates_list, render_templates
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CONF_BODY_OFF = "body_off"
|
||||
@@ -92,7 +90,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
body_on.hass = hass
|
||||
if body_off is not None:
|
||||
body_off.hass = hass
|
||||
inject_hass_in_templates_list(hass, [headers, params])
|
||||
|
||||
template.attach(hass, headers)
|
||||
template.attach(hass, params)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
|
||||
try:
|
||||
@@ -207,8 +207,8 @@ class RestSwitch(SwitchEntity):
|
||||
"""Send a state update to the device."""
|
||||
websession = async_get_clientsession(self.hass, self._verify_ssl)
|
||||
|
||||
rendered_headers = render_templates(self._headers)
|
||||
rendered_params = render_templates(self._params)
|
||||
rendered_headers = template.render_complex(self._headers, parse_result=False)
|
||||
rendered_params = template.render_complex(self._params)
|
||||
|
||||
async with async_timeout.timeout(self._timeout):
|
||||
req = await getattr(websession, self._method)(
|
||||
@@ -233,8 +233,8 @@ class RestSwitch(SwitchEntity):
|
||||
"""Get the latest data from REST API and update the state."""
|
||||
websession = async_get_clientsession(hass, self._verify_ssl)
|
||||
|
||||
rendered_headers = render_templates(self._headers)
|
||||
rendered_params = render_templates(self._params)
|
||||
rendered_headers = template.render_complex(self._headers, parse_result=False)
|
||||
rendered_params = template.render_complex(self._params)
|
||||
|
||||
async with async_timeout.timeout(self._timeout):
|
||||
req = await websession.get(
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Reusable utilities for the Rest component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
|
||||
def inject_hass_in_templates_list(
|
||||
hass: HomeAssistant, tpl_dict_list: list[dict[str, Template] | None]
|
||||
):
|
||||
"""Inject hass in a list of dict of templates."""
|
||||
for tpl_dict in tpl_dict_list:
|
||||
if tpl_dict is not None:
|
||||
for tpl in tpl_dict.values():
|
||||
tpl.hass = hass
|
||||
|
||||
|
||||
def render_templates(tpl_dict: dict[str, Template] | None):
|
||||
"""Render a dict of templates."""
|
||||
if tpl_dict is None:
|
||||
return None
|
||||
|
||||
rendered_items = {}
|
||||
for item_name, template_header in tpl_dict.items():
|
||||
if (value := template_header.async_render()) is not None:
|
||||
rendered_items[item_name] = value
|
||||
return rendered_items
|
||||
@@ -65,7 +65,7 @@ async def async_setup_entry(
|
||||
entity = RfxtrxCover(
|
||||
event.device,
|
||||
device_id,
|
||||
signal_repetitions=entity_info[CONF_SIGNAL_REPETITIONS],
|
||||
signal_repetitions=entity_info.get(CONF_SIGNAL_REPETITIONS, 1),
|
||||
venetian_blind_mode=entity_info.get(CONF_VENETIAN_BLIND_MODE),
|
||||
)
|
||||
entities.append(entity)
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.const import (
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.util.dt import get_time_zone, now
|
||||
|
||||
# Config for rova requests.
|
||||
CONF_ZIP_CODE = "zip_code"
|
||||
@@ -116,7 +117,7 @@ class RovaSensor(SensorEntity):
|
||||
self.data_service.update()
|
||||
pickup_date = self.data_service.data.get(self.entity_description.key)
|
||||
if pickup_date is not None:
|
||||
self._attr_native_value = pickup_date.isoformat()
|
||||
self._attr_native_value = pickup_date
|
||||
|
||||
|
||||
class RovaData:
|
||||
@@ -140,10 +141,12 @@ class RovaData:
|
||||
self.data = {}
|
||||
|
||||
for item in items:
|
||||
date = datetime.strptime(item["Date"], "%Y-%m-%dT%H:%M:%S")
|
||||
date = datetime.strptime(item["Date"], "%Y-%m-%dT%H:%M:%S").replace(
|
||||
tzinfo=get_time_zone("Europe/Amsterdam")
|
||||
)
|
||||
code = item["GarbageTypeCode"].lower()
|
||||
|
||||
if code not in self.data and date > datetime.now():
|
||||
if code not in self.data and date > now():
|
||||
self.data[code] = date
|
||||
|
||||
_LOGGER.debug("Updated Rova calendar: %s", self.data)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user