forked from home-assistant/core
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc54618c5 | ||
|
|
8f344252c4 | ||
|
|
cbe4b2dc1d | ||
|
|
a17d2d7c71 | ||
|
|
e3815c6c2e | ||
|
|
5cba7932f3 | ||
|
|
413430bdba | ||
|
|
81462d8655 | ||
|
|
8ee4b49aa9 | ||
|
|
19d7cb4439 | ||
|
|
21ebf4f3e6 | ||
|
|
980fcef36f | ||
|
|
e7fd24eade | ||
|
|
9ecb75dc70 | ||
|
|
7f3adce675 | ||
|
|
f0649855f9 | ||
|
|
823c3735ce | ||
|
|
68131a5c00 | ||
|
|
be0f767c34 | ||
|
|
450652a501 | ||
|
|
8523f569c0 | ||
|
|
5f289434d3 | ||
|
|
3df6dfecab | ||
|
|
d6eda65302 | ||
|
|
7a5bc2784a | ||
|
|
00878467cc | ||
|
|
899d8164b0 | ||
|
|
823fd60991 | ||
|
|
a6bb0eadca | ||
|
|
eb70354ee7 | ||
|
|
bd53185bed | ||
|
|
df9a899bbd | ||
|
|
37cf295e20 | ||
|
|
04816fe26d | ||
|
|
eb48e75fc5 | ||
|
|
9d5431fba1 | ||
|
|
a4f2c5583d | ||
|
|
a37c3af2b4 | ||
|
|
33047d7260 | ||
|
|
e3405d226a | ||
|
|
3008ff03b2 | ||
|
|
8592d94a3c | ||
|
|
b36e86d95c | ||
|
|
f61a1ecae7 | ||
|
|
80c074ca82 | ||
|
|
ff91ff4cd2 | ||
|
|
93c2a7dd70 | ||
|
|
da3ee9ed4b | ||
|
|
2ef607651d | ||
|
|
88ca83a30b | ||
|
|
f883fa9eef | ||
|
|
5b705dba36 | ||
|
|
1592408a4b | ||
|
|
89b7be52af | ||
|
|
8f85472df3 | ||
|
|
6aa771e5e8 | ||
|
|
7193e82963 | ||
|
|
245eec7041 | ||
|
|
493309daa7 | ||
|
|
af68802c17 | ||
|
|
576cece7a9 | ||
|
|
3b9859940f | ||
|
|
a315fd059a | ||
|
|
ba9ef004c8 | ||
|
|
22f745b17c | ||
|
|
05cf223146 | ||
|
|
d4aadd8af0 | ||
|
|
4045eee2e5 | ||
|
|
83a51f7f30 | ||
|
|
29110fe157 | ||
|
|
e87b7e24b4 | ||
|
|
d9056c01a6 | ||
|
|
a724bc21b6 | ||
|
|
ef00178339 | ||
|
|
b8770c3958 | ||
|
|
f0c0cfcac0 | ||
|
|
4c48ad9108 | ||
|
|
92b0453749 | ||
|
|
8ab801a7b4 | ||
|
|
f92c7b1aea | ||
|
|
0d9fbf864f | ||
|
|
275f9c8a28 | ||
|
|
84f3b1514f | ||
|
|
802f5613c4 | ||
|
|
8be40cbb00 | ||
|
|
46ce4e92f6 | ||
|
|
39f11bb46d | ||
|
|
3b0fe9adde | ||
|
|
707778229b | ||
|
|
a474534c08 | ||
|
|
65ad99d51c | ||
|
|
4052a0db89 | ||
|
|
b546fc5067 | ||
|
|
5dcc760755 | ||
|
|
fb06acf39d | ||
|
|
948f191f16 | ||
|
|
2c0d9105ac | ||
|
|
10df9f3542 | ||
|
|
6cf799459b | ||
|
|
47e2d1caa5 | ||
|
|
69d8f94e3b | ||
|
|
4b7803ed03 | ||
|
|
ff6015ff89 | ||
|
|
fbd144de46 | ||
|
|
adaebdeea8 | ||
|
|
910cb5865a | ||
|
|
baf0d9b2d9 | ||
|
|
c1bce68549 | ||
|
|
bde4c0e46f | ||
|
|
a275e7aa67 | ||
|
|
d96e416d26 | ||
|
|
efc3894303 | ||
|
|
06b47ee2f5 | ||
|
|
08ca43221f | ||
|
|
8641740ed8 | ||
|
|
d0ada6c6e2 | ||
|
|
76bb036968 | ||
|
|
d8b64be41c | ||
|
|
b3e0b7b86e | ||
|
|
e097e4c1c2 | ||
|
|
34f0fecef8 | ||
|
|
f53a10d39a | ||
|
|
5b993129d6 | ||
|
|
865656d436 | ||
|
|
fb25c6c115 | ||
|
|
c963cf8743 | ||
|
|
ddb28db21a | ||
|
|
bfc98b444f | ||
|
|
f9a0f44137 | ||
|
|
93750d71ce | ||
|
|
06e4003640 | ||
|
|
97ff5e2085 | ||
|
|
8a2c07ce19 | ||
|
|
9f7398e0df | ||
|
|
7df84dadad | ||
|
|
2a1e943b18 | ||
|
|
e6e72bfa82 | ||
|
|
219868b308 | ||
|
|
67dd861d8c | ||
|
|
f2765ba320 | ||
|
|
aefd3df914 | ||
|
|
3658eeb8d1 | ||
|
|
080cb6b6e9 | ||
|
|
20796303da | ||
|
|
dff6151ff4 | ||
|
|
6f24f4e302 | ||
|
|
175febe635 | ||
|
|
aa907f4d10 | ||
|
|
3d09478aea | ||
|
|
05df9b4b8b | ||
|
|
1865a28083 | ||
|
|
f78d57515a |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -580,7 +580,7 @@ jobs:
|
||||
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -U "pip<20.3" setuptools wheel
|
||||
pip install -U "pip<20.3" "setuptools<58" wheel
|
||||
pip install -r requirements_all.txt
|
||||
pip install -r requirements_test.txt
|
||||
pip install -e .
|
||||
|
||||
@@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes
|
||||
homeassistant/components/intent/* @home-assistant/core
|
||||
homeassistant/components/intesishome/* @jnimmo
|
||||
homeassistant/components/ios/* @robbiet480
|
||||
homeassistant/components/iotawatt/* @gtdiehl
|
||||
homeassistant/components/iperf3/* @rohankapoorcom
|
||||
homeassistant/components/ipma/* @dgomes @abmantis
|
||||
homeassistant/components/ipp/* @ctalkington
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -16,6 +16,21 @@ RUN \
|
||||
-e ./homeassistant \
|
||||
&& python3 -m compileall homeassistant/homeassistant
|
||||
|
||||
# Fix Bug with Alpine 3.14 and sqlite 3.35
|
||||
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524
|
||||
ARG BUILD_ARCH
|
||||
RUN \
|
||||
if [ "${BUILD_ARCH}" = "amd64" ]; then \
|
||||
export APK_ARCH=x86_64; \
|
||||
elif [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
export APK_ARCH=x86; \
|
||||
else \
|
||||
export APK_ARCH=${BUILD_ARCH}; \
|
||||
fi \
|
||||
&& curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \
|
||||
&& apk add --no-cache sqlite-libs-3.34.1-r0.apk \
|
||||
&& rm -f sqlite-libs-3.34.1-r0.apk
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
|
||||
@@ -342,7 +342,11 @@ def async_enable_logging(
|
||||
err_log_path, backupCount=1
|
||||
)
|
||||
|
||||
err_handler.doRollover()
|
||||
try:
|
||||
err_handler.doRollover()
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error rolling over log file: %s", err)
|
||||
|
||||
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Sensor platform for Advantage Air integration."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
|
||||
@@ -138,11 +142,11 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity):
|
||||
|
||||
|
||||
class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity):
|
||||
"""Representation of Advantage Air Zone wireless signal sensor."""
|
||||
"""Representation of Advantage Air Zone temperature sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = TEMP_CELSIUS
|
||||
_attr_device_class = DEVICE_CLASS_TEMPERATURE
|
||||
_attr_state_class = STATE_CLASS_MEASUREMENT
|
||||
_attr_icon = "mdi:thermometer"
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, instance, ac_key, zone_key):
|
||||
|
||||
@@ -319,6 +319,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
config_entry.data[CONF_API_KEY],
|
||||
config_entry.data[CONF_APP_KEY],
|
||||
session=session,
|
||||
logger=LOGGER,
|
||||
),
|
||||
)
|
||||
hass.loop.create_task(ambient.ws_connect())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Ambient Weather Station",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ambient_station",
|
||||
"requirements": ["aioambient==1.2.6"],
|
||||
"requirements": ["aioambient==1.3.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity):
|
||||
self._resource = resource
|
||||
self._attr_name = f"{location.title()} {name.title()}"
|
||||
self._attr_available = True
|
||||
self._attr_is_on = False
|
||||
|
||||
|
||||
class ArestSwitchFunction(ArestSwitchBase):
|
||||
|
||||
@@ -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.7.19"],
|
||||
"requirements": ["bimmer_connected==0.7.20"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC):
|
||||
super().__init__(device)
|
||||
self._command_on = command_on
|
||||
self._command_off = command_off
|
||||
|
||||
self._attr_assumed_state = True
|
||||
self._attr_device_class = DEVICE_CLASS_SWITCH
|
||||
self._attr_name = f"{device.name} Switch"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
|
||||
@@ -699,7 +699,7 @@ class BrSensor(SensorEntity):
|
||||
@callback
|
||||
def data_updated(self, data):
|
||||
"""Update data."""
|
||||
if self._load_data(data) and self.hass:
|
||||
if self.hass and self._load_data(data):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
|
||||
@@ -165,10 +165,7 @@ async def _async_get_image(
|
||||
width=width, height=height
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
|
||||
camera.entity_id,
|
||||
)
|
||||
camera.async_warn_old_async_camera_image_signature()
|
||||
image_bytes = await camera.async_camera_image()
|
||||
|
||||
if image_bytes:
|
||||
@@ -381,6 +378,7 @@ class Camera(Entity):
|
||||
self.stream_options: dict[str, str] = {}
|
||||
self.content_type: str = DEFAULT_CONTENT_TYPE
|
||||
self.access_tokens: collections.deque = collections.deque([], 2)
|
||||
self._warned_old_signature = False
|
||||
self.async_update_token()
|
||||
|
||||
@property
|
||||
@@ -455,11 +453,20 @@ class Camera(Entity):
|
||||
return await self.hass.async_add_executor_job(
|
||||
partial(self.camera_image, width=width, height=height)
|
||||
)
|
||||
self.async_warn_old_async_camera_image_signature()
|
||||
return await self.hass.async_add_executor_job(self.camera_image)
|
||||
|
||||
# Remove in 2022.1 after all custom components have had a chance to change their signature
|
||||
@callback
|
||||
def async_warn_old_async_camera_image_signature(self) -> None:
|
||||
"""Warn once when calling async_camera_image with the function old signature."""
|
||||
if self._warned_old_signature:
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
|
||||
self.entity_id,
|
||||
)
|
||||
return await self.hass.async_add_executor_job(self.camera_image)
|
||||
self._warned_old_signature = True
|
||||
|
||||
async def handle_async_still_stream(
|
||||
self, request: web.Request, interval: float
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Iterable, Mapping
|
||||
from functools import wraps
|
||||
import logging
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
@@ -27,7 +28,6 @@ from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig
|
||||
|
||||
DOMAIN = "device_automation"
|
||||
|
||||
|
||||
DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): "device",
|
||||
@@ -174,6 +174,13 @@ async def _async_get_device_automations(
|
||||
device_results, InvalidDeviceAutomationConfig
|
||||
):
|
||||
continue
|
||||
if isinstance(device_results, Exception):
|
||||
logging.getLogger(__name__).error(
|
||||
"Unexpected error fetching device %ss",
|
||||
automation_type,
|
||||
exc_info=device_results,
|
||||
)
|
||||
continue
|
||||
for automation in device_results:
|
||||
combined_results[automation["device_id"]].append(automation)
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from homeassistant.components.device_automation import (
|
||||
)
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
|
||||
from .exceptions import InvalidDeviceAutomationConfig
|
||||
|
||||
# mypy: allow-untyped-defs, no-check-untyped-defs
|
||||
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
@@ -17,10 +19,13 @@ async def async_validate_trigger_config(hass, config):
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], "trigger"
|
||||
)
|
||||
if hasattr(platform, "async_validate_trigger_config"):
|
||||
return await getattr(platform, "async_validate_trigger_config")(hass, config)
|
||||
if not hasattr(platform, "async_validate_trigger_config"):
|
||||
return platform.TRIGGER_SCHEMA(config)
|
||||
|
||||
return platform.TRIGGER_SCHEMA(config)
|
||||
try:
|
||||
return await getattr(platform, "async_validate_trigger_config")(hass, config)
|
||||
except InvalidDeviceAutomationConfig as err:
|
||||
raise vol.Invalid(str(err) or "Invalid trigger configuration") from err
|
||||
|
||||
|
||||
async def async_attach_trigger(hass, config, action, automation_info):
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType, EventType, StateType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
@@ -146,8 +146,15 @@ async def async_setup_entry(
|
||||
|
||||
if transport:
|
||||
# Register listener to close transport on HA shutdown
|
||||
@callback
|
||||
def close_transport(_event: EventType) -> None:
|
||||
"""Close the transport on HA shutdown."""
|
||||
if not transport:
|
||||
return
|
||||
transport.close()
|
||||
|
||||
stop_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, transport.close
|
||||
EVENT_HOMEASSISTANT_STOP, close_transport
|
||||
)
|
||||
|
||||
# Wait for reader to close
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from typing import Callable, Final
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
@@ -24,6 +24,9 @@ from homeassistant.const import (
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
|
||||
PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}"
|
||||
PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}"
|
||||
|
||||
|
||||
def dsmr_transform(value):
|
||||
"""Transform DSMR version value to right format."""
|
||||
@@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1",
|
||||
name="Low tariff delivered price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2",
|
||||
name="High tariff delivered price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1",
|
||||
name="Low tariff returned price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2",
|
||||
name="High tariff returned price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_gas",
|
||||
name="Gas price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_M3,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/fixed_cost",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Helper sensor for calculating utility costs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final, Literal, TypeVar, cast
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_LAST_RESET,
|
||||
ATTR_STATE_CLASS,
|
||||
DEVICE_CLASS_MONETARY,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
SensorEntity,
|
||||
)
|
||||
@@ -18,14 +21,19 @@ from homeassistant.const import (
|
||||
ENERGY_WATT_HOUR,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, async_get_manager
|
||||
|
||||
SUPPORTED_STATE_CLASSES = [
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity):
|
||||
f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}"
|
||||
)
|
||||
self._attr_device_class = DEVICE_CLASS_MONETARY
|
||||
self._attr_state_class = STATE_CLASS_TOTAL_INCREASING
|
||||
self._attr_state_class = STATE_CLASS_MEASUREMENT
|
||||
self._config = config
|
||||
self._last_energy_sensor_state: StateType | None = None
|
||||
self._last_energy_sensor_state: State | None = None
|
||||
self._cur_value = 0.0
|
||||
|
||||
def _reset(self, energy_state: StateType) -> None:
|
||||
def _reset(self, energy_state: State) -> None:
|
||||
"""Reset the cost sensor."""
|
||||
self._attr_native_value = 0.0
|
||||
self._cur_value = 0.0
|
||||
self._attr_last_reset = dt_util.utcnow()
|
||||
self._last_energy_sensor_state = energy_state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity):
|
||||
if energy_state is None:
|
||||
return
|
||||
|
||||
if (
|
||||
state_class := energy_state.attributes.get(ATTR_STATE_CLASS)
|
||||
) != STATE_CLASS_TOTAL_INCREASING:
|
||||
state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
|
||||
if state_class not in SUPPORTED_STATE_CLASSES:
|
||||
if not self._wrong_state_class_reported:
|
||||
self._wrong_state_class_reported = True
|
||||
_LOGGER.warning(
|
||||
@@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity):
|
||||
)
|
||||
return
|
||||
|
||||
# last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT
|
||||
if (
|
||||
state_class == STATE_CLASS_MEASUREMENT
|
||||
and ATTR_LAST_RESET not in energy_state.attributes
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
energy = float(energy_state.state)
|
||||
except ValueError:
|
||||
@@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity):
|
||||
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities are in place.
|
||||
self._reset(energy_state.state)
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
@@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity):
|
||||
)
|
||||
return
|
||||
|
||||
if reset_detected(
|
||||
if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get(
|
||||
ATTR_LAST_RESET
|
||||
) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET):
|
||||
# Energy meter was reset, reset cost sensor too
|
||||
energy_state_copy = copy.copy(energy_state)
|
||||
energy_state_copy.state = "0.0"
|
||||
self._reset(energy_state_copy)
|
||||
elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected(
|
||||
self.hass,
|
||||
cast(str, self._config[self._adapter.entity_energy_key]),
|
||||
energy,
|
||||
float(self._last_energy_sensor_state),
|
||||
float(self._last_energy_sensor_state.state),
|
||||
):
|
||||
# Energy meter was reset, reset cost sensor too
|
||||
self._reset(0)
|
||||
energy_state_copy = copy.copy(energy_state)
|
||||
energy_state_copy.state = "0.0"
|
||||
self._reset(energy_state_copy)
|
||||
# Update with newly incurred cost
|
||||
old_energy_value = float(self._last_energy_sensor_state)
|
||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||
self._cur_value += (energy - old_energy_value) * energy_price
|
||||
self._attr_native_value = round(self._cur_value, 2)
|
||||
|
||||
self._last_energy_sensor_state = energy_state.state
|
||||
self._last_energy_sensor_state = energy_state
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Validate the energy preferences provide valid data."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
@@ -10,12 +11,24 @@ from homeassistant.const import (
|
||||
ENERGY_WATT_HOUR,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
VOLUME_CUBIC_FEET,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
|
||||
from . import data
|
||||
from .const import DOMAIN
|
||||
|
||||
ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR)
|
||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||
GAS_USAGE_UNITS = (
|
||||
ENERGY_WATT_HOUR,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
VOLUME_CUBIC_METERS,
|
||||
VOLUME_CUBIC_FEET,
|
||||
)
|
||||
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ValidationIssue:
|
||||
@@ -43,8 +56,12 @@ class EnergyPreferencesValidation:
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_energy_stat(
|
||||
hass: HomeAssistant, stat_value: str, result: list[ValidationIssue]
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
stat_value: str,
|
||||
allowed_units: Sequence[str],
|
||||
unit_error: str,
|
||||
result: list[ValidationIssue],
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
has_entity_source = valid_entity_id(stat_value)
|
||||
@@ -91,14 +108,16 @@ def _async_validate_energy_stat(
|
||||
|
||||
unit = state.attributes.get("unit_of_measurement")
|
||||
|
||||
if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR):
|
||||
result.append(
|
||||
ValidationIssue("entity_unexpected_unit_energy", stat_value, unit)
|
||||
)
|
||||
if unit not in allowed_units:
|
||||
result.append(ValidationIssue(unit_error, stat_value, unit))
|
||||
|
||||
state_class = state.attributes.get("state_class")
|
||||
|
||||
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
|
||||
supported_state_classes = [
|
||||
sensor.STATE_CLASS_MEASUREMENT,
|
||||
sensor.STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
if state_class not in supported_state_classes:
|
||||
result.append(
|
||||
ValidationIssue(
|
||||
"entity_unexpected_state_class_total_increasing",
|
||||
@@ -125,16 +144,13 @@ def _async_validate_price_entity(
|
||||
return
|
||||
|
||||
try:
|
||||
value: float | None = float(state.state)
|
||||
float(state.state)
|
||||
except ValueError:
|
||||
result.append(
|
||||
ValidationIssue("entity_state_non_numeric", entity_id, state.state)
|
||||
)
|
||||
return
|
||||
|
||||
if value is not None and value < 0:
|
||||
result.append(ValidationIssue("entity_negative_state", entity_id, value))
|
||||
|
||||
unit = state.attributes.get("unit_of_measurement")
|
||||
|
||||
if unit is None or not unit.endswith(
|
||||
@@ -188,7 +204,11 @@ def _async_validate_cost_entity(
|
||||
|
||||
state_class = state.attributes.get("state_class")
|
||||
|
||||
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
|
||||
supported_state_classes = [
|
||||
sensor.STATE_CLASS_MEASUREMENT,
|
||||
sensor.STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
if state_class not in supported_state_classes:
|
||||
result.append(
|
||||
ValidationIssue(
|
||||
"entity_unexpected_state_class_total_increasing", entity_id, state_class
|
||||
@@ -211,8 +231,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
|
||||
if source["type"] == "grid":
|
||||
for flow in source["flow_from"]:
|
||||
_async_validate_energy_stat(
|
||||
hass, flow["stat_energy_from"], source_result
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
flow["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if flow.get("stat_cost") is not None:
|
||||
@@ -229,7 +253,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
for flow in source["flow_to"]:
|
||||
_async_validate_energy_stat(hass, flow["stat_energy_to"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
flow["stat_energy_to"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if flow.get("stat_compensation") is not None:
|
||||
_async_validate_cost_stat(
|
||||
@@ -247,7 +277,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if source.get("stat_cost") is not None:
|
||||
_async_validate_cost_stat(hass, source["stat_cost"], source_result)
|
||||
@@ -263,15 +299,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
elif source["type"] == "solar":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
elif source["type"] == "battery":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_energy_stat(hass, source["stat_energy_to"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_to"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
for device in manager.data["device_consumption"]:
|
||||
device_result: list[ValidationIssue] = []
|
||||
result.device_consumption.append(device_result)
|
||||
_async_validate_energy_stat(hass, device["stat_consumption"], device_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
device["stat_consumption"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
device_result,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState
|
||||
from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import (
|
||||
EsphomeEntity,
|
||||
EsphomeEnumMapper,
|
||||
esphome_state_property,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
||||
|
||||
FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10}
|
||||
|
||||
@@ -59,20 +54,81 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper(
|
||||
{
|
||||
LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN,
|
||||
LightColorMode.ON_OFF: COLOR_MODE_ONOFF,
|
||||
LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS,
|
||||
LightColorMode.WHITE: COLOR_MODE_WHITE,
|
||||
LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP,
|
||||
LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP,
|
||||
LightColorMode.RGB: COLOR_MODE_RGB,
|
||||
LightColorMode.RGB_WHITE: COLOR_MODE_RGBW,
|
||||
LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW,
|
||||
LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW,
|
||||
}
|
||||
)
|
||||
_COLOR_MODE_MAPPING = {
|
||||
COLOR_MODE_ONOFF: [
|
||||
LightColorCapability.ON_OFF,
|
||||
],
|
||||
COLOR_MODE_BRIGHTNESS: [
|
||||
LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS,
|
||||
# for compatibility with older clients (2021.8.x)
|
||||
LightColorCapability.BRIGHTNESS,
|
||||
],
|
||||
COLOR_MODE_COLOR_TEMP: [
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.COLOR_TEMPERATURE,
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.COLD_WARM_WHITE,
|
||||
],
|
||||
COLOR_MODE_RGB: [
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.RGB,
|
||||
],
|
||||
COLOR_MODE_RGBW: [
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.RGB
|
||||
| LightColorCapability.WHITE,
|
||||
],
|
||||
COLOR_MODE_RGBWW: [
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.RGB
|
||||
| LightColorCapability.WHITE
|
||||
| LightColorCapability.COLOR_TEMPERATURE,
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.RGB
|
||||
| LightColorCapability.COLD_WARM_WHITE,
|
||||
],
|
||||
COLOR_MODE_WHITE: [
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.WHITE
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _color_mode_to_ha(mode: int) -> str:
|
||||
"""Convert an esphome color mode to a HA color mode constant.
|
||||
|
||||
Choses the color mode that best matches the feature-set.
|
||||
"""
|
||||
candidates = []
|
||||
for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items():
|
||||
for caps in cap_lists:
|
||||
if caps == mode:
|
||||
# exact match
|
||||
return ha_mode
|
||||
if (mode & caps) == caps:
|
||||
# all requirements met
|
||||
candidates.append((ha_mode, caps))
|
||||
|
||||
if not candidates:
|
||||
return COLOR_MODE_UNKNOWN
|
||||
|
||||
# choose the color mode with the most bits set
|
||||
candidates.sort(key=lambda key: bin(key[1]).count("1"))
|
||||
return candidates[-1][0]
|
||||
|
||||
|
||||
def _filter_color_modes(
|
||||
supported: list[int], features: LightColorCapability
|
||||
) -> list[int]:
|
||||
"""Filter the given supported color modes, excluding all values that don't have the requested features."""
|
||||
return [mode for mode in supported if mode & features]
|
||||
|
||||
|
||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||
@@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
data: dict[str, Any] = {"key": self._static_info.key, "state": True}
|
||||
# The list of color modes that would fit this service call
|
||||
color_modes = self._native_supported_color_modes
|
||||
try_keep_current_mode = True
|
||||
|
||||
# rgb/brightness input is in range 0-255, but esphome uses 0-1
|
||||
|
||||
if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None:
|
||||
data["brightness"] = brightness_ha / 255
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.BRIGHTNESS
|
||||
)
|
||||
|
||||
if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None:
|
||||
rgb = tuple(x / 255 for x in rgb_ha)
|
||||
@@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
# normalize rgb
|
||||
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
|
||||
data["color_brightness"] = color_bri
|
||||
if self._supports_color_mode:
|
||||
data["color_mode"] = LightColorMode.RGB
|
||||
color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
|
||||
try_keep_current_mode = False
|
||||
|
||||
if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None:
|
||||
# pylint: disable=invalid-name
|
||||
@@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
|
||||
data["white"] = w
|
||||
data["color_brightness"] = color_bri
|
||||
if self._supports_color_mode:
|
||||
data["color_mode"] = LightColorMode.RGB_WHITE
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.RGB | LightColorCapability.WHITE
|
||||
)
|
||||
try_keep_current_mode = False
|
||||
|
||||
if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None:
|
||||
# pylint: disable=invalid-name
|
||||
@@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
color_bri = max(rgb)
|
||||
# normalize rgb
|
||||
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
|
||||
modes = self._native_supported_color_modes
|
||||
if (
|
||||
self._supports_color_mode
|
||||
and LightColorMode.RGB_COLD_WARM_WHITE in modes
|
||||
):
|
||||
color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
|
||||
if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE):
|
||||
# Device supports setting cwww values directly
|
||||
data["cold_white"] = cw
|
||||
data["warm_white"] = ww
|
||||
target_mode = LightColorMode.RGB_COLD_WARM_WHITE
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
)
|
||||
else:
|
||||
# need to convert cw+ww part to white+color_temp
|
||||
white = data["white"] = max(cw, ww)
|
||||
@@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
max_ct = self.max_mireds
|
||||
ct_ratio = ww / (cw + ww)
|
||||
data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct)
|
||||
target_mode = LightColorMode.RGB_COLOR_TEMPERATURE
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes,
|
||||
LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE,
|
||||
)
|
||||
try_keep_current_mode = False
|
||||
|
||||
data["color_brightness"] = color_bri
|
||||
if self._supports_color_mode:
|
||||
data["color_mode"] = target_mode
|
||||
|
||||
if (flash := kwargs.get(ATTR_FLASH)) is not None:
|
||||
data["flash_length"] = FLASH_LENGTHS[flash]
|
||||
@@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
|
||||
data["color_temperature"] = color_temp
|
||||
if self._supports_color_mode:
|
||||
supported_modes = self._native_supported_color_modes
|
||||
if LightColorMode.COLOR_TEMPERATURE in supported_modes:
|
||||
data["color_mode"] = LightColorMode.COLOR_TEMPERATURE
|
||||
elif LightColorMode.COLD_WARM_WHITE in supported_modes:
|
||||
data["color_mode"] = LightColorMode.COLD_WARM_WHITE
|
||||
if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE):
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLOR_TEMPERATURE
|
||||
)
|
||||
else:
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
)
|
||||
try_keep_current_mode = False
|
||||
|
||||
if (effect := kwargs.get(ATTR_EFFECT)) is not None:
|
||||
data["effect"] = effect
|
||||
@@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
# HA only sends `white` in turn_on, and reads total brightness through brightness property
|
||||
data["brightness"] = white_ha / 255
|
||||
data["white"] = 1.0
|
||||
data["color_mode"] = LightColorMode.WHITE
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes,
|
||||
LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE,
|
||||
)
|
||||
try_keep_current_mode = False
|
||||
|
||||
if self._supports_color_mode and color_modes:
|
||||
# try the color mode with the least complexity (fewest capabilities set)
|
||||
# popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671
|
||||
color_modes.sort(key=lambda mode: bin(mode).count("1"))
|
||||
data["color_mode"] = color_modes[0]
|
||||
if self._supports_color_mode and color_modes:
|
||||
if (
|
||||
try_keep_current_mode
|
||||
and self._state is not None
|
||||
and self._state.color_mode in color_modes
|
||||
):
|
||||
# if possible, stay with the color mode that is already set
|
||||
data["color_mode"] = self._state.color_mode
|
||||
else:
|
||||
# otherwise try the color mode with the least complexity (fewest capabilities set)
|
||||
# popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671
|
||||
color_modes.sort(key=lambda mode: bin(mode).count("1"))
|
||||
data["color_mode"] = color_modes[0]
|
||||
|
||||
await self._client.light_command(**data)
|
||||
|
||||
@@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
return None
|
||||
return next(iter(supported))
|
||||
|
||||
return _COLOR_MODES.from_esphome(self._state.color_mode)
|
||||
return _color_mode_to_ha(self._state.color_mode)
|
||||
|
||||
@esphome_state_property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
@@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
|
||||
"""Return the rgbww color value [int, int, int, int, int]."""
|
||||
rgb = cast("tuple[int, int, int]", self.rgb_color)
|
||||
if (
|
||||
not self._supports_color_mode
|
||||
or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE
|
||||
if not _filter_color_modes(
|
||||
self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
):
|
||||
# Try to reverse white + color temp to cwww
|
||||
min_ct = self._static_info.min_mireds
|
||||
@@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
return self._state.effect
|
||||
|
||||
@property
|
||||
def _native_supported_color_modes(self) -> list[LightColorMode]:
|
||||
def _native_supported_color_modes(self) -> list[int]:
|
||||
return self._static_info.supported_color_modes_compat(self._api_version)
|
||||
|
||||
@property
|
||||
@@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
# All color modes except UNKNOWN,ON_OFF support transition
|
||||
modes = self._native_supported_color_modes
|
||||
if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes):
|
||||
if any(m not in (0, LightColorCapability.ON_OFF) for m in modes):
|
||||
flags |= SUPPORT_TRANSITION
|
||||
if self._static_info.effects:
|
||||
flags |= SUPPORT_EFFECT
|
||||
@@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Flag supported color modes."""
|
||||
return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes))
|
||||
supported = set(map(_color_mode_to_ha, self._native_supported_color_modes))
|
||||
if COLOR_MODE_ONOFF in supported and len(supported) > 1:
|
||||
supported.remove(COLOR_MODE_ONOFF)
|
||||
if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1:
|
||||
supported.remove(COLOR_MODE_BRIGHTNESS)
|
||||
if COLOR_MODE_WHITE in supported and len(supported) == 1:
|
||||
supported.remove(COLOR_MODE_WHITE)
|
||||
return supported
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str]:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "ESPHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/esphome",
|
||||
"requirements": ["aioesphomeapi==7.0.0"],
|
||||
"requirements": ["aioesphomeapi==8.0.0"],
|
||||
"zeroconf": ["_esphomelib._tcp.local."],
|
||||
"codeowners": ["@OttoWinter", "@jesserockz"],
|
||||
"after_dependencies": ["zeroconf", "tag"],
|
||||
|
||||
@@ -5,7 +5,13 @@ import datetime
|
||||
import logging
|
||||
from typing import Callable, TypedDict
|
||||
|
||||
from fritzconnection.core.exceptions import FritzConnectionException
|
||||
from fritzconnection.core.exceptions import (
|
||||
FritzActionError,
|
||||
FritzActionFailedError,
|
||||
FritzConnectionException,
|
||||
FritzInternalError,
|
||||
FritzServiceError,
|
||||
)
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -108,28 +114,28 @@ def _retrieve_link_noise_margin_sent_state(
|
||||
status: FritzStatus, last_value: str
|
||||
) -> float:
|
||||
"""Return upload noise margin."""
|
||||
return status.noise_margin[0] # type: ignore[no-any-return]
|
||||
return status.noise_margin[0] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def _retrieve_link_noise_margin_received_state(
|
||||
status: FritzStatus, last_value: str
|
||||
) -> float:
|
||||
"""Return download noise margin."""
|
||||
return status.noise_margin[1] # type: ignore[no-any-return]
|
||||
return status.noise_margin[1] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def _retrieve_link_attenuation_sent_state(
|
||||
status: FritzStatus, last_value: str
|
||||
) -> float:
|
||||
"""Return upload line attenuation."""
|
||||
return status.attenuation[0] # type: ignore[no-any-return]
|
||||
return status.attenuation[0] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def _retrieve_link_attenuation_received_state(
|
||||
status: FritzStatus, last_value: str
|
||||
) -> float:
|
||||
"""Return download line attenuation."""
|
||||
return status.attenuation[1] # type: ignore[no-any-return]
|
||||
return status.attenuation[1] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class SensorData(TypedDict, total=False):
|
||||
@@ -260,12 +266,21 @@ async def async_setup_entry(
|
||||
return
|
||||
|
||||
entities = []
|
||||
dslinterface = await hass.async_add_executor_job(
|
||||
fritzbox_tools.connection.call_action,
|
||||
"WANDSLInterfaceConfig:1",
|
||||
"GetInfo",
|
||||
)
|
||||
dsl: bool = dslinterface["NewEnable"]
|
||||
dsl: bool = False
|
||||
try:
|
||||
dslinterface = await hass.async_add_executor_job(
|
||||
fritzbox_tools.connection.call_action,
|
||||
"WANDSLInterfaceConfig:1",
|
||||
"GetInfo",
|
||||
)
|
||||
dsl = dslinterface["NewEnable"]
|
||||
except (
|
||||
FritzInternalError,
|
||||
FritzActionError,
|
||||
FritzActionFailedError,
|
||||
FritzServiceError,
|
||||
):
|
||||
pass
|
||||
|
||||
for sensor_type, sensor_data in SENSOR_DATA.items():
|
||||
if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20210825.0"
|
||||
"home-assistant-frontend==20210830.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
|
||||
media_player.DOMAIN: TYPE_SETTOP,
|
||||
scene.DOMAIN: TYPE_SCENE,
|
||||
script.DOMAIN: TYPE_SCENE,
|
||||
sensor.DOMAIN: TYPE_SENSOR,
|
||||
select.DOMAIN: TYPE_SENSOR,
|
||||
switch.DOMAIN: TYPE_SWITCH,
|
||||
vacuum.DOMAIN: TYPE_VACUUM,
|
||||
|
||||
@@ -108,6 +108,7 @@ TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState"
|
||||
TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel"
|
||||
TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator"
|
||||
TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage"
|
||||
TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState"
|
||||
|
||||
PREFIX_COMMANDS = "action.devices.commands."
|
||||
COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff"
|
||||
@@ -2286,3 +2287,57 @@ class ChannelTrait(_Trait):
|
||||
blocking=True,
|
||||
context=data.context,
|
||||
)
|
||||
|
||||
|
||||
@register_trait
|
||||
class SensorStateTrait(_Trait):
|
||||
"""Trait to get sensor state.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/sensorstate
|
||||
"""
|
||||
|
||||
sensor_types = {
|
||||
sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"),
|
||||
sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"),
|
||||
sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"),
|
||||
sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"),
|
||||
sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"),
|
||||
sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: (
|
||||
"VolatileOrganicCompounds",
|
||||
"PARTS_PER_MILLION",
|
||||
),
|
||||
}
|
||||
|
||||
name = TRAIT_SENSOR_STATE
|
||||
commands = []
|
||||
|
||||
@classmethod
|
||||
def supported(cls, domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
return (
|
||||
domain == sensor.DOMAIN
|
||||
and device_class in SensorStateTrait.sensor_types.keys()
|
||||
)
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return attributes for a sync request."""
|
||||
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
data = self.sensor_types.get(device_class)
|
||||
if data is not None:
|
||||
return {
|
||||
"sensorStatesSupported": {
|
||||
"name": data[0],
|
||||
"numericCapabilities": {"rawValueUnit": data[1]},
|
||||
}
|
||||
}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return the attributes of this trait for this entity."""
|
||||
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
data = self.sensor_types.get(device_class)
|
||||
if data is not None:
|
||||
return {
|
||||
"currentSensorStateData": [
|
||||
{"name": data[0], "rawValue": self.state.state}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt"
|
||||
|
||||
SERVER_URLS = [
|
||||
"https://server.growatt.com/",
|
||||
"https://server-us.growatt.com",
|
||||
"https://server-us.growatt.com/",
|
||||
"http://server.smten.com/",
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.client import ClientTimeout
|
||||
from aiohttp.hdrs import (
|
||||
CACHE_CONTROL,
|
||||
CONTENT_ENCODING,
|
||||
CONTENT_LENGTH,
|
||||
CONTENT_TYPE,
|
||||
@@ -51,6 +52,8 @@ NO_AUTH = re.compile(
|
||||
r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$"
|
||||
)
|
||||
|
||||
NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$")
|
||||
|
||||
|
||||
class HassIOView(HomeAssistantView):
|
||||
"""Hass.io view to handle base part."""
|
||||
@@ -104,7 +107,7 @@ class HassIOView(HomeAssistantView):
|
||||
|
||||
# Stream response
|
||||
response = web.StreamResponse(
|
||||
status=client.status, headers=_response_header(client)
|
||||
status=client.status, headers=_response_header(client, path)
|
||||
)
|
||||
response.content_type = client.content_type
|
||||
|
||||
@@ -139,7 +142,7 @@ def _init_header(request: web.Request) -> dict[str, str]:
|
||||
return headers
|
||||
|
||||
|
||||
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
|
||||
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
|
||||
"""Create response header."""
|
||||
headers = {}
|
||||
|
||||
@@ -153,6 +156,9 @@ def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
|
||||
continue
|
||||
headers[name] = value
|
||||
|
||||
if NO_STORE.match(path):
|
||||
headers[CACHE_CONTROL] = "no-store, max-age=0"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from homeassistant.components.switch import DOMAIN, SwitchEntity
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
|
||||
from . import ATTR_NEW, CecEntity
|
||||
|
||||
@@ -34,17 +35,25 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
|
||||
def turn_on(self, **kwargs) -> None:
|
||||
"""Turn device on."""
|
||||
self._device.turn_on()
|
||||
self._attr_is_on = True
|
||||
self._state = STATE_ON
|
||||
self.schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
def turn_off(self, **kwargs) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._attr_is_on = False
|
||||
self._state = STATE_OFF
|
||||
self.schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
def toggle(self, **kwargs):
|
||||
"""Toggle the entity."""
|
||||
self._device.toggle()
|
||||
self._attr_is_on = not self._attr_is_on
|
||||
if self._state == STATE_ON:
|
||||
self._state = STATE_OFF
|
||||
else:
|
||||
self._state = STATE_ON
|
||||
self.schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
return self._state == STATE_ON
|
||||
|
||||
@@ -498,7 +498,10 @@ async def _async_get_supported_devices(hass):
|
||||
"""Return all supported devices."""
|
||||
results = await device_automation.async_get_device_automations(hass, "trigger")
|
||||
dev_reg = device_registry.async_get(hass)
|
||||
unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results}
|
||||
unsorted = {
|
||||
device_id: dev_reg.async_get(device_id).name or device_id
|
||||
for device_id in results
|
||||
}
|
||||
return dict(sorted(unsorted.items(), key=lambda item: item[1]))
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for Honeywell (US) Total Connect Comfort climate systems."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import somecomfort
|
||||
@@ -9,7 +10,8 @@ from homeassistant.util import Throttle
|
||||
|
||||
from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
|
||||
UPDATE_LOOP_SLEEP_TIME = 5
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
|
||||
PLATFORMS = ["climate"]
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ async def async_setup_entry(hass, config):
|
||||
return False
|
||||
|
||||
data = HoneywellData(hass, client, username, password, devices)
|
||||
await data.update()
|
||||
await data.async_update()
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][config.entry_id] = data
|
||||
hass.config_entries.async_setup_platforms(config, PLATFORMS)
|
||||
@@ -102,18 +104,19 @@ class HoneywellData:
|
||||
self.devices = devices
|
||||
return True
|
||||
|
||||
def _refresh_devices(self):
|
||||
async def _refresh_devices(self):
|
||||
"""Refresh each enabled device."""
|
||||
for device in self.devices:
|
||||
device.refresh()
|
||||
await self._hass.async_add_executor_job(device.refresh)
|
||||
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state."""
|
||||
retries = 3
|
||||
while retries > 0:
|
||||
try:
|
||||
await self._hass.async_add_executor_job(self._refresh_devices)
|
||||
await self._refresh_devices()
|
||||
break
|
||||
except (
|
||||
somecomfort.client.APIRateLimited,
|
||||
@@ -124,7 +127,7 @@ class HoneywellData:
|
||||
if retries == 0:
|
||||
raise exp
|
||||
|
||||
result = await self._hass.async_add_executor_job(self._retry())
|
||||
result = await self._retry()
|
||||
|
||||
if not result:
|
||||
raise exp
|
||||
|
||||
@@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = {
|
||||
"follow schedule": FAN_AUTO,
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Honeywell thermostat."""
|
||||
@@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
|
||||
async def async_update(self):
|
||||
"""Get the latest state from the service."""
|
||||
await self._data.update()
|
||||
await self._data.async_update()
|
||||
|
||||
@@ -118,12 +118,16 @@ async def async_validate_trigger_config(hass, config):
|
||||
|
||||
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
||||
|
||||
if (
|
||||
not device
|
||||
or device.model not in REMOTES
|
||||
or trigger not in REMOTES[device.model]
|
||||
):
|
||||
raise InvalidDeviceAutomationConfig
|
||||
if not device:
|
||||
raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found")
|
||||
|
||||
if device.model not in REMOTES:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Device model {device.model} is not a remote"
|
||||
)
|
||||
|
||||
if trigger not in REMOTES[device.model]:
|
||||
raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
self.is_osram = False
|
||||
self.is_philips = False
|
||||
self.is_innr = False
|
||||
self.is_livarno = False
|
||||
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
|
||||
self.gamut = None
|
||||
else:
|
||||
self.is_osram = light.manufacturername == "OSRAM"
|
||||
self.is_philips = light.manufacturername == "Philips"
|
||||
self.is_innr = light.manufacturername == "innr"
|
||||
self.is_livarno = light.manufacturername.startswith("_TZ3000_")
|
||||
self.gamut_typ = self.light.colorgamuttype
|
||||
self.gamut = self.light.colorgamut
|
||||
_LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut))
|
||||
@@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
"""Return the warmest color_temp that this light supports."""
|
||||
if self.is_group:
|
||||
return super().max_mireds
|
||||
if self.is_livarno:
|
||||
return 500
|
||||
|
||||
max_mireds = self.light.controlcapabilities.get("ct", {}).get("max")
|
||||
|
||||
@@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
elif flash == FLASH_SHORT:
|
||||
command["alert"] = "select"
|
||||
del command["on"]
|
||||
elif not self.is_innr:
|
||||
elif not self.is_innr and not self.is_livarno:
|
||||
command["alert"] = "none"
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
@@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
elif flash == FLASH_SHORT:
|
||||
command["alert"] = "select"
|
||||
del command["on"]
|
||||
elif not self.is_innr:
|
||||
elif not self.is_innr and not self.is_livarno:
|
||||
command["alert"] = "none"
|
||||
|
||||
if self.is_group:
|
||||
|
||||
@@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity):
|
||||
"""Move the shade to a position."""
|
||||
current_hass_position = hd_position_to_hass(self._current_cover_position)
|
||||
steps_to_move = abs(current_hass_position - target_hass_position)
|
||||
if not steps_to_move:
|
||||
return
|
||||
self._async_schedule_update_for_transition(steps_to_move)
|
||||
self._async_update_from_command(
|
||||
await self._shade.move(
|
||||
|
||||
@@ -67,13 +67,13 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return max valid temperature that can be set."""
|
||||
return 80.0
|
||||
"""Return min valid temperature that can be set."""
|
||||
return 30.0
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return max valid temperature that can be set."""
|
||||
return 30.0
|
||||
return 80.0
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
|
||||
@@ -106,20 +106,14 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
"""Initialize the integration sensor."""
|
||||
self._sensor_source_id = source_entity
|
||||
self._round_digits = round_digits
|
||||
self._state = 0
|
||||
self._state = STATE_UNAVAILABLE
|
||||
self._method = integration_method
|
||||
|
||||
self._name = name if name is not None else f"{source_entity} integral"
|
||||
|
||||
if unit_of_measurement is None:
|
||||
self._unit_template = (
|
||||
f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
|
||||
)
|
||||
# we postpone the definition of unit_of_measurement to later
|
||||
self._unit_of_measurement = None
|
||||
else:
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
|
||||
self._unit_template = (
|
||||
f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
|
||||
)
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
|
||||
self._unit_time = UNIT_TIME[unit_time]
|
||||
self._attr_state_class = STATE_CLASS_TOTAL_INCREASING
|
||||
@@ -135,10 +129,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
_LOGGER.warning("Could not restore last state: %s", err)
|
||||
else:
|
||||
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
|
||||
self._unit_of_measurement = state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT
|
||||
)
|
||||
if self._unit_of_measurement is None:
|
||||
self._unit_of_measurement = state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT
|
||||
)
|
||||
|
||||
@callback
|
||||
def calc_integration(event):
|
||||
@@ -193,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate integral: %s", err)
|
||||
else:
|
||||
self._state += integral
|
||||
if isinstance(self._state, Decimal):
|
||||
self._state += integral
|
||||
else:
|
||||
self._state = integral
|
||||
self.async_write_ha_state()
|
||||
|
||||
async_track_state_change_event(
|
||||
@@ -208,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return round(self._state, self._round_digits)
|
||||
if isinstance(self._state, Decimal):
|
||||
return round(self._state, self._round_digits)
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
|
||||
24
homeassistant/components/iotawatt/__init__.py
Normal file
24
homeassistant/components/iotawatt/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""The iotawatt integration."""
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IotawattUpdater
|
||||
|
||||
PLATFORMS = ("sensor",)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up iotawatt from a config entry."""
|
||||
coordinator = IotawattUpdater(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
107
homeassistant/components/iotawatt/config_flow.py
Normal file
107
homeassistant/components/iotawatt/config_flow.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Config flow for iotawatt integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from iotawattpy.iotawatt import Iotawatt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import httpx_client
|
||||
|
||||
from .const import CONNECTION_ERRORS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: core.HomeAssistant, data: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
iotawatt = Iotawatt(
|
||||
"",
|
||||
data[CONF_HOST],
|
||||
httpx_client.get_async_client(hass),
|
||||
data.get(CONF_USERNAME),
|
||||
data.get(CONF_PASSWORD),
|
||||
)
|
||||
try:
|
||||
is_connected = await iotawatt.connect()
|
||||
except CONNECTION_ERRORS:
|
||||
return {"base": "cannot_connect"}
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return {"base": "unknown"}
|
||||
|
||||
if not is_connected:
|
||||
return {"base": "invalid_auth"}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for iotawatt."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
self._data = {}
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
|
||||
}
|
||||
)
|
||||
if not user_input:
|
||||
return self.async_show_form(step_id="user", data_schema=schema)
|
||||
|
||||
if not (errors := await validate_input(self.hass, user_input)):
|
||||
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
|
||||
|
||||
if errors == {"base": "invalid_auth"}:
|
||||
self._data.update(user_input)
|
||||
return await self.async_step_auth()
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_auth(self, user_input=None):
|
||||
"""Authenticate user if authentication is enabled on the IoTaWatt device."""
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
|
||||
): str,
|
||||
}
|
||||
)
|
||||
if not user_input:
|
||||
return self.async_show_form(step_id="auth", data_schema=data_schema)
|
||||
|
||||
data = {**self._data, **user_input}
|
||||
|
||||
if errors := await validate_input(self.hass, data):
|
||||
return self.async_show_form(
|
||||
step_id="auth", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
return self.async_create_entry(title=data[CONF_HOST], data=data)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
12
homeassistant/components/iotawatt/const.py
Normal file
12
homeassistant/components/iotawatt/const.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Constants for the IoTaWatt integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
DOMAIN = "iotawatt"
|
||||
VOLT_AMPERE_REACTIVE = "VAR"
|
||||
VOLT_AMPERE_REACTIVE_HOURS = "VARh"
|
||||
|
||||
CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError)
|
||||
56
homeassistant/components/iotawatt/coordinator.py
Normal file
56
homeassistant/components/iotawatt/coordinator.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""IoTaWatt DataUpdateCoordinator."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from iotawattpy.iotawatt import Iotawatt
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import httpx_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONNECTION_ERRORS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IotawattUpdater(DataUpdateCoordinator):
|
||||
"""Class to manage fetching update data from the IoTaWatt Energy Device."""
|
||||
|
||||
api: Iotawatt | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize IotaWattUpdater object."""
|
||||
self.entry = entry
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
name=entry.title,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Fetch sensors from IoTaWatt device."""
|
||||
if self.api is None:
|
||||
api = Iotawatt(
|
||||
self.entry.title,
|
||||
self.entry.data[CONF_HOST],
|
||||
httpx_client.get_async_client(self.hass),
|
||||
self.entry.data.get(CONF_USERNAME),
|
||||
self.entry.data.get(CONF_PASSWORD),
|
||||
)
|
||||
try:
|
||||
is_authenticated = await api.connect()
|
||||
except CONNECTION_ERRORS as err:
|
||||
raise UpdateFailed("Connection failed") from err
|
||||
|
||||
if not is_authenticated:
|
||||
raise UpdateFailed("Authentication error")
|
||||
|
||||
self.api = api
|
||||
|
||||
await self.api.update()
|
||||
return self.api.getSensors()
|
||||
13
homeassistant/components/iotawatt/manifest.json
Normal file
13
homeassistant/components/iotawatt/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "iotawatt",
|
||||
"name": "IoTaWatt",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
|
||||
"requirements": [
|
||||
"iotawattpy==0.0.8"
|
||||
],
|
||||
"codeowners": [
|
||||
"@gtdiehl"
|
||||
],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
218
homeassistant/components/iotawatt/sensor.py
Normal file
218
homeassistant/components/iotawatt/sensor.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Support for IoTaWatt Energy monitor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from iotawattpy.sensor import Sensor
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_CURRENT,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_POWER_FACTOR,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
ELECTRIC_CURRENT_AMPERE,
|
||||
ELECTRIC_POTENTIAL_VOLT,
|
||||
ENERGY_WATT_HOUR,
|
||||
FREQUENCY_HERTZ,
|
||||
PERCENTAGE,
|
||||
POWER_VOLT_AMPERE,
|
||||
POWER_WATT,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity, entity_registry, update_coordinator
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
|
||||
from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS
|
||||
from .coordinator import IotawattUpdater
|
||||
|
||||
|
||||
@dataclass
|
||||
class IotaWattSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class describing IotaWatt sensor entities."""
|
||||
|
||||
value: Callable | None = None
|
||||
|
||||
|
||||
ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = {
|
||||
"Amps": IotaWattSensorEntityDescription(
|
||||
"Amps",
|
||||
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"Hz": IotaWattSensorEntityDescription(
|
||||
"Hz",
|
||||
native_unit_of_measurement=FREQUENCY_HERTZ,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
icon="mdi:flash",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"PF": IotaWattSensorEntityDescription(
|
||||
"PF",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
value=lambda value: value * 100,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"Watts": IotaWattSensorEntityDescription(
|
||||
"Watts",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
),
|
||||
"WattHours": IotaWattSensorEntityDescription(
|
||||
"WattHours",
|
||||
native_unit_of_measurement=ENERGY_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
),
|
||||
"VA": IotaWattSensorEntityDescription(
|
||||
"VA",
|
||||
native_unit_of_measurement=POWER_VOLT_AMPERE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
icon="mdi:flash",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"VAR": IotaWattSensorEntityDescription(
|
||||
"VAR",
|
||||
native_unit_of_measurement=VOLT_AMPERE_REACTIVE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
icon="mdi:flash",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"VARh": IotaWattSensorEntityDescription(
|
||||
"VARh",
|
||||
native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
icon="mdi:flash",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"Volts": IotaWattSensorEntityDescription(
|
||||
"Volts",
|
||||
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Add sensors for passed config_entry in HA."""
|
||||
coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id]
|
||||
created = set()
|
||||
|
||||
@callback
|
||||
def _create_entity(key: str) -> IotaWattSensor:
|
||||
"""Create a sensor entity."""
|
||||
created.add(key)
|
||||
return IotaWattSensor(
|
||||
coordinator=coordinator,
|
||||
key=key,
|
||||
mac_address=coordinator.data["sensors"][key].hub_mac_address,
|
||||
name=coordinator.data["sensors"][key].getName(),
|
||||
entity_description=ENTITY_DESCRIPTION_KEY_MAP.get(
|
||||
coordinator.data["sensors"][key].getUnit(),
|
||||
IotaWattSensorEntityDescription("base_sensor"),
|
||||
),
|
||||
)
|
||||
|
||||
async_add_entities(_create_entity(key) for key in coordinator.data["sensors"])
|
||||
|
||||
@callback
|
||||
def new_data_received():
|
||||
"""Check for new sensors."""
|
||||
entities = [
|
||||
_create_entity(key)
|
||||
for key in coordinator.data["sensors"]
|
||||
if key not in created
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.async_add_listener(new_data_received)
|
||||
|
||||
|
||||
class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity):
|
||||
"""Defines a IoTaWatt Energy Sensor."""
|
||||
|
||||
entity_description: IotaWattSensorEntityDescription
|
||||
_attr_force_update = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
key,
|
||||
mac_address,
|
||||
name,
|
||||
entity_description: IotaWattSensorEntityDescription,
|
||||
):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self._key = key
|
||||
data = self._sensor_data
|
||||
if data.getType() == "Input":
|
||||
self._attr_unique_id = (
|
||||
f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}"
|
||||
)
|
||||
self.entity_description = entity_description
|
||||
|
||||
@property
|
||||
def _sensor_data(self) -> Sensor:
|
||||
"""Return sensor data."""
|
||||
return self.coordinator.data["sensors"][self._key]
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return name of the entity."""
|
||||
return self._sensor_data.getName()
|
||||
|
||||
@property
|
||||
def device_info(self) -> entity.DeviceInfo | None:
|
||||
"""Return device info."""
|
||||
return {
|
||||
"connections": {
|
||||
(CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address)
|
||||
},
|
||||
"manufacturer": "IoTaWatt",
|
||||
"model": "IoTaWatt",
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if self._key not in self.coordinator.data["sensors"]:
|
||||
if self._attr_unique_id:
|
||||
entity_registry.async_get(self.hass).async_remove(self.entity_id)
|
||||
else:
|
||||
self.hass.async_create_task(self.async_remove())
|
||||
return
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the extra state attributes of the entity."""
|
||||
data = self._sensor_data
|
||||
attrs = {"type": data.getType()}
|
||||
if attrs["type"] == "Input":
|
||||
attrs["channel"] = data.getChannel()
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def native_value(self) -> entity.StateType:
|
||||
"""Return the state of the sensor."""
|
||||
if func := self.entity_description.value:
|
||||
return func(self._sensor_data.getValue())
|
||||
|
||||
return self._sensor_data.getValue()
|
||||
23
homeassistant/components/iotawatt/strings.json
Normal file
23
homeassistant/components/iotawatt/strings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
homeassistant/components/iotawatt/translations/en.json
Normal file
24
homeassistant/components/iotawatt/translations/en.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"auth": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
},
|
||||
"description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "iotawatt"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "IQVIA",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/iqvia",
|
||||
"requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"],
|
||||
"requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ class LIFXLight(LightEntity):
|
||||
|
||||
model = product_map.get(self.bulb.product) or self.bulb.product
|
||||
if model is not None:
|
||||
info["model"] = model
|
||||
info["model"] = str(model)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LIFX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lifx",
|
||||
"requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"],
|
||||
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
|
||||
"homekit": {
|
||||
"models": ["LIFX"]
|
||||
},
|
||||
|
||||
@@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901
|
||||
)
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes:
|
||||
if (
|
||||
supported_color_modes
|
||||
and ATTR_WHITE in params
|
||||
and COLOR_MODE_WHITE in supported_color_modes
|
||||
):
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
# Remove deprecated white value if the light supports color mode
|
||||
|
||||
@@ -4,10 +4,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.significant_change import (
|
||||
check_numeric_changed,
|
||||
either_one_none,
|
||||
)
|
||||
from homeassistant.helpers.significant_change import check_absolute_change
|
||||
|
||||
from . import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -37,24 +34,21 @@ def async_check_significant_change(
|
||||
old_color = old_attrs.get(ATTR_HS_COLOR)
|
||||
new_color = new_attrs.get(ATTR_HS_COLOR)
|
||||
|
||||
if either_one_none(old_color, new_color):
|
||||
return True
|
||||
|
||||
if old_color and new_color:
|
||||
# Range 0..360
|
||||
if check_numeric_changed(old_color[0], new_color[0], 5):
|
||||
if check_absolute_change(old_color[0], new_color[0], 5):
|
||||
return True
|
||||
|
||||
# Range 0..100
|
||||
if check_numeric_changed(old_color[1], new_color[1], 3):
|
||||
if check_absolute_change(old_color[1], new_color[1], 3):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3
|
||||
):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
# Default range 153..500
|
||||
old_attrs.get(ATTR_COLOR_TEMP),
|
||||
new_attrs.get(ATTR_COLOR_TEMP),
|
||||
@@ -62,7 +56,7 @@ def async_check_significant_change(
|
||||
):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
# Range 0..255
|
||||
old_attrs.get(ATTR_WHITE_VALUE),
|
||||
new_attrs.get(ATTR_WHITE_VALUE),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Litter-Robot",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
|
||||
"requirements": ["pylitterbot==2021.8.0"],
|
||||
"requirements": ["pylitterbot==2021.8.1"],
|
||||
"codeowners": ["@natekspencer"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ from homeassistant.helpers.integration_platform import (
|
||||
from homeassistant.loader import bind_hass
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
ENTITY_ID_JSON_TEMPLATE = '"entity_id": ?"{}"'
|
||||
ENTITY_ID_JSON_TEMPLATE = '"entity_id":"{}"'
|
||||
ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"')
|
||||
DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"')
|
||||
ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Mazda Connected Services",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/mazda",
|
||||
"requirements": ["pymazda==0.2.0"],
|
||||
"requirements": ["pymazda==0.2.1"],
|
||||
"codeowners": ["@bdr99"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -243,7 +243,7 @@ class ModbusHub:
|
||||
self._msg_wait = 0
|
||||
|
||||
def _log_error(self, text: str, error_state=True):
|
||||
log_text = f"Pymodbus: {text}"
|
||||
log_text = f"Pymodbus: {self.name}: {text}"
|
||||
if self._in_error:
|
||||
_LOGGER.debug(log_text)
|
||||
else:
|
||||
|
||||
@@ -10,6 +10,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_COMMAND_OFF,
|
||||
CONF_COMMAND_ON,
|
||||
CONF_COUNT,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -23,9 +25,11 @@ from homeassistant.const import (
|
||||
|
||||
from .const import (
|
||||
CONF_DATA_TYPE,
|
||||
CONF_INPUT_TYPE,
|
||||
CONF_SWAP,
|
||||
CONF_SWAP_BYTE,
|
||||
CONF_SWAP_NONE,
|
||||
CONF_WRITE_TYPE,
|
||||
DATA_TYPE_CUSTOM,
|
||||
DATA_TYPE_FLOAT,
|
||||
DATA_TYPE_FLOAT16,
|
||||
@@ -201,15 +205,23 @@ def scan_interval_validator(config: dict) -> dict:
|
||||
def duplicate_entity_validator(config: dict) -> dict:
|
||||
"""Control scan_interval."""
|
||||
for hub_index, hub in enumerate(config):
|
||||
addresses: set[str] = set()
|
||||
for component, conf_key in PLATFORMS:
|
||||
if conf_key not in hub:
|
||||
continue
|
||||
names: set[str] = set()
|
||||
errors: list[int] = []
|
||||
addresses: set[str] = set()
|
||||
for index, entry in enumerate(hub[conf_key]):
|
||||
name = entry[CONF_NAME]
|
||||
addr = str(entry[CONF_ADDRESS])
|
||||
if CONF_INPUT_TYPE in entry:
|
||||
addr += "_" + str(entry[CONF_INPUT_TYPE])
|
||||
elif CONF_WRITE_TYPE in entry:
|
||||
addr += "_" + str(entry[CONF_WRITE_TYPE])
|
||||
if CONF_COMMAND_ON in entry:
|
||||
addr += "_" + str(entry[CONF_COMMAND_ON])
|
||||
if CONF_COMMAND_OFF in entry:
|
||||
addr += "_" + str(entry[CONF_COMMAND_OFF])
|
||||
if CONF_SLAVE in entry:
|
||||
addr += "_" + str(entry[CONF_SLAVE])
|
||||
if addr in addresses:
|
||||
@@ -236,7 +248,10 @@ def duplicate_modbus_validator(config: list) -> list:
|
||||
errors = []
|
||||
for index, hub in enumerate(config):
|
||||
name = hub.get(CONF_NAME, DEFAULT_HUB)
|
||||
host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST]
|
||||
if hub[CONF_TYPE] == SERIAL:
|
||||
host = hub[CONF_PORT]
|
||||
else:
|
||||
host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}"
|
||||
if host in hosts:
|
||||
err = f"Modbus {name} contains duplicate host/port {host}, not loaded!"
|
||||
_LOGGER.warning(err)
|
||||
|
||||
@@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_hassio(self, discovery_info):
|
||||
"""Receive a Hass.io discovery."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
self._hassio_discovery = discovery_info
|
||||
|
||||
|
||||
@@ -53,10 +53,34 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
|
||||
|
||||
DEFAULT_NAME = "MQTT Sensor"
|
||||
DEFAULT_FORCE_UPDATE = False
|
||||
|
||||
|
||||
def validate_options(conf):
|
||||
"""Validate options.
|
||||
|
||||
If last reset topic is present it must be same as the state topic.
|
||||
"""
|
||||
if (
|
||||
CONF_LAST_RESET_TOPIC in conf
|
||||
and CONF_STATE_TOPIC in conf
|
||||
and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC]
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC
|
||||
)
|
||||
|
||||
if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf:
|
||||
_LOGGER.warning(
|
||||
"'%s' must be set if '%s' is set",
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_LAST_RESET_TOPIC,
|
||||
)
|
||||
|
||||
return conf
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
# Deprecated, remove in Home Assistant 2021.11
|
||||
cv.deprecated(CONF_LAST_RESET_TOPIC),
|
||||
cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE),
|
||||
mqtt.MQTT_RO_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
@@ -69,6 +93,7 @@ PLATFORM_SCHEMA = vol.All(
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema),
|
||||
validate_options,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity):
|
||||
"""(Re)Subscribe to topics."""
|
||||
topics = {}
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
def message_received(msg):
|
||||
"""Handle new MQTT messages."""
|
||||
def _update_state(msg):
|
||||
payload = msg.payload
|
||||
# auto-expire enabled?
|
||||
expire_after = self._config.get(CONF_EXPIRE_AFTER)
|
||||
@@ -164,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity):
|
||||
variables=variables,
|
||||
)
|
||||
self._state = payload
|
||||
self.async_write_ha_state()
|
||||
|
||||
topics["state_topic"] = {
|
||||
"topic": self._config[CONF_STATE_TOPIC],
|
||||
"msg_callback": message_received,
|
||||
"qos": self._config[CONF_QOS],
|
||||
}
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
def last_reset_message_received(msg):
|
||||
"""Handle new last_reset messages."""
|
||||
def _update_last_reset(msg):
|
||||
payload = msg.payload
|
||||
|
||||
template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE)
|
||||
@@ -198,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity):
|
||||
_LOGGER.warning(
|
||||
"Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic
|
||||
)
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
def message_received(msg):
|
||||
"""Handle new MQTT messages."""
|
||||
_update_state(msg)
|
||||
if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and (
|
||||
CONF_LAST_RESET_TOPIC not in self._config
|
||||
or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC]
|
||||
):
|
||||
_update_last_reset(msg)
|
||||
self.async_write_ha_state()
|
||||
|
||||
if CONF_LAST_RESET_TOPIC in self._config:
|
||||
topics["state_topic"] = {
|
||||
"topic": self._config[CONF_STATE_TOPIC],
|
||||
"msg_callback": message_received,
|
||||
"qos": self._config[CONF_QOS],
|
||||
}
|
||||
|
||||
@callback
|
||||
@log_messages(self.hass, self.entity_id)
|
||||
def last_reset_message_received(msg):
|
||||
"""Handle new last_reset messages."""
|
||||
_update_last_reset(msg)
|
||||
self.async_write_ha_state()
|
||||
|
||||
if (
|
||||
CONF_LAST_RESET_TOPIC in self._config
|
||||
and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC]
|
||||
):
|
||||
topics["last_reset_topic"] = {
|
||||
"topic": self._config[CONF_LAST_RESET_TOPIC],
|
||||
"msg_callback": last_reset_message_received,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -14,7 +14,11 @@ from getmac import get_mac_address
|
||||
from mac_vendor_lookup import AsyncMacLookup
|
||||
from nmap import PortScanner, PortScannerError
|
||||
|
||||
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_CONSIDER_HOME,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
@@ -37,7 +41,6 @@ from .const import (
|
||||
# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n'
|
||||
NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true"
|
||||
MAX_SCAN_ATTEMPTS: Final = 16
|
||||
OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3
|
||||
|
||||
|
||||
def short_hostname(hostname: str) -> str:
|
||||
@@ -65,7 +68,7 @@ class NmapDevice:
|
||||
manufacturer: str
|
||||
reason: str
|
||||
last_update: datetime
|
||||
offline_scans: int
|
||||
first_offline: datetime | None
|
||||
|
||||
|
||||
class NmapTrackedDevices:
|
||||
@@ -137,6 +140,7 @@ class NmapDeviceScanner:
|
||||
"""Initialize the scanner."""
|
||||
self.devices = devices
|
||||
self.home_interval = None
|
||||
self.consider_home = DEFAULT_CONSIDER_HOME
|
||||
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
@@ -170,6 +174,10 @@ class NmapDeviceScanner:
|
||||
self.home_interval = timedelta(
|
||||
minutes=cv.positive_int(config[CONF_HOME_INTERVAL])
|
||||
)
|
||||
if config.get(CONF_CONSIDER_HOME):
|
||||
self.consider_home = timedelta(
|
||||
seconds=cv.positive_float(config[CONF_CONSIDER_HOME])
|
||||
)
|
||||
self._scan_lock = asyncio.Lock()
|
||||
if self._hass.state == CoreState.running:
|
||||
await self._async_start_scanner()
|
||||
@@ -320,16 +328,35 @@ class NmapDeviceScanner:
|
||||
return result
|
||||
|
||||
@callback
|
||||
def _async_increment_device_offline(self, ipv4, reason):
|
||||
def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None:
|
||||
"""Mark an IP offline."""
|
||||
if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)):
|
||||
return
|
||||
if not (device := self.devices.tracked.get(formatted_mac)):
|
||||
# Device was unloaded
|
||||
return
|
||||
device.offline_scans += 1
|
||||
if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE:
|
||||
if not device.first_offline:
|
||||
_LOGGER.debug(
|
||||
"Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now
|
||||
)
|
||||
device.first_offline = now
|
||||
return
|
||||
if device.first_offline + self.consider_home > now:
|
||||
_LOGGER.debug(
|
||||
"Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s",
|
||||
ipv4,
|
||||
formatted_mac,
|
||||
device.first_offline,
|
||||
self.consider_home,
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s",
|
||||
ipv4,
|
||||
formatted_mac,
|
||||
device.first_offline,
|
||||
self.consider_home,
|
||||
)
|
||||
device.reason = reason
|
||||
async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False)
|
||||
del self.devices.ipv4_last_mac[ipv4]
|
||||
@@ -347,7 +374,7 @@ class NmapDeviceScanner:
|
||||
status = info["status"]
|
||||
reason = status["reason"]
|
||||
if status["state"] != "up":
|
||||
self._async_increment_device_offline(ipv4, reason)
|
||||
self._async_device_offline(ipv4, reason, now)
|
||||
continue
|
||||
# Mac address only returned if nmap ran as root
|
||||
mac = info["addresses"].get(
|
||||
@@ -356,19 +383,11 @@ class NmapDeviceScanner:
|
||||
partial(get_mac_address, ip=ipv4)
|
||||
)
|
||||
if mac is None:
|
||||
self._async_increment_device_offline(ipv4, "No MAC address found")
|
||||
self._async_device_offline(ipv4, "No MAC address found", now)
|
||||
_LOGGER.info("No MAC address found for %s", ipv4)
|
||||
continue
|
||||
|
||||
formatted_mac = format_mac(mac)
|
||||
new = formatted_mac not in devices.tracked
|
||||
if (
|
||||
new
|
||||
and formatted_mac not in devices.tracked
|
||||
and formatted_mac not in self._known_mac_addresses
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
devices.config_entry_owner.setdefault(formatted_mac, entry_id)
|
||||
!= entry_id
|
||||
@@ -379,9 +398,10 @@ class NmapDeviceScanner:
|
||||
vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac)
|
||||
name = human_readable_name(hostname, vendor, mac)
|
||||
device = NmapDevice(
|
||||
formatted_mac, hostname, name, ipv4, vendor, reason, now, 0
|
||||
formatted_mac, hostname, name, ipv4, vendor, reason, now, None
|
||||
)
|
||||
|
||||
new = formatted_mac not in devices.tracked
|
||||
devices.tracked[formatted_mac] = device
|
||||
devices.ipv4_last_mac[ipv4] = formatted_mac
|
||||
self._last_results.append(device)
|
||||
|
||||
@@ -8,7 +8,11 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import network
|
||||
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_CONSIDER_HOME,
|
||||
)
|
||||
from homeassistant.components.network.const import MDNS_TARGET_IP
|
||||
from homeassistant.config_entries import ConfigEntry, OptionsFlow
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
@@ -24,6 +28,8 @@ from .const import (
|
||||
TRACKER_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
MAX_SCAN_INTERVAL = 3600
|
||||
MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6
|
||||
DEFAULT_NETWORK_PREFIX = 24
|
||||
|
||||
|
||||
@@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input(
|
||||
vol.Optional(
|
||||
CONF_SCAN_INTERVAL,
|
||||
default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)),
|
||||
vol.Optional(
|
||||
CONF_CONSIDER_HOME,
|
||||
default=user_input.get(CONF_CONSIDER_HOME)
|
||||
or DEFAULT_CONSIDER_HOME.total_seconds(),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)),
|
||||
}
|
||||
)
|
||||
return vol.Schema(schema)
|
||||
|
||||
@@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import (
|
||||
SOURCE_TYPE_ROUTER,
|
||||
)
|
||||
from homeassistant.components.device_tracker.config_entry import ScannerEntity
|
||||
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_CONSIDER_HOME,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOSTS): cv.ensure_list,
|
||||
vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int,
|
||||
vol.Required(
|
||||
CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
): cv.time_period,
|
||||
vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string,
|
||||
}
|
||||
@@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
else:
|
||||
scan_interval = TRACKER_SCAN_INTERVAL
|
||||
|
||||
if CONF_CONSIDER_HOME in validated_config:
|
||||
consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds()
|
||||
else:
|
||||
consider_home = DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
|
||||
import_config = {
|
||||
CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]),
|
||||
CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL],
|
||||
CONF_CONSIDER_HOME: consider_home,
|
||||
CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]),
|
||||
CONF_OPTIONS: validated_config[CONF_OPTIONS],
|
||||
CONF_SCAN_INTERVAL: scan_interval,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"data": {
|
||||
"hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]",
|
||||
"home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]",
|
||||
"consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.",
|
||||
"exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]",
|
||||
"scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]",
|
||||
"interval_seconds": "Scan interval"
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.",
|
||||
"exclude": "Network addresses (comma seperated) to exclude from scanning",
|
||||
"home_interval": "Minimum number of minutes between scans of active devices (preserve battery)",
|
||||
"hosts": "Network addresses (comma seperated) to scan",
|
||||
"interval_seconds": "Scan interval",
|
||||
"scan_options": "Raw configurable scan options for Nmap",
|
||||
"track_new_devices": "Track new devices"
|
||||
"scan_options": "Raw configurable scan options for Nmap"
|
||||
},
|
||||
"description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)."
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ class ONVIFDevice:
|
||||
err,
|
||||
)
|
||||
self.available = False
|
||||
await self.device.close()
|
||||
except Fault as err:
|
||||
LOGGER.error(
|
||||
"Couldn't connect to camera '%s', please verify "
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
"domain": "onvif",
|
||||
"name": "ONVIF",
|
||||
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
||||
"requirements": [
|
||||
"onvif-zeep-async==1.0.0",
|
||||
"WSDiscovery==2.0.0",
|
||||
"zeep[async]==4.0.0"
|
||||
],
|
||||
"requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@hunterjm"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
|
||||
altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation),
|
||||
session=websession,
|
||||
logger=LOGGER,
|
||||
),
|
||||
)
|
||||
await openuv.async_update()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "OpenUV",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/openuv",
|
||||
"requirements": ["pyopenuv==2.1.0"],
|
||||
"requirements": ["pyopenuv==2.2.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "P1 Monitor",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/p1_monitor",
|
||||
"requirements": ["p1monitor==0.2.0"],
|
||||
"requirements": ["p1monitor==1.0.0"],
|
||||
"codeowners": ["@klaasnicolaas"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "local_polling"
|
||||
|
||||
@@ -192,33 +192,33 @@ SENSORS: dict[
|
||||
),
|
||||
SERVICE_SETTINGS: (
|
||||
SensorEntityDescription(
|
||||
key="gas_consumption_tariff",
|
||||
name="Gas Consumption - Tariff",
|
||||
key="gas_consumption_price",
|
||||
name="Gas Consumption Price",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=DEVICE_CLASS_MONETARY,
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_consumption_low_tariff",
|
||||
name="Energy Consumption - Low Tariff",
|
||||
key="energy_consumption_price_low",
|
||||
name="Energy Consumption Price - Low",
|
||||
device_class=DEVICE_CLASS_MONETARY,
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_consumption_high_tariff",
|
||||
name="Energy Consumption - High Tariff",
|
||||
key="energy_consumption_price_high",
|
||||
name="Energy Consumption Price - High",
|
||||
device_class=DEVICE_CLASS_MONETARY,
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_production_low_tariff",
|
||||
name="Energy Production - Low Tariff",
|
||||
key="energy_production_price_low",
|
||||
name="Energy Production Price - Low",
|
||||
device_class=DEVICE_CLASS_MONETARY,
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_production_high_tariff",
|
||||
name="Energy Production - High Tariff",
|
||||
key="energy_production_price_high",
|
||||
name="Energy Production Price - High",
|
||||
device_class=DEVICE_CLASS_MONETARY,
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
),
|
||||
|
||||
@@ -91,7 +91,7 @@ _DRIVE_MON_COND = {
|
||||
"mdi:checkbox-marked-circle-outline",
|
||||
None,
|
||||
],
|
||||
"drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE],
|
||||
"drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
|
||||
}
|
||||
_VOLUME_MON_COND = {
|
||||
"volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None],
|
||||
|
||||
@@ -54,7 +54,7 @@ async def async_get_type(hass, cloud_id, install_code, host):
|
||||
meters = await hub.get_device_list()
|
||||
except aioeagle.BadAuth as err:
|
||||
raise InvalidAuth from err
|
||||
except aiohttp.ClientError:
|
||||
except (KeyError, aiohttp.ClientError):
|
||||
# This can happen if it's an eagle-100
|
||||
meters = None
|
||||
|
||||
|
||||
@@ -38,21 +38,22 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SENSORS = (
|
||||
SensorEntityDescription(
|
||||
key="zigbee:InstantaneousDemand",
|
||||
name="Meter Power Demand",
|
||||
# We can drop the "Eagle-200" part of the name in HA 2021.12
|
||||
name="Eagle-200 Meter Power Demand",
|
||||
native_unit_of_measurement=POWER_KILO_WATT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="zigbee:CurrentSummationDelivered",
|
||||
name="Total Meter Energy Delivered",
|
||||
name="Eagle-200 Total Meter Energy Delivered",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="zigbee:CurrentSummationReceived",
|
||||
name="Total Meter Energy Received",
|
||||
name="Eagle-200 Total Meter Energy Received",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
|
||||
@@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
try:
|
||||
await client.async_get_next_pickup_event()
|
||||
await client.async_get_pickup_events()
|
||||
except RecollectError as err:
|
||||
LOGGER.error("Error during setup of integration: %s", err)
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "ReCollect Waste",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/recollect_waste",
|
||||
"requirements": ["aiorecollect==1.0.7"],
|
||||
"requirements": ["aiorecollect==1.0.8"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Support for ReCollect Waste sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from aiorecollect.client import PickupType
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -74,6 +76,12 @@ async def async_setup_platform(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_utc_midnight(target_date: date) -> datetime:
|
||||
"""Get UTC midnight for a given date."""
|
||||
return as_utc(datetime.combine(target_date, time(0)))
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
@@ -124,7 +132,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity):
|
||||
ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names(
|
||||
self._entry, next_pickup_event.pickup_types
|
||||
),
|
||||
ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(),
|
||||
ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight(
|
||||
next_pickup_event.date
|
||||
).isoformat(),
|
||||
}
|
||||
)
|
||||
self._attr_native_value = as_utc(pickup_event.date).isoformat()
|
||||
self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat()
|
||||
|
||||
@@ -70,7 +70,7 @@ DOUBLE_TYPE = (
|
||||
Float()
|
||||
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql")
|
||||
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
|
||||
.with_variant(postgresql.DOUBLE_PRECISION, "postgresql")
|
||||
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
|
||||
)
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore
|
||||
class StatisticMetaData(TypedDict, total=False):
|
||||
"""Statistic meta data class."""
|
||||
|
||||
statistic_id: str
|
||||
unit_of_measurement: str | None
|
||||
has_mean: bool
|
||||
has_sum: bool
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from sqlalchemy import bindparam
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext import baked
|
||||
from sqlalchemy.orm.scoping import scoped_session
|
||||
|
||||
@@ -53,6 +54,13 @@ QUERY_STATISTIC_META = [
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
StatisticsMeta.unit_of_measurement,
|
||||
StatisticsMeta.has_mean,
|
||||
StatisticsMeta.has_sum,
|
||||
]
|
||||
|
||||
QUERY_STATISTIC_META_ID = [
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
]
|
||||
|
||||
STATISTICS_BAKERY = "recorder_statistics_bakery"
|
||||
@@ -124,33 +132,61 @@ def _get_metadata_ids(
|
||||
) -> list[str]:
|
||||
"""Resolve metadata_id for a list of statistic_ids."""
|
||||
baked_query = hass.data[STATISTICS_META_BAKERY](
|
||||
lambda session: session.query(*QUERY_STATISTIC_META)
|
||||
lambda session: session.query(*QUERY_STATISTIC_META_ID)
|
||||
)
|
||||
baked_query += lambda q: q.filter(
|
||||
StatisticsMeta.statistic_id.in_(bindparam("statistic_ids"))
|
||||
)
|
||||
result = execute(baked_query(session).params(statistic_ids=statistic_ids))
|
||||
|
||||
return [id for id, _, _ in result] if result else []
|
||||
return [id for id, _ in result] if result else []
|
||||
|
||||
|
||||
def _get_or_add_metadata_id(
|
||||
def _update_or_add_metadata(
|
||||
hass: HomeAssistant,
|
||||
session: scoped_session,
|
||||
statistic_id: str,
|
||||
metadata: StatisticMetaData,
|
||||
new_metadata: StatisticMetaData,
|
||||
) -> str:
|
||||
"""Get metadata_id for a statistic_id, add if it doesn't exist."""
|
||||
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
|
||||
if not metadata_id:
|
||||
unit = metadata["unit_of_measurement"]
|
||||
has_mean = metadata["has_mean"]
|
||||
has_sum = metadata["has_sum"]
|
||||
old_metadata_dict = _get_metadata(hass, session, [statistic_id], None)
|
||||
if not old_metadata_dict:
|
||||
unit = new_metadata["unit_of_measurement"]
|
||||
has_mean = new_metadata["has_mean"]
|
||||
has_sum = new_metadata["has_sum"]
|
||||
session.add(
|
||||
StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum)
|
||||
)
|
||||
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
|
||||
return metadata_id[0]
|
||||
metadata_ids = _get_metadata_ids(hass, session, [statistic_id])
|
||||
_LOGGER.debug(
|
||||
"Added new statistics metadata for %s, new_metadata: %s",
|
||||
statistic_id,
|
||||
new_metadata,
|
||||
)
|
||||
return metadata_ids[0]
|
||||
|
||||
metadata_id, old_metadata = next(iter(old_metadata_dict.items()))
|
||||
if (
|
||||
old_metadata["has_mean"] != new_metadata["has_mean"]
|
||||
or old_metadata["has_sum"] != new_metadata["has_sum"]
|
||||
or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"]
|
||||
):
|
||||
session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update(
|
||||
{
|
||||
StatisticsMeta.has_mean: new_metadata["has_mean"],
|
||||
StatisticsMeta.has_sum: new_metadata["has_sum"],
|
||||
StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"],
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s",
|
||||
statistic_id,
|
||||
old_metadata,
|
||||
new_metadata,
|
||||
)
|
||||
|
||||
return metadata_id
|
||||
|
||||
|
||||
@retryable_database_job("statistics")
|
||||
@@ -177,10 +213,17 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool:
|
||||
with session_scope(session=instance.get_session()) as session: # type: ignore
|
||||
for stats in platform_stats:
|
||||
for entity_id, stat in stats.items():
|
||||
metadata_id = _get_or_add_metadata_id(
|
||||
metadata_id = _update_or_add_metadata(
|
||||
instance.hass, session, entity_id, stat["meta"]
|
||||
)
|
||||
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
|
||||
try:
|
||||
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
|
||||
except SQLAlchemyError:
|
||||
_LOGGER.exception(
|
||||
"Unexpected exception when inserting statistics %s:%s ",
|
||||
metadata_id,
|
||||
stat,
|
||||
)
|
||||
session.add(StatisticsRuns(start=start))
|
||||
|
||||
return True
|
||||
@@ -191,14 +234,19 @@ def _get_metadata(
|
||||
session: scoped_session,
|
||||
statistic_ids: list[str] | None,
|
||||
statistic_type: str | None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
) -> dict[str, StatisticMetaData]:
|
||||
"""Fetch meta data."""
|
||||
|
||||
def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None:
|
||||
meta = None
|
||||
for metadata_id, statistic_id, unit in metas:
|
||||
def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None:
|
||||
meta: StatisticMetaData | None = None
|
||||
for metadata_id, statistic_id, unit, has_mean, has_sum in metas:
|
||||
if metadata_id == wanted_metadata_id:
|
||||
meta = {"unit_of_measurement": unit, "statistic_id": statistic_id}
|
||||
meta = {
|
||||
"statistic_id": statistic_id,
|
||||
"unit_of_measurement": unit,
|
||||
"has_mean": has_mean,
|
||||
"has_sum": has_sum,
|
||||
}
|
||||
return meta
|
||||
|
||||
baked_query = hass.data[STATISTICS_META_BAKERY](
|
||||
@@ -219,7 +267,7 @@ def _get_metadata(
|
||||
return {}
|
||||
|
||||
metadata_ids = [metadata[0] for metadata in result]
|
||||
metadata = {}
|
||||
metadata: dict[str, StatisticMetaData] = {}
|
||||
for _id in metadata_ids:
|
||||
meta = _meta(result, _id)
|
||||
if meta:
|
||||
@@ -230,7 +278,7 @@ def _get_metadata(
|
||||
def get_metadata(
|
||||
hass: HomeAssistant,
|
||||
statistic_id: str,
|
||||
) -> dict[str, str] | None:
|
||||
) -> StatisticMetaData | None:
|
||||
"""Return metadata for a statistic_id."""
|
||||
statistic_ids = [statistic_id]
|
||||
with session_scope(hass=hass) as session:
|
||||
@@ -255,7 +303,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str:
|
||||
|
||||
def list_statistic_ids(
|
||||
hass: HomeAssistant, statistic_type: str | None = None
|
||||
) -> list[dict[str, str] | None]:
|
||||
) -> list[StatisticMetaData | None]:
|
||||
"""Return statistic_ids and meta data."""
|
||||
units = hass.config.units
|
||||
statistic_ids = {}
|
||||
@@ -263,7 +311,9 @@ def list_statistic_ids(
|
||||
metadata = _get_metadata(hass, session, None, statistic_type)
|
||||
|
||||
for meta in metadata.values():
|
||||
unit = _configured_unit(meta["unit_of_measurement"], units)
|
||||
unit = meta["unit_of_measurement"]
|
||||
if unit is not None:
|
||||
unit = _configured_unit(unit, units)
|
||||
meta["unit_of_measurement"] = unit
|
||||
|
||||
statistic_ids = {
|
||||
@@ -277,7 +327,8 @@ def list_statistic_ids(
|
||||
platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type)
|
||||
|
||||
for statistic_id, unit in platform_statistic_ids.items():
|
||||
unit = _configured_unit(unit, units)
|
||||
if unit is not None:
|
||||
unit = _configured_unit(unit, units)
|
||||
platform_statistic_ids[statistic_id] = unit
|
||||
|
||||
statistic_ids = {**statistic_ids, **platform_statistic_ids}
|
||||
@@ -326,11 +377,11 @@ def statistics_during_period(
|
||||
)
|
||||
if not stats:
|
||||
return {}
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True)
|
||||
|
||||
|
||||
def get_last_statistics(
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str
|
||||
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."""
|
||||
statistic_ids = [statistic_id]
|
||||
@@ -360,19 +411,26 @@ def get_last_statistics(
|
||||
if not stats:
|
||||
return {}
|
||||
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
|
||||
return _sorted_statistics_to_dict(
|
||||
hass, stats, statistic_ids, metadata, convert_units
|
||||
)
|
||||
|
||||
|
||||
def _sorted_statistics_to_dict(
|
||||
hass: HomeAssistant,
|
||||
stats: list,
|
||||
statistic_ids: list[str] | None,
|
||||
metadata: dict[str, dict[str, str]],
|
||||
metadata: dict[str, StatisticMetaData],
|
||||
convert_units: bool,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Convert SQL results into JSON friendly data structure."""
|
||||
result: dict = defaultdict(list)
|
||||
units = hass.config.units
|
||||
|
||||
def no_conversion(val: Any, _: Any) -> float | None:
|
||||
"""Return x."""
|
||||
return val # type: ignore
|
||||
|
||||
# Set all statistic IDs to empty lists in result set to maintain the order
|
||||
if statistic_ids is not None:
|
||||
for stat_id in statistic_ids:
|
||||
@@ -385,9 +443,11 @@ def _sorted_statistics_to_dict(
|
||||
for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore
|
||||
unit = metadata[meta_id]["unit_of_measurement"]
|
||||
statistic_id = metadata[meta_id]["statistic_id"]
|
||||
convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get(
|
||||
unit, lambda x, units: x # type: ignore
|
||||
)
|
||||
convert: Callable[[Any, Any], float | None]
|
||||
if convert_units:
|
||||
convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore
|
||||
else:
|
||||
convert = no_conversion
|
||||
ent_results = result[meta_id]
|
||||
ent_results.extend(
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
SENSOR_TYPES = (
|
||||
RfxtrxSensorEntityDescription(
|
||||
key="Barameter",
|
||||
key="Barometer",
|
||||
device_class=DEVICE_CLASS_PRESSURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
native_unit_of_measurement=PRESSURE_HPA,
|
||||
|
||||
@@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self._last_event = None
|
||||
self._last_video_id = None
|
||||
self._video_url = None
|
||||
self._image = None
|
||||
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
@@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self._last_event = None
|
||||
self._last_video_id = None
|
||||
self._video_url = None
|
||||
self._image = None
|
||||
self._expires_at = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
if self._video_url is None:
|
||||
return
|
||||
if self._image is None and self._video_url:
|
||||
image = await ffmpeg.async_get_image(
|
||||
self.hass,
|
||||
self._video_url,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
return await ffmpeg.async_get_image(
|
||||
self.hass, self._video_url, width=width, height=height
|
||||
)
|
||||
if image:
|
||||
self._image = image
|
||||
|
||||
return self._image
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
@@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera):
|
||||
if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at:
|
||||
return
|
||||
|
||||
if self._last_video_id != self._last_event["id"]:
|
||||
self._image = None
|
||||
|
||||
try:
|
||||
video_url = await self.hass.async_add_executor_job(
|
||||
self._device.recording_url, self._last_event["id"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ring",
|
||||
"name": "Ring",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ring",
|
||||
"requirements": ["ring_doorbell==0.6.2"],
|
||||
"requirements": ["ring_doorbell==0.7.1"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -240,7 +240,8 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
|
||||
|
||||
def _send_key(self, key):
|
||||
"""Send the key using legacy protocol."""
|
||||
self._get_remote().control(key)
|
||||
if remote := self._get_remote():
|
||||
remote.control(key)
|
||||
|
||||
def stop(self):
|
||||
"""Stop Bridge."""
|
||||
@@ -315,7 +316,8 @@ class SamsungTVWSBridge(SamsungTVBridge):
|
||||
"""Send the key using websocket protocol."""
|
||||
if key == "KEY_POWEROFF":
|
||||
key = "KEY_POWER"
|
||||
self._get_remote().send_key(key)
|
||||
if remote := self._get_remote():
|
||||
remote.send_key(key)
|
||||
|
||||
def _get_remote(self, avoid_open: bool = False):
|
||||
"""Create or return a remote control instance."""
|
||||
|
||||
@@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage"
|
||||
CONSUMPTION_ID = "usage"
|
||||
PRODUCTION_NAME = "Production"
|
||||
PRODUCTION_ID = "production"
|
||||
PRODUCTION_PCT_NAME = "Net Production Percentage"
|
||||
PRODUCTION_PCT_ID = "production_pct"
|
||||
NET_PRODUCTION_NAME = "Net Production"
|
||||
NET_PRODUCTION_ID = "net_production"
|
||||
TO_GRID_NAME = "To Grid"
|
||||
TO_GRID_ID = "to_grid"
|
||||
FROM_GRID_NAME = "From Grid"
|
||||
FROM_GRID_ID = "from_grid"
|
||||
SOLAR_POWERED_NAME = "Solar Powered Percentage"
|
||||
SOLAR_POWERED_ID = "solar_powered"
|
||||
|
||||
ICON = "mdi:flash"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.const import (
|
||||
DEVICE_CLASS_POWER,
|
||||
ELECTRIC_POTENTIAL_VOLT,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
PERCENTAGE,
|
||||
POWER_WATT,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
@@ -22,15 +23,25 @@ from .const import (
|
||||
CONSUMPTION_ID,
|
||||
CONSUMPTION_NAME,
|
||||
DOMAIN,
|
||||
FROM_GRID_ID,
|
||||
FROM_GRID_NAME,
|
||||
ICON,
|
||||
MDI_ICONS,
|
||||
NET_PRODUCTION_ID,
|
||||
NET_PRODUCTION_NAME,
|
||||
PRODUCTION_ID,
|
||||
PRODUCTION_NAME,
|
||||
PRODUCTION_PCT_ID,
|
||||
PRODUCTION_PCT_NAME,
|
||||
SENSE_DATA,
|
||||
SENSE_DEVICE_UPDATE,
|
||||
SENSE_DEVICES_DATA,
|
||||
SENSE_DISCOVERED_DEVICES_DATA,
|
||||
SENSE_TRENDS_COORDINATOR,
|
||||
SOLAR_POWERED_ID,
|
||||
SOLAR_POWERED_NAME,
|
||||
TO_GRID_ID,
|
||||
TO_GRID_NAME,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +66,16 @@ TRENDS_SENSOR_TYPES = {
|
||||
}
|
||||
|
||||
# Production/consumption variants
|
||||
SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID]
|
||||
SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)]
|
||||
|
||||
# Trend production/consumption variants
|
||||
TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [
|
||||
(PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME),
|
||||
(NET_PRODUCTION_ID, NET_PRODUCTION_NAME),
|
||||
(FROM_GRID_ID, FROM_GRID_NAME),
|
||||
(TO_GRID_ID, TO_GRID_NAME),
|
||||
(SOLAR_POWERED_ID, SOLAR_POWERED_NAME),
|
||||
]
|
||||
|
||||
|
||||
def sense_to_mdi(sense_icon):
|
||||
@@ -86,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
if device["tags"]["DeviceListAllowed"] == "true"
|
||||
]
|
||||
|
||||
for var in SENSOR_VARIANTS:
|
||||
for variant_id, variant_name in SENSOR_VARIANTS:
|
||||
name = ACTIVE_SENSOR_TYPE.name
|
||||
sensor_type = ACTIVE_SENSOR_TYPE.sensor_type
|
||||
is_production = var == PRODUCTION_ID
|
||||
|
||||
unique_id = f"{sense_monitor_id}-active-{var}"
|
||||
unique_id = f"{sense_monitor_id}-active-{variant_id}"
|
||||
devices.append(
|
||||
SenseActiveSensor(
|
||||
data, name, sensor_type, is_production, sense_monitor_id, var, unique_id
|
||||
data,
|
||||
name,
|
||||
sensor_type,
|
||||
sense_monitor_id,
|
||||
variant_id,
|
||||
variant_name,
|
||||
unique_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -102,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
devices.append(SenseVoltageSensor(data, i, sense_monitor_id))
|
||||
|
||||
for type_id, typ in TRENDS_SENSOR_TYPES.items():
|
||||
for var in SENSOR_VARIANTS:
|
||||
for variant_id, variant_name in TREND_SENSOR_VARIANTS:
|
||||
name = typ.name
|
||||
sensor_type = typ.sensor_type
|
||||
is_production = var == PRODUCTION_ID
|
||||
|
||||
unique_id = f"{sense_monitor_id}-{type_id}-{var}"
|
||||
unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}"
|
||||
devices.append(
|
||||
SenseTrendsSensor(
|
||||
data,
|
||||
name,
|
||||
sensor_type,
|
||||
is_production,
|
||||
variant_id,
|
||||
variant_name,
|
||||
trends_coordinator,
|
||||
unique_id,
|
||||
)
|
||||
@@ -137,19 +162,19 @@ class SenseActiveSensor(SensorEntity):
|
||||
data,
|
||||
name,
|
||||
sensor_type,
|
||||
is_production,
|
||||
sense_monitor_id,
|
||||
sensor_id,
|
||||
variant_id,
|
||||
variant_name,
|
||||
unique_id,
|
||||
):
|
||||
"""Initialize the Sense sensor."""
|
||||
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
|
||||
self._attr_name = f"{name} {name_type}"
|
||||
self._attr_name = f"{name} {variant_name}"
|
||||
self._attr_unique_id = unique_id
|
||||
self._data = data
|
||||
self._sense_monitor_id = sense_monitor_id
|
||||
self._sensor_type = sensor_type
|
||||
self._is_production = is_production
|
||||
self._variant_id = variant_id
|
||||
self._variant_name = variant_name
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
@@ -166,7 +191,7 @@ class SenseActiveSensor(SensorEntity):
|
||||
"""Update the sensor from the data. Must not do I/O."""
|
||||
new_state = round(
|
||||
self._data.active_solar_power
|
||||
if self._is_production
|
||||
if self._variant_id == PRODUCTION_ID
|
||||
else self._data.active_power
|
||||
)
|
||||
if self._attr_available and self._attr_native_value == new_state:
|
||||
@@ -235,24 +260,30 @@ class SenseTrendsSensor(SensorEntity):
|
||||
data,
|
||||
name,
|
||||
sensor_type,
|
||||
is_production,
|
||||
variant_id,
|
||||
variant_name,
|
||||
trends_coordinator,
|
||||
unique_id,
|
||||
):
|
||||
"""Initialize the Sense sensor."""
|
||||
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
|
||||
self._attr_name = f"{name} {name_type}"
|
||||
self._attr_name = f"{name} {variant_name}"
|
||||
self._attr_unique_id = unique_id
|
||||
self._data = data
|
||||
self._sensor_type = sensor_type
|
||||
self._coordinator = trends_coordinator
|
||||
self._is_production = is_production
|
||||
self._variant_id = variant_id
|
||||
self._had_any_update = False
|
||||
|
||||
if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]:
|
||||
self._attr_native_unit_of_measurement = PERCENTAGE
|
||||
self._attr_entity_registry_enabled_default = False
|
||||
self._attr_state_class = None
|
||||
self._attr_device_class = None
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return round(self._data.get_trend(self._sensor_type, self._is_production), 1)
|
||||
return round(self._data.get_trend(self._sensor_type, self._variant_id), 1)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components.recorder import history, statistics
|
||||
@@ -108,6 +109,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = {
|
||||
}
|
||||
|
||||
# Keep track of entities for which a warning about decreasing value has been logged
|
||||
SEEN_DIP = "sensor_seen_total_increasing_dip"
|
||||
WARN_DIP = "sensor_warn_total_increasing_dip"
|
||||
# Keep track of entities for which a warning about unsupported unit has been logged
|
||||
WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit"
|
||||
@@ -128,13 +130,6 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]:
|
||||
return entity_ids
|
||||
|
||||
|
||||
# Faster than try/except
|
||||
# From https://stackoverflow.com/a/23639915
|
||||
def _is_number(s: str) -> bool: # pylint: disable=invalid-name
|
||||
"""Return True if string is a number."""
|
||||
return s.replace(".", "", 1).isdigit()
|
||||
|
||||
|
||||
def _time_weighted_average(
|
||||
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
|
||||
) -> float:
|
||||
@@ -178,6 +173,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]:
|
||||
return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates}
|
||||
|
||||
|
||||
def _parse_float(state: str) -> float:
|
||||
"""Parse a float string, throw on inf or nan."""
|
||||
fstate = float(state)
|
||||
if math.isnan(fstate) or math.isinf(fstate):
|
||||
raise ValueError
|
||||
return fstate
|
||||
|
||||
|
||||
def _normalize_states(
|
||||
hass: HomeAssistant,
|
||||
entity_history: list[State],
|
||||
@@ -189,9 +192,14 @@ def _normalize_states(
|
||||
|
||||
if device_class not in UNIT_CONVERSIONS:
|
||||
# We're not normalizing this device class, return the state as they are
|
||||
fstates = [
|
||||
(float(el.state), el) for el in entity_history if _is_number(el.state)
|
||||
]
|
||||
fstates = []
|
||||
for state in entity_history:
|
||||
try:
|
||||
fstate = _parse_float(state.state)
|
||||
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
|
||||
continue
|
||||
fstates.append((fstate, state))
|
||||
|
||||
if fstates:
|
||||
all_units = _get_units(fstates)
|
||||
if len(all_units) > 1:
|
||||
@@ -199,11 +207,18 @@ def _normalize_states(
|
||||
hass.data[WARN_UNSTABLE_UNIT] = set()
|
||||
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
|
||||
hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
|
||||
extra = ""
|
||||
if old_metadata := statistics.get_metadata(hass, entity_id):
|
||||
extra = (
|
||||
" and matches the unit of already compiled statistics "
|
||||
f"({old_metadata['unit_of_measurement']})"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"The unit of %s is changing, got %s, generation of long term "
|
||||
"statistics will be suppressed unless the unit is stable",
|
||||
"The unit of %s is changing, got multiple %s, generation of long term "
|
||||
"statistics will be suppressed unless the unit is stable%s",
|
||||
entity_id,
|
||||
all_units,
|
||||
extra,
|
||||
)
|
||||
return None, []
|
||||
unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
@@ -212,11 +227,10 @@ def _normalize_states(
|
||||
fstates = []
|
||||
|
||||
for state in entity_history:
|
||||
# Exclude non numerical states from statistics
|
||||
if not _is_number(state.state):
|
||||
try:
|
||||
fstate = _parse_float(state.state)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
fstate = float(state.state)
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
# Exclude unsupported units from statistics
|
||||
if unit not in UNIT_CONVERSIONS[device_class]:
|
||||
@@ -233,7 +247,17 @@ def _normalize_states(
|
||||
|
||||
|
||||
def warn_dip(hass: HomeAssistant, entity_id: str) -> None:
|
||||
"""Log a warning once if a sensor with state_class_total has a decreasing value."""
|
||||
"""Log a warning once if a sensor with state_class_total has a decreasing value.
|
||||
|
||||
The log will be suppressed until two dips have been seen to prevent warning due to
|
||||
rounding issues with databases storing the state as a single precision float, which
|
||||
was fixed in recorder DB version 20.
|
||||
"""
|
||||
if SEEN_DIP not in hass.data:
|
||||
hass.data[SEEN_DIP] = set()
|
||||
if entity_id not in hass.data[SEEN_DIP]:
|
||||
hass.data[SEEN_DIP].add(entity_id)
|
||||
return
|
||||
if WARN_DIP not in hass.data:
|
||||
hass.data[WARN_DIP] = set()
|
||||
if entity_id not in hass.data[WARN_DIP]:
|
||||
@@ -264,7 +288,22 @@ def reset_detected(
|
||||
return state < 0.9 * previous_state
|
||||
|
||||
|
||||
def compile_statistics(
|
||||
def _wanted_statistics(
|
||||
entities: list[tuple[str, str, str | None]]
|
||||
) -> dict[str, set[str]]:
|
||||
"""Prepare a dict with wanted statistics for entities."""
|
||||
wanted_statistics = {}
|
||||
for entity_id, state_class, device_class in entities:
|
||||
if device_class in DEVICE_CLASS_STATISTICS[state_class]:
|
||||
wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][
|
||||
device_class
|
||||
]
|
||||
else:
|
||||
wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class]
|
||||
return wanted_statistics
|
||||
|
||||
|
||||
def compile_statistics( # noqa: C901
|
||||
hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime
|
||||
) -> dict:
|
||||
"""Compile statistics for all entities during start-end.
|
||||
@@ -275,17 +314,32 @@ def compile_statistics(
|
||||
|
||||
entities = _get_entities(hass)
|
||||
|
||||
wanted_statistics = _wanted_statistics(entities)
|
||||
|
||||
# Get history between start and end
|
||||
history_list = history.get_significant_states( # type: ignore
|
||||
hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities]
|
||||
)
|
||||
entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]]
|
||||
history_list = {}
|
||||
if entities_full_history:
|
||||
history_list = history.get_significant_states( # type: ignore
|
||||
hass,
|
||||
start - datetime.timedelta.resolution,
|
||||
end,
|
||||
entity_ids=entities_full_history,
|
||||
significant_changes_only=False,
|
||||
)
|
||||
entities_significant_history = [
|
||||
i[0] for i in entities if "sum" not in wanted_statistics[i[0]]
|
||||
]
|
||||
if entities_significant_history:
|
||||
_history_list = history.get_significant_states( # type: ignore
|
||||
hass,
|
||||
start - datetime.timedelta.resolution,
|
||||
end,
|
||||
entity_ids=entities_significant_history,
|
||||
)
|
||||
history_list = {**history_list, **_history_list}
|
||||
|
||||
for entity_id, state_class, device_class in entities:
|
||||
if device_class in DEVICE_CLASS_STATISTICS[state_class]:
|
||||
wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class]
|
||||
else:
|
||||
wanted_statistics = DEFAULT_STATISTICS[state_class]
|
||||
|
||||
if entity_id not in history_list:
|
||||
continue
|
||||
|
||||
@@ -309,7 +363,7 @@ def compile_statistics(
|
||||
entity_id,
|
||||
unit,
|
||||
old_metadata["unit_of_measurement"],
|
||||
unit,
|
||||
old_metadata["unit_of_measurement"],
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -318,30 +372,30 @@ def compile_statistics(
|
||||
# Set meta data
|
||||
result[entity_id]["meta"] = {
|
||||
"unit_of_measurement": unit,
|
||||
"has_mean": "mean" in wanted_statistics,
|
||||
"has_sum": "sum" in wanted_statistics,
|
||||
"has_mean": "mean" in wanted_statistics[entity_id],
|
||||
"has_sum": "sum" in wanted_statistics[entity_id],
|
||||
}
|
||||
|
||||
# Make calculations
|
||||
stat: dict = {}
|
||||
if "max" in wanted_statistics:
|
||||
if "max" in wanted_statistics[entity_id]:
|
||||
stat["max"] = max(*itertools.islice(zip(*fstates), 1))
|
||||
if "min" in wanted_statistics:
|
||||
if "min" in wanted_statistics[entity_id]:
|
||||
stat["min"] = min(*itertools.islice(zip(*fstates), 1))
|
||||
|
||||
if "mean" in wanted_statistics:
|
||||
if "mean" in wanted_statistics[entity_id]:
|
||||
stat["mean"] = _time_weighted_average(fstates, start, end)
|
||||
|
||||
if "sum" in wanted_statistics:
|
||||
if "sum" in wanted_statistics[entity_id]:
|
||||
last_reset = old_last_reset = None
|
||||
new_state = old_state = None
|
||||
_sum = 0
|
||||
last_stats = statistics.get_last_statistics(hass, 1, entity_id)
|
||||
last_stats = statistics.get_last_statistics(hass, 1, entity_id, False)
|
||||
if entity_id in last_stats:
|
||||
# We have compiled history for this sensor before, use that as a starting point
|
||||
last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"]
|
||||
new_state = old_state = last_stats[entity_id][0]["state"]
|
||||
_sum = last_stats[entity_id][0]["sum"]
|
||||
_sum = last_stats[entity_id][0]["sum"] or 0
|
||||
|
||||
for fstate, state in fstates:
|
||||
|
||||
@@ -358,6 +412,19 @@ def compile_statistics(
|
||||
and (last_reset := state.attributes.get("last_reset"))
|
||||
!= old_last_reset
|
||||
):
|
||||
if old_state is None:
|
||||
_LOGGER.info(
|
||||
"Compiling initial sum statistics for %s, zero point set to %s",
|
||||
entity_id,
|
||||
fstate,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Detected new cycle for %s, last_reset set to %s (old last_reset %s)",
|
||||
entity_id,
|
||||
last_reset,
|
||||
old_last_reset,
|
||||
)
|
||||
reset = True
|
||||
elif old_state is None and last_reset is None:
|
||||
reset = True
|
||||
@@ -372,7 +439,7 @@ def compile_statistics(
|
||||
):
|
||||
reset = True
|
||||
_LOGGER.info(
|
||||
"Detected new cycle for %s, zero point set to %s (old zero point %s)",
|
||||
"Detected new cycle for %s, value dropped from %s to %s",
|
||||
entity_id,
|
||||
fstate,
|
||||
new_state,
|
||||
@@ -385,11 +452,8 @@ def compile_statistics(
|
||||
# ..and update the starting point
|
||||
new_state = fstate
|
||||
old_last_reset = last_reset
|
||||
# Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0
|
||||
if (
|
||||
state_class == STATE_CLASS_TOTAL_INCREASING
|
||||
and old_state is not None
|
||||
):
|
||||
# Force a new cycle for an existing sensor to start at 0
|
||||
if old_state is not None:
|
||||
old_state = 0.0
|
||||
else:
|
||||
old_state = new_state
|
||||
|
||||
@@ -9,8 +9,33 @@ from homeassistant.const import (
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.significant_change import (
|
||||
check_absolute_change,
|
||||
check_percentage_change,
|
||||
)
|
||||
|
||||
from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE
|
||||
from . import (
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_CO,
|
||||
DEVICE_CLASS_CO2,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
)
|
||||
|
||||
|
||||
def _absolute_and_relative_change(
|
||||
old_state: int | float | None,
|
||||
new_state: int | float | None,
|
||||
absolute_change: int | float,
|
||||
percentage_change: int | float,
|
||||
) -> bool:
|
||||
return check_absolute_change(
|
||||
old_state, new_state, absolute_change
|
||||
) and check_percentage_change(old_state, new_state, percentage_change)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -28,20 +53,35 @@ def async_check_significant_change(
|
||||
if device_class is None:
|
||||
return None
|
||||
|
||||
absolute_change: float | None = None
|
||||
percentage_change: float | None = None
|
||||
if device_class == DEVICE_CLASS_TEMPERATURE:
|
||||
if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT:
|
||||
change: float | int = 1
|
||||
absolute_change = 1.0
|
||||
else:
|
||||
change = 0.5
|
||||
|
||||
old_value = float(old_state)
|
||||
new_value = float(new_state)
|
||||
return abs(old_value - new_value) >= change
|
||||
absolute_change = 0.5
|
||||
|
||||
if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY):
|
||||
old_value = float(old_state)
|
||||
new_value = float(new_state)
|
||||
absolute_change = 1.0
|
||||
|
||||
return abs(old_value - new_value) >= 1
|
||||
if device_class in (
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CO,
|
||||
DEVICE_CLASS_CO2,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
):
|
||||
absolute_change = 1.0
|
||||
percentage_change = 2.0
|
||||
|
||||
if absolute_change is not None and percentage_change is not None:
|
||||
return _absolute_and_relative_change(
|
||||
float(old_state), float(new_state), absolute_change, percentage_change
|
||||
)
|
||||
if absolute_change is not None:
|
||||
return check_absolute_change(
|
||||
float(old_state), float(new_state), absolute_change
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Binary sensor for Shelly."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
from typing import Final, cast
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
@@ -46,7 +46,9 @@ SENSORS: Final = {
|
||||
name="Overpowering", device_class=DEVICE_CLASS_PROBLEM
|
||||
),
|
||||
("sensor", "dwIsOpened"): BlockAttributeDescription(
|
||||
name="Door", device_class=DEVICE_CLASS_OPENING
|
||||
name="Door",
|
||||
device_class=DEVICE_CLASS_OPENING,
|
||||
available=lambda block: cast(bool, block.dwIsOpened != -1),
|
||||
),
|
||||
("sensor", "flood"): BlockAttributeDescription(
|
||||
name="Flood", device_class=DEVICE_CLASS_MOISTURE
|
||||
|
||||
@@ -40,6 +40,7 @@ SENSORS: Final = {
|
||||
device_class=sensor.DEVICE_CLASS_BATTERY,
|
||||
state_class=sensor.STATE_CLASS_MEASUREMENT,
|
||||
removal_condition=lambda settings, _: settings.get("external_power") == 1,
|
||||
available=lambda block: cast(bool, block.battery != -1),
|
||||
),
|
||||
("device", "deviceTemp"): BlockAttributeDescription(
|
||||
name="Device Temperature",
|
||||
@@ -176,6 +177,7 @@ SENSORS: Final = {
|
||||
unit=LIGHT_LUX,
|
||||
device_class=sensor.DEVICE_CLASS_ILLUMINANCE,
|
||||
state_class=sensor.STATE_CLASS_MEASUREMENT,
|
||||
available=lambda block: cast(bool, block.luminosity != -1),
|
||||
),
|
||||
("sensor", "tilt"): BlockAttributeDescription(
|
||||
name="Tilt",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==11.0.4"],
|
||||
"requirements": ["simplisafe-python==11.0.6"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._device.device_id}.{self.report_name}"
|
||||
return f"{self._device.device_id}.{self.report_name}_meter"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
"""Solar-Log integration."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
from sunwatcher.solarlog.solarlog import SolarLog
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import update_coordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry for solarlog."""
|
||||
coordinator = SolarlogData(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
@@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
class SolarlogData(update_coordinator.DataUpdateCoordinator):
|
||||
"""Get and update the latest data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize the data object."""
|
||||
super().__init__(
|
||||
hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60)
|
||||
)
|
||||
|
||||
host_entry = entry.data[CONF_HOST]
|
||||
|
||||
url = urlparse(host_entry, "http")
|
||||
netloc = url.netloc or url.path
|
||||
path = url.path if url.netloc else ""
|
||||
url = ParseResult("http", netloc, path, *url[3:])
|
||||
self.unique_id = entry.entry_id
|
||||
self.name = entry.title
|
||||
self.host = url.geturl()
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update the data from the SolarLog device."""
|
||||
try:
|
||||
api = await self.hass.async_add_executor_job(SolarLog, self.host)
|
||||
except (OSError, Timeout, HTTPError) as err:
|
||||
raise update_coordinator.UpdateFailed(err)
|
||||
|
||||
if api.time.year == 1999:
|
||||
raise update_coordinator.UpdateFailed(
|
||||
"Invalid data returned (can happen after Solarlog restart)."
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
"Connection to Solarlog successful. Retrieving latest Solarlog update of %s",
|
||||
api.time,
|
||||
)
|
||||
|
||||
data = {}
|
||||
|
||||
try:
|
||||
data["TIME"] = api.time
|
||||
data["powerAC"] = api.power_ac
|
||||
data["powerDC"] = api.power_dc
|
||||
data["voltageAC"] = api.voltage_ac
|
||||
data["voltageDC"] = api.voltage_dc
|
||||
data["yieldDAY"] = api.yield_day / 1000
|
||||
data["yieldYESTERDAY"] = api.yield_yesterday / 1000
|
||||
data["yieldMONTH"] = api.yield_month / 1000
|
||||
data["yieldYEAR"] = api.yield_year / 1000
|
||||
data["yieldTOTAL"] = api.yield_total / 1000
|
||||
data["consumptionAC"] = api.consumption_ac
|
||||
data["consumptionDAY"] = api.consumption_day / 1000
|
||||
data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000
|
||||
data["consumptionMONTH"] = api.consumption_month / 1000
|
||||
data["consumptionYEAR"] = api.consumption_year / 1000
|
||||
data["consumptionTOTAL"] = api.consumption_total / 1000
|
||||
data["totalPOWER"] = api.total_power
|
||||
data["alternatorLOSS"] = api.alternator_loss
|
||||
data["CAPACITY"] = round(api.capacity * 100, 0)
|
||||
data["EFFICIENCY"] = round(api.efficiency * 100, 0)
|
||||
data["powerAVAILABLE"] = api.power_available
|
||||
data["USAGE"] = round(api.usage * 100, 0)
|
||||
except AttributeError as err:
|
||||
raise update_coordinator.UpdateFailed(
|
||||
f"Missing details data in Solarlog response: {err}"
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Updated Solarlog overview data: %s", data)
|
||||
return data
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
@@ -23,13 +22,10 @@ from homeassistant.const import (
|
||||
|
||||
DOMAIN = "solarlog"
|
||||
|
||||
"""Default config for solarlog."""
|
||||
# Default config for solarlog.
|
||||
DEFAULT_HOST = "http://solar-log"
|
||||
DEFAULT_NAME = "solarlog"
|
||||
|
||||
"""Fixed constants."""
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolarlogRequiredKeysMixin:
|
||||
|
||||
@@ -1,133 +1,42 @@
|
||||
"""Platform for solarlog sensors."""
|
||||
import logging
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
from sunwatcher.solarlog.solarlog import SolarLog
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.helpers import update_coordinator
|
||||
from homeassistant.helpers.entity import StateType
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the solarlog platform."""
|
||||
_LOGGER.warning(
|
||||
"Configuration of the solarlog platform in configuration.yaml is deprecated "
|
||||
"in Home Assistant 0.119. Please remove entry from your configuration"
|
||||
)
|
||||
from . import SolarlogData
|
||||
from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Add solarlog entry."""
|
||||
host_entry = entry.data[CONF_HOST]
|
||||
device_name = entry.title
|
||||
|
||||
url = urlparse(host_entry, "http")
|
||||
netloc = url.netloc or url.path
|
||||
path = url.path if url.netloc else ""
|
||||
url = ParseResult("http", netloc, path, *url[3:])
|
||||
host = url.geturl()
|
||||
|
||||
try:
|
||||
api = await hass.async_add_executor_job(SolarLog, host)
|
||||
_LOGGER.debug("Connected to Solar-Log device, setting up entries")
|
||||
except (OSError, HTTPError, Timeout):
|
||||
_LOGGER.error(
|
||||
"Could not connect to Solar-Log device at %s, check host ip address", host
|
||||
)
|
||||
return
|
||||
|
||||
# Create solarlog data service which will retrieve and update the data.
|
||||
data = await hass.async_add_executor_job(SolarlogData, hass, api, host)
|
||||
|
||||
# Create a new sensor for each sensor type.
|
||||
entities = [
|
||||
SolarlogSensor(entry.entry_id, device_name, data, description)
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
return True
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
SolarlogSensor(coordinator, description) for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
class SolarlogData:
|
||||
"""Get and update the latest data."""
|
||||
|
||||
def __init__(self, hass, api, host):
|
||||
"""Initialize the data object."""
|
||||
self.api = api
|
||||
self.hass = hass
|
||||
self.host = host
|
||||
self.update = Throttle(SCAN_INTERVAL)(self._update)
|
||||
self.data = {}
|
||||
|
||||
def _update(self):
|
||||
"""Update the data from the SolarLog device."""
|
||||
try:
|
||||
self.api = SolarLog(self.host)
|
||||
response = self.api.time
|
||||
_LOGGER.debug(
|
||||
"Connection to Solarlog successful. Retrieving latest Solarlog update of %s",
|
||||
response,
|
||||
)
|
||||
except (OSError, Timeout, HTTPError):
|
||||
_LOGGER.error("Connection error, Could not retrieve data, skipping update")
|
||||
return
|
||||
|
||||
try:
|
||||
self.data["TIME"] = self.api.time
|
||||
self.data["powerAC"] = self.api.power_ac
|
||||
self.data["powerDC"] = self.api.power_dc
|
||||
self.data["voltageAC"] = self.api.voltage_ac
|
||||
self.data["voltageDC"] = self.api.voltage_dc
|
||||
self.data["yieldDAY"] = self.api.yield_day / 1000
|
||||
self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000
|
||||
self.data["yieldMONTH"] = self.api.yield_month / 1000
|
||||
self.data["yieldYEAR"] = self.api.yield_year / 1000
|
||||
self.data["yieldTOTAL"] = self.api.yield_total / 1000
|
||||
self.data["consumptionAC"] = self.api.consumption_ac
|
||||
self.data["consumptionDAY"] = self.api.consumption_day / 1000
|
||||
self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000
|
||||
self.data["consumptionMONTH"] = self.api.consumption_month / 1000
|
||||
self.data["consumptionYEAR"] = self.api.consumption_year / 1000
|
||||
self.data["consumptionTOTAL"] = self.api.consumption_total / 1000
|
||||
self.data["totalPOWER"] = self.api.total_power
|
||||
self.data["alternatorLOSS"] = self.api.alternator_loss
|
||||
self.data["CAPACITY"] = round(self.api.capacity * 100, 0)
|
||||
self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0)
|
||||
self.data["powerAVAILABLE"] = self.api.power_available
|
||||
self.data["USAGE"] = round(self.api.usage * 100, 0)
|
||||
_LOGGER.debug("Updated Solarlog overview data: %s", self.data)
|
||||
except AttributeError:
|
||||
_LOGGER.error("Missing details data in Solarlog response")
|
||||
|
||||
|
||||
class SolarlogSensor(SensorEntity):
|
||||
class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Sensor."""
|
||||
|
||||
entity_description: SolarLogSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: str,
|
||||
device_name: str,
|
||||
data: SolarlogData,
|
||||
coordinator: SolarlogData,
|
||||
description: SolarLogSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self.data = data
|
||||
self._attr_name = f"{device_name} {description.name}"
|
||||
self._attr_unique_id = f"{entry_id}_{description.key}"
|
||||
self._attr_name = f"{coordinator.name} {description.name}"
|
||||
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry_id)},
|
||||
"name": device_name,
|
||||
"identifiers": {(DOMAIN, coordinator.unique_id)},
|
||||
"name": coordinator.name,
|
||||
"manufacturer": "Solar-Log",
|
||||
}
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data from the sensor and update the state."""
|
||||
self.data.update()
|
||||
self._attr_native_value = self.data.data[self.entity_description.json_key]
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native sensor value."""
|
||||
return self.coordinator.data[self.entity_description.json_key]
|
||||
|
||||
@@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the flow."""
|
||||
self._reauth = False
|
||||
self._entry_id = None
|
||||
self._entry_data = {}
|
||||
self.entry = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._reauth = True
|
||||
self._entry_data = dict(data)
|
||||
entry = await self.async_set_unique_id(self.unique_id)
|
||||
self._entry_id = entry.entry_id
|
||||
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={"host": self._entry_data[CONF_HOST]},
|
||||
description_placeholders={"host": self.entry.data[CONF_HOST]},
|
||||
data_schema=vol.Schema({}),
|
||||
errors={},
|
||||
)
|
||||
@@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
if self._reauth:
|
||||
user_input = {**self._entry_data, **user_input}
|
||||
if self.entry:
|
||||
user_input = {**self.entry.data, **user_input}
|
||||
|
||||
if CONF_VERIFY_SSL not in user_input:
|
||||
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
|
||||
@@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
else:
|
||||
if self._reauth:
|
||||
return await self._async_reauth_update_entry(
|
||||
self._entry_id, user_input
|
||||
)
|
||||
if self.entry:
|
||||
return await self._async_reauth_update_entry(user_input)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
@@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult:
|
||||
async def _async_reauth_update_entry(self, data: dict) -> FlowResult:
|
||||
"""Update existing config entry."""
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
self.hass.config_entries.async_update_entry(entry, data=data)
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
def _get_user_data_schema(self) -> dict[str, Any]:
|
||||
"""Get the data schema to display user form."""
|
||||
if self._reauth:
|
||||
if self.entry:
|
||||
return {vol.Required(CONF_API_KEY): str}
|
||||
|
||||
data_schema = {
|
||||
|
||||
@@ -223,6 +223,7 @@ async def async_setup_entry(
|
||||
{
|
||||
vol.Required(ATTR_ALARM_ID): cv.positive_int,
|
||||
vol.Optional(ATTR_TIME): cv.time,
|
||||
vol.Optional(ATTR_VOLUME): cv.small_float,
|
||||
vol.Optional(ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
|
||||
},
|
||||
|
||||
@@ -323,6 +323,18 @@ class SonosSpeaker:
|
||||
async def async_subscribe(self) -> bool:
|
||||
"""Initiate event subscriptions."""
|
||||
_LOGGER.debug("Creating subscriptions for %s", self.zone_name)
|
||||
|
||||
# Create a polling task in case subscriptions fail or callback events do not arrive
|
||||
if not self._poll_timer:
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.set_basic_info)
|
||||
|
||||
@@ -337,10 +349,10 @@ class SonosSpeaker:
|
||||
for service in SUBSCRIPTION_SERVICES
|
||||
]
|
||||
await asyncio.gather(*subscriptions)
|
||||
return True
|
||||
except SoCoException as ex:
|
||||
_LOGGER.warning("Could not connect %s: %s", self.zone_name, ex)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _subscribe(
|
||||
self, target: SubscriptionBase, sub_callback: Callable
|
||||
@@ -497,15 +509,6 @@ class SonosSpeaker:
|
||||
self.soco.ip_address,
|
||||
)
|
||||
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
if self._is_ready and not self.subscriptions_failed:
|
||||
done = await self.async_subscribe()
|
||||
if not done:
|
||||
@@ -567,15 +570,6 @@ class SonosSpeaker:
|
||||
self._seen_timer = self.hass.helpers.event.async_call_later(
|
||||
SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen
|
||||
)
|
||||
if not self._poll_timer:
|
||||
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
||||
partial(
|
||||
async_dispatcher_send,
|
||||
self.hass,
|
||||
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
|
||||
),
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
self.async_write_entity_states()
|
||||
|
||||
#
|
||||
|
||||
@@ -32,6 +32,8 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
vol.All(
|
||||
# Deprecated in Home Assistant 2021.6
|
||||
@@ -46,8 +48,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
): cv.positive_time_period,
|
||||
vol.Optional(CONF_MANUAL, default=False): cv.boolean,
|
||||
vol.Optional(
|
||||
CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)
|
||||
): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
|
||||
CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS)
|
||||
): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]),
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -286,6 +286,11 @@ class Scanner:
|
||||
if header_st is not None:
|
||||
self.seen.add((header_st, header_location))
|
||||
|
||||
def _async_unsee(self, header_st: str | None, header_location: str | None) -> None:
|
||||
"""If we see a device in a new location, unsee the original location."""
|
||||
if header_st is not None:
|
||||
self.seen.discard((header_st, header_location))
|
||||
|
||||
async def _async_process_entry(self, headers: Mapping[str, str]) -> None:
|
||||
"""Process SSDP entries."""
|
||||
_LOGGER.debug("_async_process_entry: %s", headers)
|
||||
@@ -293,7 +298,12 @@ class Scanner:
|
||||
h_location = headers.get("location")
|
||||
|
||||
if h_st and (udn := _udn_from_usn(headers.get("usn"))):
|
||||
self.cache[(udn, h_st)] = headers
|
||||
cache_key = (udn, h_st)
|
||||
if old_headers := self.cache.get(cache_key):
|
||||
old_h_location = old_headers.get("location")
|
||||
if h_location != old_h_location:
|
||||
self._async_unsee(old_headers.get("st"), old_h_location)
|
||||
self.cache[cache_key] = headers
|
||||
|
||||
callbacks = self._async_get_matching_callbacks(headers)
|
||||
if self._async_seen(h_st, h_location) and not callbacks:
|
||||
|
||||
@@ -90,7 +90,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
sensor
|
||||
for device in account.api.devices.values()
|
||||
for description in SENSOR_TYPES
|
||||
if (sensor := StarlineSensor(account, device, description)).state is not None
|
||||
if (sensor := StarlineSensor(account, device, description)).native_value
|
||||
is not None
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class SurePetcareAPI:
|
||||
"""Get the latest data from Sure Petcare."""
|
||||
|
||||
try:
|
||||
self.states = await self.surepy.get_entities()
|
||||
self.states = await self.surepy.get_entities(refresh=True)
|
||||
except SurePetcareError as error:
|
||||
_LOGGER.error("Unable to fetch data: %s", error)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Switcher",
|
||||
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
|
||||
"codeowners": ["@tomerfi","@thecode"],
|
||||
"requirements": ["aioswitcher==2.0.4"],
|
||||
"requirements": ["aioswitcher==2.0.5"],
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
|
||||
return
|
||||
|
||||
if self.home_variable == "outdoor temperature":
|
||||
self._state = self.hass.config.units.temperature(
|
||||
self._tado_weather_data["outsideTemperature"]["celsius"],
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
self._state = self._tado_weather_data["outsideTemperature"]["celsius"]
|
||||
self._state_attributes = {
|
||||
"time": self._tado_weather_data["outsideTemperature"]["timestamp"],
|
||||
}
|
||||
@@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
if self.zone_variable == "temperature":
|
||||
return self.hass.config.units.temperature_unit
|
||||
return TEMP_CELSIUS
|
||||
if self.zone_variable == "humidity":
|
||||
return PERCENTAGE
|
||||
if self.zone_variable == "heating":
|
||||
@@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
|
||||
return
|
||||
|
||||
if self.zone_variable == "temperature":
|
||||
self._state = self.hass.config.units.temperature(
|
||||
self._tado_zone_data.current_temp, TEMP_CELSIUS
|
||||
)
|
||||
self._state = self._tado_zone_data.current_temp
|
||||
self._state_attributes = {
|
||||
"time": self._tado_zone_data.current_temp_timestamp,
|
||||
"setting": 0, # setting is used in climate device
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user