mirror of
https://github.com/home-assistant/core.git
synced 2026-01-22 15:46:58 +01:00
Compare commits
9 Commits
claude/ext
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa30ed1dd8 | ||
|
|
947ed121dc | ||
|
|
9448f52d4a | ||
|
|
54be76f0ab | ||
|
|
32cd649fe4 | ||
|
|
69dc711466 | ||
|
|
78212245dd | ||
|
|
5bbc39bd88 | ||
|
|
6b14eb7ad1 |
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -477,7 +477,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -297,7 +297,7 @@ jobs:
|
||||
- &setup-python-matrix
|
||||
name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: &actions-setup-python actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
|
||||
@@ -2,14 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ArveConfigEntry, ArveCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version == 1:
|
||||
# 1 -> 1.2: Unique ID from integer to string
|
||||
if entry.minor_version == 1:
|
||||
minor_version = 2
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(entry.unique_id), minor_version=minor_version
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
|
||||
"""Set up Arve from a config entry."""
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Arve."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -35,7 +38,7 @@ class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
except ArveConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(customer.customerId)
|
||||
await self.async_set_unique_id(str(customer.customerId))
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title="Arve",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
from pyhik.constants import SENSOR_MAP
|
||||
from pyhik.hikvision import HikCamera
|
||||
@@ -88,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
|
||||
def fetch_and_inject_nvr_events() -> None:
|
||||
"""Fetch and inject NVR events in a single executor job."""
|
||||
nvr_events = camera.get_event_triggers(nvr_notification_methods)
|
||||
try:
|
||||
nvr_events = camera.get_event_triggers(nvr_notification_methods)
|
||||
except (requests.exceptions.RequestException, ParseError) as err:
|
||||
_LOGGER.warning("Unable to fetch event triggers from %s: %s", host, err)
|
||||
return
|
||||
|
||||
_LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events)
|
||||
if nvr_events:
|
||||
# Map raw event type names to friendly names using SENSOR_MAP
|
||||
@@ -101,6 +107,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
mapped_events[friendly_name] = list(channels)
|
||||
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
|
||||
camera.inject_events(mapped_events)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No event triggers returned from %s. "
|
||||
"Ensure events are configured on the device",
|
||||
host,
|
||||
)
|
||||
|
||||
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -36,6 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import HikvisionConfigEntry
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .entity import HikvisionEntity
|
||||
|
||||
CONF_IGNORED = "ignored"
|
||||
|
||||
@@ -150,7 +150,12 @@ async def async_setup_entry(
|
||||
|
||||
sensors = camera.current_event_states
|
||||
if sensors is None or not sensors:
|
||||
_LOGGER.warning("Hikvision device has no sensors available")
|
||||
_LOGGER.warning(
|
||||
"Hikvision %s %s has no sensors available. "
|
||||
"Ensure event detection is enabled and configured on the device",
|
||||
data.device_type,
|
||||
data.device_name,
|
||||
)
|
||||
return
|
||||
|
||||
async_add_entities(
|
||||
@@ -164,10 +169,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class HikvisionBinarySensor(BinarySensorEntity):
|
||||
class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity):
|
||||
"""Representation of a Hikvision binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
@@ -177,38 +181,14 @@ class HikvisionBinarySensor(BinarySensorEntity):
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
self._data = entry.runtime_data
|
||||
self._camera = self._data.camera
|
||||
super().__init__(entry, channel)
|
||||
self._sensor_type = sensor_type
|
||||
self._channel = channel
|
||||
|
||||
# Build unique ID
|
||||
# Build unique ID (includes sensor_type for uniqueness per sensor)
|
||||
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
self._attr_name = sensor_type
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
self._attr_name = sensor_type
|
||||
# Set entity name
|
||||
self._attr_name = sensor_type
|
||||
|
||||
# Set device class
|
||||
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)
|
||||
|
||||
@@ -5,11 +5,10 @@ from __future__ import annotations
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HikvisionConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import HikvisionEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -35,10 +34,9 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HikvisionCamera(Camera):
|
||||
class HikvisionCamera(HikvisionEntity, Camera):
|
||||
"""Representation of a Hikvision camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
@@ -48,37 +46,11 @@ class HikvisionCamera(Camera):
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__()
|
||||
self._data = entry.runtime_data
|
||||
self._channel = channel
|
||||
self._camera = self._data.camera
|
||||
super().__init__(entry, channel)
|
||||
|
||||
# Build unique ID (unique per platform per integration)
|
||||
self._attr_unique_id = f"{self._data.device_id}_{channel}"
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
|
||||
49
homeassistant/components/hikvision/entity.py
Normal file
49
homeassistant/components/hikvision/entity.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Base entity for Hikvision integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import HikvisionConfigEntry, HikvisionData
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class HikvisionEntity(Entity):
|
||||
"""Base class for Hikvision entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: HikvisionConfigEntry,
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__()
|
||||
self._data: HikvisionData = entry.runtime_data
|
||||
self._camera = self._data.camera
|
||||
self._channel = channel
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
@@ -19,7 +19,7 @@
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.6.4",
|
||||
"insteon-frontend-home-assistant==0.6.0"
|
||||
"insteon-frontend-home-assistant==0.6.1"
|
||||
],
|
||||
"single_config_entry": true,
|
||||
"usb": [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from microBeesPy import MicroBees
|
||||
@@ -15,6 +16,8 @@ from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeAssistantMicroBeesData:
|
||||
@@ -25,6 +28,23 @@ class HomeAssistantMicroBeesData:
|
||||
session: config_entry_oauth2_flow.OAuth2Session
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version == 1:
|
||||
# 1 -> 1.2: Unique ID from integer to string
|
||||
if entry.minor_version == 1:
|
||||
minor_version = 2
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(entry.unique_id), minor_version=minor_version
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up microBees from a config entry."""
|
||||
implementation = (
|
||||
|
||||
@@ -19,6 +19,8 @@ class OAuth2FlowHandler(
|
||||
"""Handle a config flow for microBees."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -47,7 +49,7 @@ class OAuth2FlowHandler(
|
||||
self.logger.exception("Unexpected error")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
await self.async_set_unique_id(current_user.id)
|
||||
await self.async_set_unique_id(str(current_user.id))
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -15,9 +17,28 @@ from .api import AuthenticatedMonzoAPI
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MonzoCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version == 1:
|
||||
# 1 -> 1.2: Unique ID from integer to string
|
||||
if entry.minor_version == 1:
|
||||
minor_version = 2
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(entry.unique_id), minor_version=minor_version
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Monzo from a config entry."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
|
||||
@@ -21,6 +21,8 @@ class MonzoFlowHandler(
|
||||
"""Handle a config flow."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
oauth_data: dict[str, Any]
|
||||
|
||||
@@ -51,7 +53,7 @@ class MonzoFlowHandler(
|
||||
"""Create an entry for the flow."""
|
||||
self.oauth_data = data
|
||||
user_id = data[CONF_TOKEN]["user_id"]
|
||||
await self.async_set_unique_id(user_id)
|
||||
await self.async_set_unique_id(str(user_id))
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
|
||||
@@ -253,7 +253,7 @@ class NumberDeviceClass(StrEnum):
|
||||
NITROGEN_MONOXIDE = "nitrogen_monoxide"
|
||||
"""Amount of NO.
|
||||
|
||||
Unit of measurement: `μg/m³`
|
||||
Unit of measurement: `ppb` (parts per billion), `μg/m³`
|
||||
"""
|
||||
|
||||
NITROUS_OXIDE = "nitrous_oxide"
|
||||
@@ -521,7 +521,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.NITROGEN_MONOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.OZONE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
|
||||
@@ -60,6 +60,7 @@ from homeassistant.util.unit_conversion import (
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
PowerConverter,
|
||||
PressureConverter,
|
||||
@@ -228,6 +229,7 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
|
||||
_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
TemperatureDeltaConverter,
|
||||
|
||||
@@ -34,6 +34,7 @@ from homeassistant.util.unit_conversion import (
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
PowerConverter,
|
||||
PressureConverter,
|
||||
@@ -94,6 +95,9 @@ UNIT_SCHEMA = vol.Schema(
|
||||
vol.Optional("nitrogen_dioxide"): vol.In(
|
||||
NitrogenDioxideConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("nitrogen_monoxide"): vol.In(
|
||||
NitrogenMonoxideConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("ozone"): vol.In(OzoneConcentrationConverter.VALID_UNITS),
|
||||
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
|
||||
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/screenlogic",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["screenlogicpy"],
|
||||
"requirements": ["screenlogicpy==0.10.2"]
|
||||
|
||||
@@ -64,6 +64,7 @@ from homeassistant.util.unit_conversion import (
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
PowerConverter,
|
||||
PressureConverter,
|
||||
@@ -291,7 +292,7 @@ class SensorDeviceClass(StrEnum):
|
||||
NITROGEN_MONOXIDE = "nitrogen_monoxide"
|
||||
"""Amount of NO.
|
||||
|
||||
Unit of measurement: `μg/m³`
|
||||
Unit of measurement: `ppb` (parts per billion), `μg/m³`
|
||||
"""
|
||||
|
||||
NITROUS_OXIDE = "nitrous_oxide"
|
||||
@@ -566,6 +567,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
|
||||
SensorDeviceClass.ENERGY_STORAGE: EnergyConverter,
|
||||
SensorDeviceClass.GAS: VolumeConverter,
|
||||
SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
|
||||
SensorDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter,
|
||||
SensorDeviceClass.OZONE: OzoneConcentrationConverter,
|
||||
SensorDeviceClass.POWER: PowerConverter,
|
||||
SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
|
||||
@@ -639,7 +641,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
SensorDeviceClass.NITROGEN_MONOXIDE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
SensorDeviceClass.OZONE: {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
|
||||
@@ -355,16 +355,3 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
await self._async_handle_restored_state()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update of the data."""
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if self.handle_rendered_result(CONF_STATE):
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
@@ -183,8 +184,32 @@ class AbstractTemplateBinarySensor(
|
||||
|
||||
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
|
||||
self._template: template.Template = config[CONF_STATE]
|
||||
self._delay_on = None
|
||||
self._delay_off = None
|
||||
self._delay_cancel: CALLBACK_TYPE | None = None
|
||||
|
||||
self.setup_state_template(
|
||||
CONF_STATE,
|
||||
"_attr_is_on",
|
||||
on_update=self._update_state,
|
||||
)
|
||||
self._delay_on = None
|
||||
try:
|
||||
self._delay_on = cv.positive_time_period(config.get(CONF_DELAY_ON))
|
||||
except vol.Invalid:
|
||||
self.setup_template(CONF_DELAY_ON, "_delay_on", cv.positive_time_period)
|
||||
|
||||
self._delay_off = None
|
||||
try:
|
||||
self._delay_off = cv.positive_time_period(config.get(CONF_DELAY_OFF))
|
||||
except vol.Invalid:
|
||||
self.setup_template(CONF_DELAY_OFF, "_delay_off", cv.positive_time_period)
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def _update_state(self, result: Any) -> None:
|
||||
"""Update the state."""
|
||||
|
||||
|
||||
class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
"""A virtual binary sensor that triggers from another sensor."""
|
||||
@@ -200,17 +225,15 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
"""Initialize the Template binary sensor."""
|
||||
TemplateEntity.__init__(self, hass, config, unique_id)
|
||||
AbstractTemplateBinarySensor.__init__(self, config)
|
||||
self._delay_on = None
|
||||
self._delay_on_template = config.get(CONF_DELAY_ON)
|
||||
self._delay_off = None
|
||||
self._delay_off_template = config.get(CONF_DELAY_OFF)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore state."""
|
||||
if (
|
||||
(
|
||||
self._delay_on_template is not None
|
||||
or self._delay_off_template is not None
|
||||
CONF_DELAY_ON in self._templates
|
||||
or CONF_DELAY_OFF in self._templates
|
||||
or self._delay_on is not None
|
||||
or self._delay_off is not None
|
||||
)
|
||||
and (last_state := await self.async_get_last_state()) is not None
|
||||
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
@@ -218,29 +241,6 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_setup_templates(self) -> None:
|
||||
"""Set up templates."""
|
||||
self.add_template_attribute("_state", self._template, None, self._update_state)
|
||||
|
||||
if self._delay_on_template is not None:
|
||||
try:
|
||||
self._delay_on = cv.positive_time_period(self._delay_on_template)
|
||||
except vol.Invalid:
|
||||
self.add_template_attribute(
|
||||
"_delay_on", self._delay_on_template, cv.positive_time_period
|
||||
)
|
||||
|
||||
if self._delay_off_template is not None:
|
||||
try:
|
||||
self._delay_off = cv.positive_time_period(self._delay_off_template)
|
||||
except vol.Invalid:
|
||||
self.add_template_attribute(
|
||||
"_delay_off", self._delay_off_template, cv.positive_time_period
|
||||
)
|
||||
|
||||
super()._async_setup_templates()
|
||||
|
||||
@callback
|
||||
def _update_state(self, result):
|
||||
super()._update_state(result)
|
||||
@@ -291,15 +291,11 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
TriggerEntity.__init__(self, hass, coordinator, config)
|
||||
AbstractTemplateBinarySensor.__init__(self, config)
|
||||
|
||||
for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
|
||||
if isinstance(config.get(key), template.Template):
|
||||
self._to_render_simple.append(key)
|
||||
self._parse_result.add(key)
|
||||
|
||||
self._last_delay_from: bool | None = None
|
||||
self._last_delay_to: bool | None = None
|
||||
self._auto_off_cancel: CALLBACK_TYPE | None = None
|
||||
self._auto_off_time: datetime | None = None
|
||||
self.setup_template(CONF_AUTO_OFF, "_auto_off_time", cv.positive_time_period)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
@@ -329,26 +325,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
self._set_auto_off(auto_off_time)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update of the data."""
|
||||
self._process_data()
|
||||
|
||||
raw = self._rendered.get(CONF_STATE)
|
||||
state: bool | None = None
|
||||
if raw is not None:
|
||||
state = template.result_as_boolean(raw)
|
||||
|
||||
key = CONF_DELAY_ON if state else CONF_DELAY_OFF
|
||||
delay = self._rendered.get(key) or self._config.get(key)
|
||||
|
||||
if (
|
||||
self._delay_cancel
|
||||
and delay
|
||||
and self._attr_is_on == self._last_delay_from
|
||||
and state == self._last_delay_to
|
||||
):
|
||||
return
|
||||
|
||||
def _cancel_delays(self):
|
||||
if self._delay_cancel:
|
||||
self._delay_cancel()
|
||||
self._delay_cancel = None
|
||||
@@ -358,10 +335,27 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
self._auto_off_cancel = None
|
||||
self._auto_off_time = None
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
@callback
|
||||
def _update_state(self, result):
|
||||
state: bool | None = None
|
||||
if result is not None:
|
||||
state = template.result_as_boolean(result)
|
||||
|
||||
if state:
|
||||
delay = self._rendered.get(CONF_DELAY_ON) or self._delay_on
|
||||
else:
|
||||
delay = self._rendered.get(CONF_DELAY_OFF) or self._delay_off
|
||||
|
||||
if (
|
||||
self._delay_cancel
|
||||
and delay
|
||||
and self._attr_is_on == self._last_delay_from
|
||||
and state == self._last_delay_to
|
||||
):
|
||||
return
|
||||
|
||||
self._cancel_delays()
|
||||
|
||||
# state without delay.
|
||||
if self._attr_is_on == state or delay is None:
|
||||
self._set_state(state)
|
||||
@@ -371,6 +365,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
try:
|
||||
delay = cv.positive_time_period(delay)
|
||||
except vol.Invalid as err:
|
||||
key = CONF_DELAY_ON if state else CONF_DELAY_OFF
|
||||
logging.getLogger(__name__).warning(
|
||||
"Error rendering %s template: %s", key, err
|
||||
)
|
||||
@@ -412,6 +407,14 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
auto_off_time = dt_util.utcnow() + auto_off_delay
|
||||
self._set_auto_off(auto_off_time)
|
||||
|
||||
def _render_availability_template(self, variables):
|
||||
available = super()._render_availability_template(variables)
|
||||
if not available:
|
||||
# Cancel any delay_on, delay_off, or auto_off when
|
||||
# the entity goes unavailable
|
||||
self._cancel_delays()
|
||||
return available
|
||||
|
||||
def _set_auto_off(self, auto_off_time: datetime) -> None:
|
||||
@callback
|
||||
def _auto_off(_):
|
||||
|
||||
@@ -500,7 +500,6 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -96,6 +96,30 @@ class AbstractTemplateEntity(Entity):
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
|
||||
@abstractmethod
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
|
||||
def add_template(
|
||||
self,
|
||||
option: str,
|
||||
@@ -109,7 +133,11 @@ class AbstractTemplateEntity(Entity):
|
||||
if (template := self._config.get(option)) and isinstance(template, Template):
|
||||
if add_if_static or (not template.is_static):
|
||||
self._templates[option] = EntityTemplate(
|
||||
attribute, template, validator, on_update, none_on_template_error
|
||||
attribute,
|
||||
template,
|
||||
validator,
|
||||
on_update,
|
||||
none_on_template_error,
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ class TriggerEventEntity(TriggerEntity, AbstractTemplateEvent, RestoreEntity):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
for key, updater in (
|
||||
|
||||
@@ -552,7 +552,6 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -180,3 +180,4 @@ class TriggerImageEntity(TriggerEntity, AbstractTemplateImage):
|
||||
"""Process new data."""
|
||||
super()._process_data()
|
||||
self._handle_state(self._rendered.get(CONF_URL))
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1123,7 +1123,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -377,7 +377,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -236,7 +236,6 @@ class TriggerNumberEntity(TriggerEntity, AbstractTemplateNumber):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -209,7 +209,6 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -323,3 +323,4 @@ class TriggerSensorEntity(TriggerEntity, AbstractTemplateSensor):
|
||||
|
||||
rendered = self._rendered.get(CONF_STATE)
|
||||
self._handle_state(rendered)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -281,7 +281,6 @@ class TriggerSwitchEntity(TriggerEntity, AbstractTemplateSwitch):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -303,6 +303,30 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
|
||||
self.add_template(option, attribute, on_update=_update_state)
|
||||
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
):
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
self.add_template(option, attribute, validator, on_update, True)
|
||||
|
||||
def add_template_attribute(
|
||||
self,
|
||||
attribute: str,
|
||||
|
||||
@@ -59,10 +59,33 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
if self._config.get(option):
|
||||
self._to_render_simple.append(CONF_STATE)
|
||||
self._parse_result.add(CONF_STATE)
|
||||
self.add_template(option, attribute, validator, on_update)
|
||||
if self.add_template(option, attribute, validator, on_update):
|
||||
self._to_render_simple.append(option)
|
||||
self._parse_result.add(option)
|
||||
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
self.setup_state_template(option, attribute, validator, on_update)
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
@@ -103,21 +126,35 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
self._render_attributes(rendered, variables)
|
||||
self._rendered = rendered
|
||||
|
||||
def handle_rendered_result(self, key: str) -> bool:
|
||||
def _handle_rendered_results(self) -> bool:
|
||||
"""Get a rendered result and return the value."""
|
||||
if (rendered := self._rendered.get(key)) is not None:
|
||||
if (entity_template := self._templates.get(key)) is not None:
|
||||
# Handle any templates.
|
||||
for option, entity_template in self._templates.items():
|
||||
value = _SENTINEL
|
||||
if (rendered := self._rendered.get(option)) is not None:
|
||||
value = rendered
|
||||
if entity_template.validator:
|
||||
value = entity_template.validator(rendered)
|
||||
|
||||
if entity_template.on_update:
|
||||
entity_template.on_update(value)
|
||||
else:
|
||||
setattr(self, entity_template.attribute, value)
|
||||
if entity_template.validator:
|
||||
value = entity_template.validator(rendered)
|
||||
|
||||
# Capture templates that did not render a result due to an exception and
|
||||
# ensure the state object updates. _SENTINEL is used to differentiate
|
||||
# templates that render None.
|
||||
if value is _SENTINEL:
|
||||
return True
|
||||
|
||||
if entity_template.on_update:
|
||||
entity_template.on_update(value)
|
||||
else:
|
||||
setattr(self, entity_template.attribute, value)
|
||||
return True
|
||||
|
||||
if len(self._rendered) > 0:
|
||||
# In some cases, the entity may be state optimistic or
|
||||
# attribute optimistic, in these scenarios the state needs
|
||||
# to update.
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
@@ -136,13 +173,35 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
else:
|
||||
self._rendered_entity_variables = coordinator_variables
|
||||
variables = self._template_variables(self._rendered_entity_variables)
|
||||
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
if self._render_availability_template(variables):
|
||||
self._render_templates(variables)
|
||||
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
write_state = False
|
||||
# While transitioning platforms to the new framework, this
|
||||
# if-statement is necessary for backward compatibility with existing
|
||||
# trigger based platforms.
|
||||
if self._templates:
|
||||
# Handle any results that were rendered.
|
||||
write_state = self._handle_rendered_results()
|
||||
|
||||
# Check availability after rendering the results because the state
|
||||
# template could render the entity unavailable
|
||||
if not self.available:
|
||||
write_state = True
|
||||
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
"""Handle updated data from the coordinator.
|
||||
|
||||
While transitioning platforms to the new framework, this
|
||||
function is necessary for backward compatibility with existing
|
||||
trigger based platforms.
|
||||
"""
|
||||
self._process_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -438,7 +438,6 @@ class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -489,7 +489,6 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -747,7 +747,6 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -83,6 +83,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.version == 2:
|
||||
# 2 -> 2.2: Unique ID from integer to string
|
||||
if entry.minor_version == 1:
|
||||
minor_version = 2
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(entry.unique_id), minor_version=minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
agreements: list[Agreement]
|
||||
data: dict[str, Any]
|
||||
@@ -92,7 +93,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
if self.migrate_entry:
|
||||
await self.hass.config_entries.async_remove(self.migrate_entry)
|
||||
|
||||
await self.async_set_unique_id(agreement.agreement_id)
|
||||
await self.async_set_unique_id(str(agreement.agreement_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.data[CONF_AGREEMENT_ID] = agreement.agreement_id
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["xiaomi-ble==1.5.0"]
|
||||
"requirements": ["xiaomi-ble==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ _AMBIENT_IDEAL_GAS_MOLAR_VOLUME = ( # m3⋅mol⁻¹
|
||||
# Molar masses in g⋅mol⁻¹
|
||||
_CARBON_MONOXIDE_MOLAR_MASS = 28.01
|
||||
_NITROGEN_DIOXIDE_MOLAR_MASS = 46.0055
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS = 30.0061
|
||||
_OZONE_MOLAR_MASS = 48.00
|
||||
_SULPHUR_DIOXIDE_MOLAR_MASS = 64.066
|
||||
|
||||
@@ -502,6 +503,22 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
|
||||
}
|
||||
|
||||
|
||||
class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
"""Convert nitrogen monoxide ratio to mass per volume."""
|
||||
|
||||
UNIT_CLASS = "nitrogen_monoxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
class OzoneConcentrationConverter(BaseUnitConverter):
|
||||
"""Convert ozone ratio to mass per volume."""
|
||||
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -1296,7 +1296,7 @@ influxdb==5.3.1
|
||||
inkbird-ble==1.1.1
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.0
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
@@ -3215,7 +3215,7 @@ wsdot==0.0.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.5.0
|
||||
xiaomi-ble==1.6.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -1142,7 +1142,7 @@ influxdb==5.3.1
|
||||
inkbird-ble==1.1.1
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.0
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
@@ -2691,7 +2691,7 @@ wsdot==0.0.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.5.0
|
||||
xiaomi-ble==1.6.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
|
||||
@@ -27,7 +27,10 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
def mock_config_entry(hass: HomeAssistant, mock_arve: MagicMock) -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="Arve", domain=DOMAIN, data=USER_INPUT, unique_id=mock_arve.customer_id
|
||||
title="Arve",
|
||||
domain=DOMAIN,
|
||||
data=USER_INPUT,
|
||||
unique_id=str(mock_arve.customer_id),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_correct_flow(
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["data"] == USER_INPUT
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert result2["result"].unique_id == 12345
|
||||
assert result2["result"].unique_id == "12345"
|
||||
|
||||
|
||||
async def test_form_cannot_connect(
|
||||
|
||||
26
tests/components/arve/test_init.py
Normal file
26
tests/components/arve/test_init.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Tests for the Arve component."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.arve.const import DOMAIN
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
|
||||
"""Test migrating a 1.1 config entry to 1.2."""
|
||||
with patch("homeassistant.components.arve.async_setup_entry", return_value=True):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_ACCESS_TOKEN: "mock", CONF_CLIENT_SECRET: "mock"},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
unique_id=12345,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.version == 1
|
||||
assert entry.minor_version == 2
|
||||
assert entry.unique_id == "12345"
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test Hikvision integration setup and unload."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -102,3 +104,69 @@ async def test_setup_entry_nvr_fetches_events(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_called_once()
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_event_fetch_request_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR event fetch fails with request error."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.side_effect = (
|
||||
requests.exceptions.RequestException("Connection error")
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
assert f"Unable to fetch event triggers from {TEST_HOST}" in caplog.text
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_event_fetch_parse_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR event fetch fails with parse error."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.side_effect = ParseError("Invalid XML")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
assert f"Unable to fetch event triggers from {TEST_HOST}" in caplog.text
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_no_events_returned(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR returns no events."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.return_value = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_empty_events_returned(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR returns empty events."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.return_value = {}
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
|
||||
@@ -59,7 +59,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=TITLE,
|
||||
unique_id=54321,
|
||||
unique_id="54321",
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
|
||||
@@ -74,7 +74,7 @@ async def test_full_flow(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@microbees.com"
|
||||
assert "result" in result
|
||||
assert result["result"].unique_id == 54321
|
||||
assert result["result"].unique_id == "54321"
|
||||
assert "token" in result["result"].data
|
||||
assert result["result"].data["token"]["access_token"] == "mock-access-token"
|
||||
assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token"
|
||||
@@ -197,7 +197,7 @@ async def test_config_reauth_wrong_account(
|
||||
) -> None:
|
||||
"""Test reauth with wrong account."""
|
||||
await setup_integration(hass, config_entry)
|
||||
microbees.return_value.getMyProfile.return_value.id = 12345
|
||||
microbees.return_value.getMyProfile.return_value.id = "12345"
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
35
tests/components/microbees/test_init.py
Normal file
35
tests/components/microbees/test_init.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tests for the microBees component."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.microbees.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
|
||||
"""Test migrating a 1.1 config entry to 1.2."""
|
||||
with patch(
|
||||
"homeassistant.components.microbees.async_setup_entry", return_value=True
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
unique_id=54321,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.version == 1
|
||||
assert entry.minor_version == 2
|
||||
assert entry.unique_id == "54321"
|
||||
@@ -244,7 +244,7 @@ async def test_config_reauth_wrong_account(
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
"user_id": 12346,
|
||||
"user_id": "12346",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for component initialisation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from monzopy import AuthorisationExpiredError
|
||||
@@ -35,3 +35,29 @@ async def test_api_can_trigger_reauth(
|
||||
assert flow["step_id"] == "reauth_confirm"
|
||||
assert flow["handler"] == DOMAIN
|
||||
assert flow["context"]["source"] == SOURCE_REAUTH
|
||||
|
||||
|
||||
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
|
||||
"""Test migrating a 1.1 config entry to 1.2."""
|
||||
with patch("homeassistant.components.monzo.async_setup_entry", return_value=True):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
"user_id": "600",
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
unique_id=600,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.version == 1
|
||||
assert entry.minor_version == 2
|
||||
assert entry.unique_id == "600"
|
||||
|
||||
@@ -3107,7 +3107,6 @@ def test_device_class_converters_are_complete() -> None:
|
||||
SensorDeviceClass.IRRADIANCE,
|
||||
SensorDeviceClass.MOISTURE,
|
||||
SensorDeviceClass.MONETARY,
|
||||
SensorDeviceClass.NITROGEN_MONOXIDE,
|
||||
SensorDeviceClass.NITROUS_OXIDE,
|
||||
SensorDeviceClass.PH,
|
||||
SensorDeviceClass.PM1,
|
||||
|
||||
@@ -213,7 +213,7 @@ async def test_agreement_already_set_up(
|
||||
) -> None:
|
||||
"""Test showing display form again if display already exists."""
|
||||
await setup_component(hass)
|
||||
MockConfigEntry(domain=DOMAIN, unique_id=123).add_to_hass(hass)
|
||||
MockConfigEntry(domain=DOMAIN, unique_id="123").add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
@@ -312,7 +312,7 @@ async def test_import_migration(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test if importing step with migration works."""
|
||||
old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1)
|
||||
old_entry = MockConfigEntry(domain=DOMAIN, unique_id="123", version=1)
|
||||
old_entry.add_to_hass(hass)
|
||||
|
||||
await setup_component(hass)
|
||||
|
||||
@@ -40,3 +40,29 @@ async def test_oauth_implementation_not_available(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_migrate_entry_minor_version_2_2(hass: HomeAssistant) -> None:
|
||||
"""Test migrating a 2.1 config entry to 2.2."""
|
||||
with patch("homeassistant.components.toon.async_setup_entry", return_value=True):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
"agreement_id": 123,
|
||||
},
|
||||
version=2,
|
||||
minor_version=1,
|
||||
unique_id=123,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert entry.unique_id == "123"
|
||||
|
||||
@@ -57,6 +57,7 @@ from homeassistant.util.unit_conversion import (
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
PowerConverter,
|
||||
PressureConverter,
|
||||
@@ -107,6 +108,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = {
|
||||
VolumeConverter,
|
||||
VolumeFlowRateConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
)
|
||||
}
|
||||
@@ -169,6 +171,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
),
|
||||
NitrogenMonoxideConcentrationConverter: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
),
|
||||
OzoneConcentrationConverter: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
@@ -407,6 +414,20 @@ _CONVERTED_VALUE: dict[
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
],
|
||||
NitrogenMonoxideConcentrationConverter: [
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
96.200906,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
],
|
||||
ConductivityConverter: [
|
||||
(
|
||||
5,
|
||||
|
||||
Reference in New Issue
Block a user