mirror of
https://github.com/home-assistant/core.git
synced 2025-10-09 19:59:30 +00:00
Compare commits
69 Commits
mqtt-suben
...
input-week
Author | SHA1 | Date | |
---|---|---|---|
![]() |
39d970347e | ||
![]() |
9cccc96f63 | ||
![]() |
a32ada3155 | ||
![]() |
77f078e57d | ||
![]() |
8657bfd0bf | ||
![]() |
fe4eb8766d | ||
![]() |
2d9f14c401 | ||
![]() |
7b6ccb07fd | ||
![]() |
2ba5728060 | ||
![]() |
b5f163cc85 | ||
![]() |
65540a3e0b | ||
![]() |
cbf1b39edb | ||
![]() |
142daf5e49 | ||
![]() |
8bd0ff7cca | ||
![]() |
ac676e12f6 | ||
![]() |
c0ac3292cd | ||
![]() |
80fd07c128 | ||
![]() |
3701d8859a | ||
![]() |
6dd26bae88 | ||
![]() |
1a0abe296c | ||
![]() |
de6c61a4ab | ||
![]() |
33c677596e | ||
![]() |
e9b4b8e99b | ||
![]() |
0525c04c42 | ||
![]() |
d57b502551 | ||
![]() |
9fb708baf4 | ||
![]() |
abdf24b7a0 | ||
![]() |
29bfbd27bb | ||
![]() |
224553f8d9 | ||
![]() |
7c9f6a061f | ||
![]() |
8e115d4685 | ||
![]() |
00c189844f | ||
![]() |
4587c286bb | ||
![]() |
b46097a7fc | ||
![]() |
299cb6a2ff | ||
![]() |
1b7b91b328 | ||
![]() |
01a1480ebd | ||
![]() |
26b8abb118 | ||
![]() |
53d1bbb530 | ||
![]() |
a3ef55274e | ||
![]() |
2034915457 | ||
![]() |
9e46d7964a | ||
![]() |
f9828a227b | ||
![]() |
3341fa5f33 | ||
![]() |
e38ae47e76 | ||
![]() |
934c0e3c4c | ||
![]() |
994a6ae7ed | ||
![]() |
cdbe93c289 | ||
![]() |
56f90e4d96 | ||
![]() |
34977abfec | ||
![]() |
5622103eb1 | ||
![]() |
b9a1ab4a44 | ||
![]() |
18997833c4 | ||
![]() |
f99b194afc | ||
![]() |
566a347da7 | ||
![]() |
881306f6a4 | ||
![]() |
f63504af01 | ||
![]() |
d140b82a70 | ||
![]() |
681211b1a5 | ||
![]() |
6c8b1f3618 | ||
![]() |
d341065c34 | ||
![]() |
81b1346080 | ||
![]() |
5613be3980 | ||
![]() |
fbcf0eb94c | ||
![]() |
1c7b9cc354 | ||
![]() |
75e900606e | ||
![]() |
7c665c53b5 | ||
![]() |
f72047eb02 | ||
![]() |
ade424c074 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -741,7 +741,7 @@ jobs:
|
||||
- name: Generate partial mypy restore key
|
||||
id: generate-mypy-key
|
||||
run: |
|
||||
mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3)
|
||||
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
|
||||
echo "version=$mypy_version" >> $GITHUB_OUTPUT
|
||||
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
8
CODEOWNERS
generated
8
CODEOWNERS
generated
@@ -753,6 +753,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/input_select/ @home-assistant/core
|
||||
/homeassistant/components/input_text/ @home-assistant/core
|
||||
/tests/components/input_text/ @home-assistant/core
|
||||
/homeassistant/components/input_weekday/ @home-assistant/core
|
||||
/tests/components/input_weekday/ @home-assistant/core
|
||||
/homeassistant/components/insteon/ @teharris1
|
||||
/tests/components/insteon/ @teharris1
|
||||
/homeassistant/components/integration/ @dgomes
|
||||
@@ -1065,6 +1067,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/nilu/ @hfurubotten
|
||||
/homeassistant/components/nina/ @DeerMaximum
|
||||
/tests/components/nina/ @DeerMaximum
|
||||
/homeassistant/components/nintendo_parental/ @pantherale0
|
||||
/tests/components/nintendo_parental/ @pantherale0
|
||||
/homeassistant/components/nissan_leaf/ @filcole
|
||||
/homeassistant/components/noaa_tides/ @jdelaney72
|
||||
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
|
||||
@@ -1411,8 +1415,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sfr_box/ @epenet
|
||||
/homeassistant/components/sftp_storage/ @maretodoric
|
||||
/tests/components/sftp_storage/ @maretodoric
|
||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
|
||||
/tests/components/sharkiq/ @JeffResc @funkybunch
|
||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
|
||||
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
|
||||
/homeassistant/components/shell_command/ @home-assistant/core
|
||||
/tests/components/shell_command/ @home-assistant/core
|
||||
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
|
||||
|
@@ -231,6 +231,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"input_datetime",
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_weekday",
|
||||
"input_text",
|
||||
"schedule",
|
||||
"timer",
|
||||
|
@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
|
||||
}
|
||||
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
|
||||
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
|
||||
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)
|
||||
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30)
|
||||
|
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"default": "mdi:air-filter"
|
||||
},
|
||||
"cloud_ceiling": {
|
||||
"default": "mdi:weather-fog"
|
||||
},
|
||||
@@ -34,9 +37,6 @@
|
||||
"thunderstorm_probability_night": {
|
||||
"default": "mdi:weather-lightning"
|
||||
},
|
||||
"translation_key": {
|
||||
"default": "mdi:air-filter"
|
||||
},
|
||||
"tree_pollen": {
|
||||
"default": "mdi:tree-outline"
|
||||
},
|
||||
|
@@ -1,7 +1,9 @@
|
||||
"""Airgradient Update platform."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airgradient import AirGradientConnectionError
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
|
||||
@@ -13,6 +15,7 @@ from .entity import AirGradientEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -31,6 +34,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
|
||||
"""Representation of Airgradient Update."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
_server_unreachable_logged = False
|
||||
|
||||
def __init__(self, coordinator: AirGradientCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
@@ -47,10 +51,27 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
|
||||
"""Return the installed version of the entity."""
|
||||
return self.coordinator.data.measures.firmware_version
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._attr_available
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
self._attr_latest_version = (
|
||||
await self.coordinator.client.get_latest_firmware_version(
|
||||
self.coordinator.serial_number
|
||||
try:
|
||||
self._attr_latest_version = (
|
||||
await self.coordinator.client.get_latest_firmware_version(
|
||||
self.coordinator.serial_number
|
||||
)
|
||||
)
|
||||
)
|
||||
except AirGradientConnectionError:
|
||||
self._attr_latest_version = None
|
||||
self._attr_available = False
|
||||
if not self._server_unreachable_logged:
|
||||
_LOGGER.error(
|
||||
"Unable to connect to AirGradient server to check for updates"
|
||||
)
|
||||
self._server_unreachable_logged = True
|
||||
else:
|
||||
self._server_unreachable_logged = False
|
||||
self._attr_available = True
|
||||
|
@@ -16,10 +16,12 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
UnitOfPressure,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -112,6 +114,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"lux": SensorEntityDescription(
|
||||
key="lux",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"noise": SensorEntityDescription(
|
||||
key="noise",
|
||||
translation_key="ambient_noise",
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
@@ -41,6 +41,9 @@
|
||||
},
|
||||
"illuminance": {
|
||||
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
|
||||
},
|
||||
"ambient_noise": {
|
||||
"name": "Ambient noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -29,23 +30,19 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
|
@@ -7,14 +7,21 @@ from typing import Any, cast
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.components.cover import (
|
||||
STATE_CLOSED,
|
||||
STATE_CLOSING,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -29,21 +36,19 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[COVER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, COVER)
|
||||
)
|
||||
|
||||
|
||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@@ -62,7 +67,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
super().__init__(coordinator, device, config_entry_entry_id)
|
||||
# Device doesn't provide a status so we assume UNKNOWN at first startup
|
||||
self._last_action: int | None = None
|
||||
self._last_state: str | None = None
|
||||
|
||||
def _current_action(self, action: str) -> bool:
|
||||
"""Return the current cover action."""
|
||||
@@ -98,7 +102,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@bridge_api_call
|
||||
async def _cover_set_state(self, action: int, state: int) -> None:
|
||||
"""Set desired cover state."""
|
||||
self._last_state = self.state
|
||||
await self.coordinator.api.set_device_status(COVER, self._device.index, action)
|
||||
self.coordinator.data[COVER][self._device.index].status = state
|
||||
self.async_write_ha_state()
|
||||
@@ -124,5 +127,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if last_state := await self.async_get_last_state():
|
||||
self._last_state = last_state.state
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
if state.state == STATE_CLOSED:
|
||||
self._last_action = STATE_COVER.index(STATE_CLOSING)
|
||||
if state.state == STATE_OPEN:
|
||||
self._last_action = STATE_COVER.index(STATE_OPENING)
|
||||
|
||||
self._attr_is_closed = state.state == STATE_CLOSED
|
||||
|
@@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -27,21 +27,19 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[LIGHT])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, LIGHT)
|
||||
)
|
||||
|
||||
|
||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
|
@@ -20,6 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -65,24 +66,22 @@ async def async_setup_bridge_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[OTHER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[OTHER].values()
|
||||
if device.index in new_devices
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, OTHER)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
@@ -94,24 +93,22 @@ async def async_setup_vedo_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -28,35 +28,20 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
entities: list[ComelitSwitchEntity] = []
|
||||
entities.extend(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[IRRIGATION].values()
|
||||
)
|
||||
entities.extend(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[OTHER].values()
|
||||
)
|
||||
async_add_entities(entities)
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
known_devices: dict[str, set[int]] = {
|
||||
dev_type: set() for dev_type in (IRRIGATION, OTHER)
|
||||
}
|
||||
|
||||
def _check_device() -> None:
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
current_devices = set(coordinator.data[dev_type])
|
||||
new_devices = current_devices - known_devices[dev_type]
|
||||
if new_devices:
|
||||
known_devices[dev_type].update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, dev_type)
|
||||
)
|
||||
|
||||
|
||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
|
@@ -4,7 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.api import (
|
||||
ComelitSerialBridgeObject,
|
||||
ComelitVedoAreaObject,
|
||||
ComelitVedoZoneObject,
|
||||
)
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
@@ -19,8 +23,11 @@ from homeassistant.helpers import (
|
||||
)
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import ComelitBaseCoordinator
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
|
||||
DeviceType = ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject
|
||||
|
||||
|
||||
async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Return a new aiohttp session."""
|
||||
@@ -113,3 +120,41 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
|
||||
self.coordinator.config_entry.async_start_reauth(self.hass)
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
|
||||
def new_device_listener(
|
||||
coordinator: ComelitBaseCoordinator,
|
||||
new_devices_callback: Callable[
|
||||
[
|
||||
list[
|
||||
ComelitSerialBridgeObject
|
||||
| ComelitVedoAreaObject
|
||||
| ComelitVedoZoneObject
|
||||
],
|
||||
str,
|
||||
],
|
||||
None,
|
||||
],
|
||||
data_type: str,
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to coordinator updates to check for new devices."""
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_devices() -> None:
|
||||
"""Check for new devices and call callback with any new monitors."""
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
new_devices: list[DeviceType] = []
|
||||
for _id in coordinator.data[data_type]:
|
||||
if _id not in known_devices:
|
||||
known_devices.add(_id)
|
||||
new_devices.append(coordinator.data[data_type][_id])
|
||||
|
||||
if new_devices:
|
||||
new_devices_callback(new_devices, data_type)
|
||||
|
||||
# Check for devices immediately
|
||||
_check_devices()
|
||||
|
||||
return coordinator.async_add_listener(_check_devices)
|
||||
|
@@ -38,22 +38,30 @@ from home_assistant_intents import (
|
||||
ErrorKey,
|
||||
FuzzyConfig,
|
||||
FuzzyLanguageResponses,
|
||||
LanguageScores,
|
||||
get_fuzzy_config,
|
||||
get_fuzzy_language,
|
||||
get_intents,
|
||||
get_language_scores,
|
||||
get_languages,
|
||||
)
|
||||
import yaml
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
@@ -192,7 +200,7 @@ class IntentCache:
|
||||
|
||||
|
||||
async def async_setup_default_agent(
|
||||
hass: core.HomeAssistant,
|
||||
hass: HomeAssistant,
|
||||
entity_component: EntityComponent[ConversationEntity],
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
@@ -201,15 +209,13 @@ async def async_setup_default_agent(
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@core.callback
|
||||
def async_entity_state_listener(
|
||||
event: core.Event[core.EventStateChangedData],
|
||||
) -> None:
|
||||
@callback
|
||||
def async_entity_state_listener(event: Event[EventStateChangedData]) -> None:
|
||||
"""Set expose flag on new entities."""
|
||||
async_should_expose(hass, DOMAIN, event.data["entity_id"])
|
||||
|
||||
@core.callback
|
||||
def async_hass_started(hass: core.HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_hass_started(hass: HomeAssistant) -> None:
|
||||
"""Set expose flag on all entities."""
|
||||
for state in hass.states.async_all():
|
||||
async_should_expose(hass, DOMAIN, state.entity_id)
|
||||
@@ -224,9 +230,7 @@ class DefaultAgent(ConversationEntity):
|
||||
_attr_name = "Home Assistant"
|
||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||
|
||||
def __init__(
|
||||
self, hass: core.HomeAssistant, config_intents: dict[str, Any]
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
@@ -259,7 +263,7 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Return a list of supported languages."""
|
||||
return get_languages()
|
||||
|
||||
@core.callback
|
||||
@callback
|
||||
def _filter_entity_registry_changes(
|
||||
self, event_data: er.EventEntityRegistryUpdatedData
|
||||
) -> bool:
|
||||
@@ -268,12 +272,12 @@ class DefaultAgent(ConversationEntity):
|
||||
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
|
||||
)
|
||||
|
||||
@core.callback
|
||||
def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
|
||||
@callback
|
||||
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
|
||||
"""Filter state changed events."""
|
||||
return not event_data["old_state"] or not event_data["new_state"]
|
||||
|
||||
@core.callback
|
||||
@callback
|
||||
def _listen_clear_slot_list(self) -> None:
|
||||
"""Listen for changes that can invalidate slot list."""
|
||||
assert self._unsub_clear_slot_list is None
|
||||
@@ -342,6 +346,81 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return result
|
||||
|
||||
async def async_debug_recognize(
|
||||
self, user_input: ConversationInput
|
||||
) -> dict[str, Any] | None:
|
||||
"""Debug recognize from user input."""
|
||||
result_dict: dict[str, Any] | None = None
|
||||
|
||||
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
|
||||
result_dict = {
|
||||
# Matched a user-defined sentence trigger.
|
||||
# We can't provide the response here without executing the
|
||||
# trigger.
|
||||
"match": True,
|
||||
"source": "trigger",
|
||||
"sentence_template": trigger_result.sentence_template or "",
|
||||
}
|
||||
elif intent_result := await self.async_recognize_intent(user_input):
|
||||
successful_match = not intent_result.unmatched_entities
|
||||
result_dict = {
|
||||
# Name of the matching intent (or the closest)
|
||||
"intent": {
|
||||
"name": intent_result.intent.name,
|
||||
},
|
||||
# Slot values that would be received by the intent
|
||||
"slots": { # direct access to values
|
||||
entity_key: entity.text or entity.value
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Extra slot details, such as the originally matched text
|
||||
"details": {
|
||||
entity_key: {
|
||||
"name": entity.name,
|
||||
"value": entity.value,
|
||||
"text": entity.text,
|
||||
}
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Entities/areas/etc. that would be targeted
|
||||
"targets": {},
|
||||
# True if match was successful
|
||||
"match": successful_match,
|
||||
# Text of the sentence template that matched (or was closest)
|
||||
"sentence_template": "",
|
||||
# When match is incomplete, this will contain the best slot guesses
|
||||
"unmatched_slots": _get_unmatched_slots(intent_result),
|
||||
# True if match was not exact
|
||||
"fuzzy_match": False,
|
||||
}
|
||||
|
||||
if successful_match:
|
||||
result_dict["targets"] = {
|
||||
state.entity_id: {"matched": is_matched}
|
||||
for state, is_matched in _get_debug_targets(
|
||||
self.hass, intent_result
|
||||
)
|
||||
}
|
||||
|
||||
if intent_result.intent_sentence is not None:
|
||||
result_dict["sentence_template"] = intent_result.intent_sentence.text
|
||||
|
||||
if intent_result.intent_metadata:
|
||||
# Inspect metadata to determine if this matched a custom sentence
|
||||
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
|
||||
result_dict["source"] = "custom"
|
||||
result_dict["file"] = intent_result.intent_metadata.get(
|
||||
METADATA_CUSTOM_FILE
|
||||
)
|
||||
else:
|
||||
result_dict["source"] = "builtin"
|
||||
|
||||
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
|
||||
METADATA_FUZZY_MATCH, False
|
||||
)
|
||||
|
||||
return result_dict
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: ConversationInput,
|
||||
@@ -890,7 +969,7 @@ class DefaultAgent(ConversationEntity):
|
||||
) -> str:
|
||||
# Get first matched or unmatched state.
|
||||
# This is available in the response template as "state".
|
||||
state1: core.State | None = None
|
||||
state1: State | None = None
|
||||
if intent_response.matched_states:
|
||||
state1 = intent_response.matched_states[0]
|
||||
elif intent_response.unmatched_states:
|
||||
@@ -1528,6 +1607,10 @@ class DefaultAgent(ConversationEntity):
|
||||
return None
|
||||
return response
|
||||
|
||||
async def async_get_language_scores(self) -> dict[str, LanguageScores]:
|
||||
"""Get support scores per language."""
|
||||
return await self.hass.async_add_executor_job(get_language_scores)
|
||||
|
||||
|
||||
def _make_error_result(
|
||||
language: str,
|
||||
@@ -1589,7 +1672,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
|
||||
|
||||
|
||||
def _get_match_error_response(
|
||||
hass: core.HomeAssistant,
|
||||
hass: HomeAssistant,
|
||||
match_error: intent.MatchFailedError,
|
||||
) -> tuple[ErrorKey, dict[str, Any]]:
|
||||
"""Return key and template arguments for error when target matching fails."""
|
||||
@@ -1724,3 +1807,75 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No
|
||||
elif isinstance(expression, ListReference):
|
||||
# {list}
|
||||
list_names.add(expression.slot_name)
|
||||
|
||||
|
||||
def _get_debug_targets(
|
||||
hass: HomeAssistant,
|
||||
result: RecognizeResult,
|
||||
) -> Iterable[tuple[State, bool]]:
|
||||
"""Yield state/is_matched pairs for a hassil recognition."""
|
||||
entities = result.entities
|
||||
|
||||
name: str | None = None
|
||||
area_name: str | None = None
|
||||
domains: set[str] | None = None
|
||||
device_classes: set[str] | None = None
|
||||
state_names: set[str] | None = None
|
||||
|
||||
if "name" in entities:
|
||||
name = str(entities["name"].value)
|
||||
|
||||
if "area" in entities:
|
||||
area_name = str(entities["area"].value)
|
||||
|
||||
if "domain" in entities:
|
||||
domains = set(cv.ensure_list(entities["domain"].value))
|
||||
|
||||
if "device_class" in entities:
|
||||
device_classes = set(cv.ensure_list(entities["device_class"].value))
|
||||
|
||||
if "state" in entities:
|
||||
# HassGetState only
|
||||
state_names = set(cv.ensure_list(entities["state"].value))
|
||||
|
||||
if (
|
||||
(name is None)
|
||||
and (area_name is None)
|
||||
and (not domains)
|
||||
and (not device_classes)
|
||||
and (not state_names)
|
||||
):
|
||||
# Avoid "matching" all entities when there is no filter
|
||||
return
|
||||
|
||||
states = intent.async_match_states(
|
||||
hass,
|
||||
name=name,
|
||||
area_name=area_name,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
# For queries, a target is "matched" based on its state
|
||||
is_matched = (state_names is None) or (state.state in state_names)
|
||||
yield state, is_matched
|
||||
|
||||
|
||||
def _get_unmatched_slots(
|
||||
result: RecognizeResult,
|
||||
) -> dict[str, str | int | float]:
|
||||
"""Return a dict of unmatched text/range slot entities."""
|
||||
unmatched_slots: dict[str, str | int | float] = {}
|
||||
for entity in result.unmatched_entities_list:
|
||||
if isinstance(entity, UnmatchedTextEntity):
|
||||
if entity.text == MISSING_ENTITY:
|
||||
# Don't report <missing> since these are just missing context
|
||||
# slots.
|
||||
continue
|
||||
|
||||
unmatched_slots[entity.name] = entity.text
|
||||
elif isinstance(entity, UnmatchedRangeEntity):
|
||||
unmatched_slots[entity.name] = entity.value
|
||||
|
||||
return unmatched_slots
|
||||
|
@@ -2,21 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from hassil.recognize import MISSING_ENTITY, RecognizeResult
|
||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
|
||||
from home_assistant_intents import get_language_scores
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
@@ -26,11 +21,6 @@ from .agent_manager import (
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT
|
||||
from .default_agent import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
METADATA_FUZZY_MATCH,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
|
||||
@@ -206,150 +196,12 @@ async def websocket_hass_agent_debug(
|
||||
language=msg.get("language", hass.config.language),
|
||||
agent_id=agent.entity_id,
|
||||
)
|
||||
result_dict: dict[str, Any] | None = None
|
||||
|
||||
if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
|
||||
result_dict = {
|
||||
# Matched a user-defined sentence trigger.
|
||||
# We can't provide the response here without executing the
|
||||
# trigger.
|
||||
"match": True,
|
||||
"source": "trigger",
|
||||
"sentence_template": trigger_result.sentence_template or "",
|
||||
}
|
||||
elif intent_result := await agent.async_recognize_intent(user_input):
|
||||
successful_match = not intent_result.unmatched_entities
|
||||
result_dict = {
|
||||
# Name of the matching intent (or the closest)
|
||||
"intent": {
|
||||
"name": intent_result.intent.name,
|
||||
},
|
||||
# Slot values that would be received by the intent
|
||||
"slots": { # direct access to values
|
||||
entity_key: entity.text or entity.value
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Extra slot details, such as the originally matched text
|
||||
"details": {
|
||||
entity_key: {
|
||||
"name": entity.name,
|
||||
"value": entity.value,
|
||||
"text": entity.text,
|
||||
}
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Entities/areas/etc. that would be targeted
|
||||
"targets": {},
|
||||
# True if match was successful
|
||||
"match": successful_match,
|
||||
# Text of the sentence template that matched (or was closest)
|
||||
"sentence_template": "",
|
||||
# When match is incomplete, this will contain the best slot guesses
|
||||
"unmatched_slots": _get_unmatched_slots(intent_result),
|
||||
# True if match was not exact
|
||||
"fuzzy_match": False,
|
||||
}
|
||||
|
||||
if successful_match:
|
||||
result_dict["targets"] = {
|
||||
state.entity_id: {"matched": is_matched}
|
||||
for state, is_matched in _get_debug_targets(hass, intent_result)
|
||||
}
|
||||
|
||||
if intent_result.intent_sentence is not None:
|
||||
result_dict["sentence_template"] = intent_result.intent_sentence.text
|
||||
|
||||
if intent_result.intent_metadata:
|
||||
# Inspect metadata to determine if this matched a custom sentence
|
||||
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
|
||||
result_dict["source"] = "custom"
|
||||
result_dict["file"] = intent_result.intent_metadata.get(
|
||||
METADATA_CUSTOM_FILE
|
||||
)
|
||||
else:
|
||||
result_dict["source"] = "builtin"
|
||||
|
||||
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
|
||||
METADATA_FUZZY_MATCH, False
|
||||
)
|
||||
|
||||
result_dict = await agent.async_debug_recognize(user_input)
|
||||
result_dicts.append(result_dict)
|
||||
|
||||
connection.send_result(msg["id"], {"results": result_dicts})
|
||||
|
||||
|
||||
def _get_debug_targets(
|
||||
hass: HomeAssistant,
|
||||
result: RecognizeResult,
|
||||
) -> Iterable[tuple[State, bool]]:
|
||||
"""Yield state/is_matched pairs for a hassil recognition."""
|
||||
entities = result.entities
|
||||
|
||||
name: str | None = None
|
||||
area_name: str | None = None
|
||||
domains: set[str] | None = None
|
||||
device_classes: set[str] | None = None
|
||||
state_names: set[str] | None = None
|
||||
|
||||
if "name" in entities:
|
||||
name = str(entities["name"].value)
|
||||
|
||||
if "area" in entities:
|
||||
area_name = str(entities["area"].value)
|
||||
|
||||
if "domain" in entities:
|
||||
domains = set(cv.ensure_list(entities["domain"].value))
|
||||
|
||||
if "device_class" in entities:
|
||||
device_classes = set(cv.ensure_list(entities["device_class"].value))
|
||||
|
||||
if "state" in entities:
|
||||
# HassGetState only
|
||||
state_names = set(cv.ensure_list(entities["state"].value))
|
||||
|
||||
if (
|
||||
(name is None)
|
||||
and (area_name is None)
|
||||
and (not domains)
|
||||
and (not device_classes)
|
||||
and (not state_names)
|
||||
):
|
||||
# Avoid "matching" all entities when there is no filter
|
||||
return
|
||||
|
||||
states = intent.async_match_states(
|
||||
hass,
|
||||
name=name,
|
||||
area_name=area_name,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
# For queries, a target is "matched" based on its state
|
||||
is_matched = (state_names is None) or (state.state in state_names)
|
||||
yield state, is_matched
|
||||
|
||||
|
||||
def _get_unmatched_slots(
|
||||
result: RecognizeResult,
|
||||
) -> dict[str, str | int | float]:
|
||||
"""Return a dict of unmatched text/range slot entities."""
|
||||
unmatched_slots: dict[str, str | int | float] = {}
|
||||
for entity in result.unmatched_entities_list:
|
||||
if isinstance(entity, UnmatchedTextEntity):
|
||||
if entity.text == MISSING_ENTITY:
|
||||
# Don't report <missing> since these are just missing context
|
||||
# slots.
|
||||
continue
|
||||
|
||||
unmatched_slots[entity.name] = entity.text
|
||||
elif isinstance(entity, UnmatchedRangeEntity):
|
||||
unmatched_slots[entity.name] = entity.value
|
||||
|
||||
return unmatched_slots
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
|
||||
@@ -364,10 +216,13 @@ async def websocket_hass_agent_language_scores(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get support scores per language."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
language = msg.get("language", hass.config.language)
|
||||
country = msg.get("country", hass.config.country)
|
||||
|
||||
scores = await hass.async_add_executor_job(get_language_scores)
|
||||
scores = await agent.async_get_language_scores()
|
||||
matching_langs = language_util.matches(language, scores.keys(), country=country)
|
||||
preferred_lang = matching_langs[0] if matching_langs else language
|
||||
result = {
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env-canada==0.11.2"]
|
||||
"requirements": ["env-canada==0.11.3"]
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.12.0",
|
||||
"aioesphomeapi==41.13.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.81", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.82", "babel==2.15.0"]
|
||||
}
|
||||
|
@@ -72,15 +72,21 @@ _TIME_TRIGGER_SCHEMA = vol.Any(
|
||||
),
|
||||
)
|
||||
|
||||
_WEEKDAY_SCHEMA = vol.Any(
|
||||
vol.In(WEEKDAYS),
|
||||
vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]),
|
||||
cv.entity_domain(["input_weekday"]),
|
||||
msg=(
|
||||
"Expected a weekday (mon, tue, wed, thu, fri, sat, sun), "
|
||||
"a list of weekdays, or an Entity ID with domain 'input_weekday'"
|
||||
),
|
||||
)
|
||||
|
||||
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): "time",
|
||||
vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]),
|
||||
vol.Optional(CONF_WEEKDAY): vol.Any(
|
||||
vol.In(WEEKDAYS),
|
||||
vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]),
|
||||
),
|
||||
vol.Optional(CONF_WEEKDAY): _WEEKDAY_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -117,7 +123,14 @@ async def async_attach_trigger( # noqa: C901
|
||||
|
||||
# Check if current weekday matches the configuration
|
||||
if isinstance(weekday_config, str):
|
||||
if current_weekday != weekday_config:
|
||||
# Could be a single weekday string or an entity_id
|
||||
if weekday_config.startswith("input_weekday."):
|
||||
if (weekday_state := hass.states.get(weekday_config)) is None:
|
||||
return
|
||||
entity_weekdays = weekday_state.attributes.get("weekdays", [])
|
||||
if current_weekday not in entity_weekdays:
|
||||
return
|
||||
elif current_weekday != weekday_config:
|
||||
return
|
||||
elif current_weekday not in weekday_config:
|
||||
return
|
||||
|
@@ -456,7 +456,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
|
||||
return self._available
|
||||
|
||||
@ha_callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event."""
|
||||
if state := self.hass.states.get(self.entity_id):
|
||||
@@ -725,7 +725,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
|
||||
self._entry_title = entry_title
|
||||
self.iid_storage = iid_storage
|
||||
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def pair(
|
||||
self, client_username_bytes: bytes, client_public: str, client_permissions: int
|
||||
) -> bool:
|
||||
@@ -735,7 +735,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
|
||||
async_dismiss_setup_message(self.hass, self.entry_id)
|
||||
return cast(bool, success)
|
||||
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def unpair(self, client_uuid: UUID) -> None:
|
||||
"""Override super function to show setup message if unpaired."""
|
||||
super().unpair(client_uuid)
|
||||
|
@@ -71,7 +71,7 @@ class HomeDoorbellAccessory(HomeAccessory):
|
||||
self.async_update_doorbell_state(None, state)
|
||||
|
||||
@ha_callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def run(self) -> None:
|
||||
"""Handle doorbell event."""
|
||||
if self._char_doorbell_detected:
|
||||
|
@@ -219,7 +219,7 @@ class AirPurifier(Fan):
|
||||
return preset_mode.lower() != "auto"
|
||||
|
||||
@callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event.
|
||||
|
||||
|
@@ -229,7 +229,7 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
)
|
||||
self._async_update_motion_state(None, state)
|
||||
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
@callback
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event.
|
||||
|
@@ -127,7 +127,7 @@ class GarageDoorOpener(HomeAccessory):
|
||||
self.async_update_state(state)
|
||||
|
||||
@callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event.
|
||||
|
||||
|
@@ -178,7 +178,7 @@ class HumidifierDehumidifier(HomeAccessory):
|
||||
self._async_update_current_humidity(humidity_state)
|
||||
|
||||
@callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event.
|
||||
|
||||
|
@@ -108,7 +108,7 @@ class DeviceTriggerAccessory(HomeAccessory):
|
||||
_LOGGER.log,
|
||||
)
|
||||
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
@pyhap_callback # type: ignore[untyped-decorator]
|
||||
@callback
|
||||
def run(self) -> None:
|
||||
"""Run the accessory."""
|
||||
|
@@ -41,16 +41,12 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
|
||||
self._expected_connected = False
|
||||
self._height: int | None = None
|
||||
|
||||
@callback
|
||||
def async_update_data() -> None:
|
||||
self.async_set_updated_data(self._height)
|
||||
|
||||
self._debouncer = Debouncer(
|
||||
hass=self.hass,
|
||||
logger=_LOGGER,
|
||||
cooldown=UPDATE_DEBOUNCE_TIME,
|
||||
immediate=True,
|
||||
function=async_update_data,
|
||||
function=callback(lambda: self.async_set_updated_data(self._height)),
|
||||
)
|
||||
|
||||
async def async_connect(self) -> bool:
|
||||
|
285
homeassistant/components/input_weekday/__init__.py
Normal file
285
homeassistant/components/input_weekday/__init__.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Support to select weekdays for use in automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Self
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_EDITABLE,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
SERVICE_RELOAD,
|
||||
WEEKDAYS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import collection, config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
import homeassistant.helpers.service
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType, VolDictType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "input_weekday"
|
||||
|
||||
CONF_WEEKDAYS = "weekdays"
|
||||
|
||||
ATTR_WEEKDAYS = "weekdays"
|
||||
ATTR_WEEKDAY = "weekday"
|
||||
|
||||
SERVICE_SET_WEEKDAYS = "set_weekdays"
|
||||
SERVICE_ADD_WEEKDAY = "add_weekday"
|
||||
SERVICE_REMOVE_WEEKDAY = "remove_weekday"
|
||||
SERVICE_TOGGLE_WEEKDAY = "toggle_weekday"
|
||||
SERVICE_CLEAR = "clear"
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
STORAGE_FIELDS: VolDictType = {
|
||||
vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
|
||||
vol.Optional(CONF_WEEKDAYS, default=list): vol.All(
|
||||
cv.ensure_list, [vol.In(WEEKDAYS)]
|
||||
),
|
||||
vol.Optional(CONF_ICON): cv.icon,
|
||||
}
|
||||
|
||||
|
||||
def _cv_input_weekday(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Configure validation helper for input weekday (voluptuous)."""
|
||||
if CONF_WEEKDAYS in cfg:
|
||||
weekdays = cfg[CONF_WEEKDAYS]
|
||||
# Remove duplicates while preserving order
|
||||
cfg[CONF_WEEKDAYS] = list(dict.fromkeys(weekdays))
|
||||
return cfg
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: cv.schema_with_slug_keys(
|
||||
vol.All(
|
||||
{
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_WEEKDAYS): vol.All(
|
||||
cv.ensure_list, [vol.In(WEEKDAYS)]
|
||||
),
|
||||
vol.Optional(CONF_ICON): cv.icon,
|
||||
},
|
||||
_cv_input_weekday,
|
||||
)
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
RELOAD_SERVICE_SCHEMA = vol.Schema({})
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up an input weekday."""
|
||||
component = EntityComponent[InputWeekday](_LOGGER, DOMAIN, hass)
|
||||
|
||||
id_manager = collection.IDManager()
|
||||
|
||||
yaml_collection = collection.YamlCollection(
|
||||
logging.getLogger(f"{__name__}.yaml_collection"), id_manager
|
||||
)
|
||||
collection.sync_entity_lifecycle(
|
||||
hass, DOMAIN, DOMAIN, component, yaml_collection, InputWeekday
|
||||
)
|
||||
|
||||
storage_collection = InputWeekdayStorageCollection(
|
||||
Store(hass, STORAGE_VERSION, STORAGE_KEY),
|
||||
id_manager,
|
||||
)
|
||||
collection.sync_entity_lifecycle(
|
||||
hass, DOMAIN, DOMAIN, component, storage_collection, InputWeekday
|
||||
)
|
||||
|
||||
await yaml_collection.async_load(
|
||||
[{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()]
|
||||
)
|
||||
await storage_collection.async_load()
|
||||
|
||||
collection.DictStorageCollectionWebsocket(
|
||||
storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS
|
||||
).async_setup(hass)
|
||||
|
||||
async def reload_service_handler(service_call: ServiceCall) -> None:
|
||||
"""Reload yaml entities."""
|
||||
conf = await component.async_prepare_reload(skip_reset=True)
|
||||
if conf is None:
|
||||
conf = {DOMAIN: {}}
|
||||
await yaml_collection.async_load(
|
||||
[{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()]
|
||||
)
|
||||
|
||||
homeassistant.helpers.service.async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RELOAD,
|
||||
reload_service_handler,
|
||||
schema=RELOAD_SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_SET_WEEKDAYS,
|
||||
{vol.Required(ATTR_WEEKDAYS): vol.All(cv.ensure_list, [vol.In(WEEKDAYS)])},
|
||||
"async_set_weekdays",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ADD_WEEKDAY,
|
||||
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
|
||||
"async_add_weekday",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_REMOVE_WEEKDAY,
|
||||
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
|
||||
"async_remove_weekday",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_TOGGLE_WEEKDAY,
|
||||
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
|
||||
"async_toggle_weekday",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_CLEAR,
|
||||
None,
|
||||
"async_clear",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class InputWeekdayStorageCollection(collection.DictStorageCollection):
|
||||
"""Input weekday storage based collection."""
|
||||
|
||||
CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_weekday))
|
||||
|
||||
async def _process_create_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the config is valid."""
|
||||
return self.CREATE_UPDATE_SCHEMA(data)
|
||||
|
||||
@callback
|
||||
def _get_suggested_id(self, info: dict[str, Any]) -> str:
|
||||
"""Suggest an ID based on the config."""
|
||||
return info[CONF_NAME]
|
||||
|
||||
async def _update_data(
|
||||
self, item: dict[str, Any], update_data: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Return a new updated data object."""
|
||||
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
|
||||
return item | update_data
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class InputWeekday(collection.CollectionEntity, RestoreEntity):
|
||||
"""Representation of a weekday input."""
|
||||
|
||||
_unrecorded_attributes = frozenset({ATTR_EDITABLE})
|
||||
|
||||
_attr_should_poll = False
|
||||
editable: bool
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize a weekday input."""
|
||||
self._config = config
|
||||
self._attr_weekdays = config.get(CONF_WEEKDAYS, [])
|
||||
self._attr_unique_id = config[CONF_ID]
|
||||
|
||||
@classmethod
|
||||
def from_storage(cls, config: ConfigType) -> Self:
|
||||
"""Return entity instance initialized from storage."""
|
||||
input_weekday = cls(config)
|
||||
input_weekday.editable = True
|
||||
return input_weekday
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, config: ConfigType) -> Self:
|
||||
"""Return entity instance initialized from yaml."""
|
||||
input_weekday = cls(config)
|
||||
input_weekday.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
|
||||
input_weekday.editable = False
|
||||
return input_weekday
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return name of the weekday input."""
|
||||
return self._config.get(CONF_NAME) or self._config[CONF_ID]
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Return the icon to be used for this entity."""
|
||||
return self._config.get(CONF_ICON)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the state of the entity."""
|
||||
# Return a comma-separated string of selected weekdays
|
||||
return ",".join(self._attr_weekdays) if self._attr_weekdays else ""
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the entity."""
|
||||
return {
|
||||
ATTR_WEEKDAYS: self._attr_weekdays,
|
||||
ATTR_EDITABLE: self.editable,
|
||||
}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Restore previous state if no initial weekdays were provided
|
||||
if self._config.get(CONF_WEEKDAYS) is not None:
|
||||
return
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and ATTR_WEEKDAYS in state.attributes:
|
||||
self._attr_weekdays = state.attributes[ATTR_WEEKDAYS]
|
||||
|
||||
async def async_set_weekdays(self, weekdays: list[str]) -> None:
|
||||
"""Set the selected weekdays."""
|
||||
# Remove duplicates while preserving order
|
||||
self._attr_weekdays = list(dict.fromkeys(weekdays))
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_add_weekday(self, weekday: str) -> None:
|
||||
"""Add a weekday to the selection."""
|
||||
if weekday not in self._attr_weekdays:
|
||||
self._attr_weekdays.append(weekday)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_remove_weekday(self, weekday: str) -> None:
|
||||
"""Remove a weekday from the selection."""
|
||||
if weekday in self._attr_weekdays:
|
||||
self._attr_weekdays.remove(weekday)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_toggle_weekday(self, weekday: str) -> None:
|
||||
"""Toggle a weekday in the selection."""
|
||||
if weekday in self._attr_weekdays:
|
||||
self._attr_weekdays.remove(weekday)
|
||||
else:
|
||||
self._attr_weekdays.append(weekday)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_clear(self) -> None:
|
||||
"""Clear all selected weekdays."""
|
||||
self._attr_weekdays = []
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update_config(self, config: ConfigType) -> None:
|
||||
"""Handle when the config is updated."""
|
||||
self._config = config
|
||||
self._attr_weekdays = config.get(CONF_WEEKDAYS, [])
|
||||
self.async_write_ha_state()
|
29
homeassistant/components/input_weekday/icons.json
Normal file
29
homeassistant/components/input_weekday/icons.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"entity": {
|
||||
"input_weekday": {
|
||||
"default": {
|
||||
"default": "mdi:calendar-week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_weekdays": {
|
||||
"service": "mdi:calendar-edit"
|
||||
},
|
||||
"add_weekday": {
|
||||
"service": "mdi:calendar-plus"
|
||||
},
|
||||
"remove_weekday": {
|
||||
"service": "mdi:calendar-minus"
|
||||
},
|
||||
"toggle_weekday": {
|
||||
"service": "mdi:calendar-check"
|
||||
},
|
||||
"clear": {
|
||||
"service": "mdi:calendar-remove"
|
||||
},
|
||||
"reload": {
|
||||
"service": "mdi:reload"
|
||||
}
|
||||
}
|
||||
}
|
8
homeassistant/components/input_weekday/manifest.json
Normal file
8
homeassistant/components/input_weekday/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "input_weekday",
|
||||
"name": "Input Weekday",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/input_weekday",
|
||||
"integration_type": "helper",
|
||||
"quality_scale": "internal"
|
||||
}
|
42
homeassistant/components/input_weekday/reproduce_state.py
Normal file
42
homeassistant/components/input_weekday/reproduce_state.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Reproduce an Input Weekday state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
|
||||
from . import ATTR_WEEKDAYS, DOMAIN, SERVICE_SET_WEEKDAYS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_reproduce_states(
|
||||
hass: HomeAssistant,
|
||||
states: list[State],
|
||||
*,
|
||||
context: Context | None = None,
|
||||
reproduce_options: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Reproduce Input Weekday states."""
|
||||
for state in states:
|
||||
if ATTR_WEEKDAYS not in state.attributes:
|
||||
_LOGGER.warning(
|
||||
"Unable to reproduce state for %s: %s attribute is missing",
|
||||
state.entity_id,
|
||||
ATTR_WEEKDAYS,
|
||||
)
|
||||
continue
|
||||
|
||||
weekdays = state.attributes[ATTR_WEEKDAYS]
|
||||
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: state.entity_id,
|
||||
ATTR_WEEKDAYS: weekdays,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_SET_WEEKDAYS, service_data, context=context, blocking=True
|
||||
)
|
115
homeassistant/components/input_weekday/services.yaml
Normal file
115
homeassistant/components/input_weekday/services.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
set_weekdays:
|
||||
target:
|
||||
entity:
|
||||
domain: input_weekday
|
||||
fields:
|
||||
weekdays:
|
||||
required: true
|
||||
example: '["mon", "wed", "fri"]'
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
mode: list
|
||||
options:
|
||||
- value: mon
|
||||
label: Monday
|
||||
- value: tue
|
||||
label: Tuesday
|
||||
- value: wed
|
||||
label: Wednesday
|
||||
- value: thu
|
||||
label: Thursday
|
||||
- value: fri
|
||||
label: Friday
|
||||
- value: sat
|
||||
label: Saturday
|
||||
- value: sun
|
||||
label: Sunday
|
||||
|
||||
add_weekday:
|
||||
target:
|
||||
entity:
|
||||
domain: input_weekday
|
||||
fields:
|
||||
weekday:
|
||||
required: true
|
||||
example: mon
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
options:
|
||||
- value: mon
|
||||
label: Monday
|
||||
- value: tue
|
||||
label: Tuesday
|
||||
- value: wed
|
||||
label: Wednesday
|
||||
- value: thu
|
||||
label: Thursday
|
||||
- value: fri
|
||||
label: Friday
|
||||
- value: sat
|
||||
label: Saturday
|
||||
- value: sun
|
||||
label: Sunday
|
||||
|
||||
remove_weekday:
|
||||
target:
|
||||
entity:
|
||||
domain: input_weekday
|
||||
fields:
|
||||
weekday:
|
||||
required: true
|
||||
example: mon
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
options:
|
||||
- value: mon
|
||||
label: Monday
|
||||
- value: tue
|
||||
label: Tuesday
|
||||
- value: wed
|
||||
label: Wednesday
|
||||
- value: thu
|
||||
label: Thursday
|
||||
- value: fri
|
||||
label: Friday
|
||||
- value: sat
|
||||
label: Saturday
|
||||
- value: sun
|
||||
label: Sunday
|
||||
|
||||
toggle_weekday:
|
||||
target:
|
||||
entity:
|
||||
domain: input_weekday
|
||||
fields:
|
||||
weekday:
|
||||
required: true
|
||||
example: mon
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
options:
|
||||
- value: mon
|
||||
label: Monday
|
||||
- value: tue
|
||||
label: Tuesday
|
||||
- value: wed
|
||||
label: Wednesday
|
||||
- value: thu
|
||||
label: Thursday
|
||||
- value: fri
|
||||
label: Friday
|
||||
- value: sat
|
||||
label: Saturday
|
||||
- value: sun
|
||||
label: Sunday
|
||||
|
||||
clear:
|
||||
target:
|
||||
entity:
|
||||
domain: input_weekday
|
||||
|
||||
reload:
|
70
homeassistant/components/input_weekday/strings.json
Normal file
70
homeassistant/components/input_weekday/strings.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Input Weekday",
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::input_weekday::title%]",
|
||||
"state_attributes": {
|
||||
"weekdays": {
|
||||
"name": "Weekdays"
|
||||
},
|
||||
"editable": {
|
||||
"name": "[%key:common::generic::ui_managed%]",
|
||||
"state": {
|
||||
"true": "[%key:common::state::yes%]",
|
||||
"false": "[%key:common::state::no%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_weekdays": {
|
||||
"name": "Set weekdays",
|
||||
"description": "Sets the selected weekdays.",
|
||||
"fields": {
|
||||
"weekdays": {
|
||||
"name": "Weekdays",
|
||||
"description": "List of weekdays to select."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add_weekday": {
|
||||
"name": "Add weekday",
|
||||
"description": "Adds a weekday to the selection.",
|
||||
"fields": {
|
||||
"weekday": {
|
||||
"name": "Weekday",
|
||||
"description": "Weekday to add."
|
||||
}
|
||||
}
|
||||
},
|
||||
"remove_weekday": {
|
||||
"name": "Remove weekday",
|
||||
"description": "Removes a weekday from the selection.",
|
||||
"fields": {
|
||||
"weekday": {
|
||||
"name": "Weekday",
|
||||
"description": "Weekday to remove."
|
||||
}
|
||||
}
|
||||
},
|
||||
"toggle_weekday": {
|
||||
"name": "Toggle weekday",
|
||||
"description": "Toggles a weekday in the selection.",
|
||||
"fields": {
|
||||
"weekday": {
|
||||
"name": "Weekday",
|
||||
"description": "Weekday to toggle."
|
||||
}
|
||||
}
|
||||
},
|
||||
"clear": {
|
||||
"name": "Clear",
|
||||
"description": "Clears all selected weekdays."
|
||||
},
|
||||
"reload": {
|
||||
"name": "[%key:common::action::reload%]",
|
||||
"description": "Reloads helpers from the YAML-configuration."
|
||||
}
|
||||
}
|
||||
}
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.1.1"]
|
||||
"requirements": ["pylamarzocco==2.1.2"]
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -23,7 +24,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
|
||||
from . import async_get_config_entry_implementation
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import TokenManager, mcp_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -41,9 +48,17 @@ MCP_DISCOVERY_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthConfig:
|
||||
"""Class to hold OAuth configuration."""
|
||||
|
||||
authorization_server: AuthorizationServer
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
async def async_discover_oauth_config(
|
||||
hass: HomeAssistant, mcp_server_url: str
|
||||
) -> AuthorizationServer:
|
||||
) -> OAuthConfig:
|
||||
"""Discover the OAuth configuration for the MCP server.
|
||||
|
||||
This implements the functionality in the MCP spec for discovery. If the MCP server URL
|
||||
@@ -65,9 +80,11 @@ async def async_discover_oauth_config(
|
||||
except httpx.HTTPStatusError as error:
|
||||
if error.response.status_code == 404:
|
||||
_LOGGER.info("Authorization Server Metadata not found, using default paths")
|
||||
return AuthorizationServer(
|
||||
authorize_url=str(parsed_url.with_path("/authorize")),
|
||||
token_url=str(parsed_url.with_path("/token")),
|
||||
return OAuthConfig(
|
||||
authorization_server=AuthorizationServer(
|
||||
authorize_url=str(parsed_url.with_path("/authorize")),
|
||||
token_url=str(parsed_url.with_path("/token")),
|
||||
)
|
||||
)
|
||||
raise CannotConnect from error
|
||||
except httpx.HTTPError as error:
|
||||
@@ -81,9 +98,15 @@ async def async_discover_oauth_config(
|
||||
authorize_url = str(parsed_url.with_path(authorize_url))
|
||||
if token_url.startswith("/"):
|
||||
token_url = str(parsed_url.with_path(token_url))
|
||||
return AuthorizationServer(
|
||||
authorize_url=authorize_url,
|
||||
token_url=token_url,
|
||||
# We have no way to know the minimum set of scopes needed, so request
|
||||
# all of them and let the user limit during the authorization step.
|
||||
scopes = data.get("scopes_supported")
|
||||
return OAuthConfig(
|
||||
authorization_server=AuthorizationServer(
|
||||
authorize_url=authorize_url,
|
||||
token_url=token_url,
|
||||
),
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +153,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self.data: dict[str, Any] = {}
|
||||
self.oauth_config: OAuthConfig | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -170,7 +194,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
to find the OAuth medata then run the OAuth authentication flow.
|
||||
"""
|
||||
try:
|
||||
authorization_server = await async_discover_oauth_config(
|
||||
oauth_config = await async_discover_oauth_config(
|
||||
self.hass, self.data[CONF_URL]
|
||||
)
|
||||
except TimeoutConnectError:
|
||||
@@ -181,11 +205,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
else:
|
||||
_LOGGER.info("OAuth configuration: %s", authorization_server)
|
||||
_LOGGER.info("OAuth configuration: %s", oauth_config)
|
||||
self.oauth_config = oauth_config
|
||||
self.data.update(
|
||||
{
|
||||
CONF_AUTHORIZATION_URL: authorization_server.authorize_url,
|
||||
CONF_TOKEN_URL: authorization_server.token_url,
|
||||
CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url,
|
||||
CONF_TOKEN_URL: oauth_config.authorization_server.token_url,
|
||||
CONF_SCOPE: oauth_config.scopes,
|
||||
}
|
||||
)
|
||||
return await self.async_step_credentials_choice()
|
||||
@@ -197,6 +223,15 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
self.data[CONF_TOKEN_URL],
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
data = {}
|
||||
if self.data and (scopes := self.data[CONF_SCOPE]) is not None:
|
||||
data[CONF_SCOPE] = " ".join(scopes)
|
||||
data.update(super().extra_authorize_data)
|
||||
return data
|
||||
|
||||
async def async_step_credentials_choice(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
@@ -5,3 +5,4 @@ DOMAIN = "mcp"
|
||||
CONF_ACCESS_TOKEN = "access_token"
|
||||
CONF_AUTHORIZATION_URL = "authorization_url"
|
||||
CONF_TOKEN_URL = "token_url"
|
||||
CONF_SCOPE = "scope"
|
||||
|
@@ -59,7 +59,7 @@ async def create_server(
|
||||
# Backwards compatibility with old MCP Server config
|
||||
return await llm.async_get_api(hass, llm_api_id, llm_context)
|
||||
|
||||
@server.list_prompts() # type: ignore[no-untyped-call, misc]
|
||||
@server.list_prompts() # type: ignore[no-untyped-call,untyped-decorator]
|
||||
async def handle_list_prompts() -> list[types.Prompt]:
|
||||
llm_api = await get_api_instance()
|
||||
return [
|
||||
@@ -69,7 +69,7 @@ async def create_server(
|
||||
)
|
||||
]
|
||||
|
||||
@server.get_prompt() # type: ignore[no-untyped-call, misc]
|
||||
@server.get_prompt() # type: ignore[no-untyped-call,untyped-decorator]
|
||||
async def handle_get_prompt(
|
||||
name: str, arguments: dict[str, str] | None
|
||||
) -> types.GetPromptResult:
|
||||
@@ -90,13 +90,13 @@ async def create_server(
|
||||
],
|
||||
)
|
||||
|
||||
@server.list_tools() # type: ignore[no-untyped-call, misc]
|
||||
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
"""List available time tools."""
|
||||
llm_api = await get_api_instance()
|
||||
return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
|
||||
|
||||
@server.call_tool() # type: ignore[misc]
|
||||
@server.call_tool() # type: ignore[untyped-decorator]
|
||||
async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
|
||||
"""Handle calling tools."""
|
||||
llm_api = await get_api_instance()
|
||||
|
@@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DEFAULT_PLATE_COUNT = 4
|
||||
|
||||
PLATE_COUNT = {
|
||||
"KM7575": 6,
|
||||
"KM7678": 6,
|
||||
"KM7697": 6,
|
||||
"KM7878": 6,
|
||||
|
@@ -10,7 +10,11 @@ from mill import Heater, Mill
|
||||
from mill_local import Mill as MillLocal
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
@@ -147,7 +151,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
)
|
||||
metadata = StatisticMetaData(
|
||||
has_mean=False,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"{heater.name}",
|
||||
source=DOMAIN,
|
||||
|
@@ -253,6 +253,7 @@ class ModbusHub:
|
||||
self._client: (
|
||||
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
|
||||
) = None
|
||||
self._lock = asyncio.Lock()
|
||||
self.event_connected = asyncio.Event()
|
||||
self.hass = hass
|
||||
self.name = client_config[CONF_NAME]
|
||||
@@ -415,7 +416,9 @@ class ModbusHub:
|
||||
"""Convert async to sync pymodbus call."""
|
||||
if not self._client:
|
||||
return None
|
||||
result = await self.low_level_pb_call(unit, address, value, use_call)
|
||||
if self._msg_wait:
|
||||
await asyncio.sleep(self._msg_wait)
|
||||
return result
|
||||
async with self._lock:
|
||||
result = await self.low_level_pb_call(unit, address, value, use_call)
|
||||
if self._msg_wait:
|
||||
# small delay until next request/response
|
||||
await asyncio.sleep(self._msg_wait)
|
||||
return result
|
||||
|
@@ -174,7 +174,7 @@ class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity):
|
||||
|
||||
_restore_tilt = False
|
||||
|
||||
def __init__(self, coordinator, blind, device_class):
|
||||
def __init__(self, coordinator, blind, device_class) -> None:
|
||||
"""Initialize the blind."""
|
||||
super().__init__(coordinator, blind)
|
||||
|
||||
@@ -275,7 +275,7 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
"""
|
||||
if self._blind.angle is None:
|
||||
return None
|
||||
return self._blind.angle * 100 / 180
|
||||
return 100 - (self._blind.angle * 100 / 180)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
@@ -287,14 +287,14 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
@@ -302,7 +302,7 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
"""Move the cover tilt to a specific position."""
|
||||
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 180 - angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
@@ -347,9 +347,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
if self._blind.position is None:
|
||||
if self._blind.angle is None:
|
||||
return None
|
||||
return self._blind.angle * 100 / 180
|
||||
return 100 - (self._blind.angle * 100 / 180)
|
||||
|
||||
return self._blind.position
|
||||
return 100 - self._blind.position
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
@@ -357,9 +357,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
if self._blind.position is None:
|
||||
if self._blind.angle is None:
|
||||
return None
|
||||
return self._blind.angle == 0
|
||||
return self._blind.angle == 180
|
||||
|
||||
return self._blind.position == 0
|
||||
return self._blind.position == 100
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
@@ -381,10 +381,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
if self._blind.position is None:
|
||||
angle = angle * 180 / 100
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._blind.Set_angle, 180 - angle
|
||||
)
|
||||
else:
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._blind.Set_position, 100 - angle
|
||||
)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
@@ -397,10 +401,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
if self._blind.position is None:
|
||||
angle = angle * 180 / 100
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._blind.Set_angle, 180 - angle
|
||||
)
|
||||
else:
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._blind.Set_position, 100 - angle
|
||||
)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
@@ -408,7 +416,7 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
class MotionTDBUDevice(MotionBaseDevice):
|
||||
"""Representation of a Motion Top Down Bottom Up blind Device."""
|
||||
|
||||
def __init__(self, coordinator, blind, device_class, motor):
|
||||
def __init__(self, coordinator, blind, device_class, motor) -> None:
|
||||
"""Initialize the blind."""
|
||||
super().__init__(coordinator, blind, device_class)
|
||||
self._motor = motor
|
||||
|
@@ -458,7 +458,6 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
@@ -1142,7 +1141,6 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.LOCK.value: None,
|
||||
Platform.NOTIFY.value: None,
|
||||
Platform.NUMBER.value: validate_number_platform_config,
|
||||
Platform.SELECT: None,
|
||||
Platform.SENSOR.value: validate_sensor_platform_config,
|
||||
Platform.SWITCH.value: None,
|
||||
}
|
||||
@@ -1369,7 +1367,6 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
custom_filtering=True,
|
||||
),
|
||||
},
|
||||
Platform.SELECT.value: {},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_DEVICE_CLASS: PlatformField(
|
||||
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
|
||||
@@ -3106,34 +3103,6 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.SELECT.value: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
|
@@ -346,7 +346,6 @@
|
||||
"mode_state_template": "Operation mode value template",
|
||||
"on_command_type": "ON command type",
|
||||
"optimistic": "Optimistic",
|
||||
"options": "Set options",
|
||||
"payload_off": "Payload \"off\"",
|
||||
"payload_on": "Payload \"on\"",
|
||||
"payload_press": "Payload \"press\"",
|
||||
@@ -394,7 +393,6 @@
|
||||
"mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
|
||||
"on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.",
|
||||
"optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
|
||||
"options": "List of options that can be selected.",
|
||||
"payload_off": "The payload that represents the \"off\" state.",
|
||||
"payload_on": "The payload that represents the \"on\" state.",
|
||||
"payload_press": "The payload to send when the button is triggered.",
|
||||
@@ -1336,7 +1334,6 @@
|
||||
"lock": "[%key:component::lock::title%]",
|
||||
"notify": "[%key:component::notify::title%]",
|
||||
"number": "[%key:component::number::title%]",
|
||||
"select": "[%key:component::select::title%]",
|
||||
"sensor": "[%key:component::sensor::title%]",
|
||||
"switch": "[%key:component::switch::title%]"
|
||||
}
|
||||
|
@@ -53,7 +53,7 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255))
|
||||
await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["nikohomecontrol"],
|
||||
"requirements": ["nhc==0.4.12"]
|
||||
"requirements": ["nhc==0.6.1"]
|
||||
}
|
||||
|
51
homeassistant/components/nintendo_parental/__init__.py
Normal file
51
homeassistant/components/nintendo_parental/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""The Nintendo Switch Parental Controls integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pynintendoparental import Authenticator
|
||||
from pynintendoparental.exceptions import (
|
||||
InvalidOAuthConfigurationException,
|
||||
InvalidSessionTokenException,
|
||||
)
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_SESSION_TOKEN, DOMAIN
|
||||
from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: NintendoParentalConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Nintendo Switch Parental Controls from a config entry."""
|
||||
try:
|
||||
nintendo_auth = await Authenticator.complete_login(
|
||||
auth=None,
|
||||
response_token=entry.data[CONF_SESSION_TOKEN],
|
||||
is_session_token=True,
|
||||
client_session=async_get_clientsession(hass),
|
||||
)
|
||||
except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_expired",
|
||||
) from err
|
||||
entry.runtime_data = coordinator = NintendoUpdateCoordinator(
|
||||
hass, nintendo_auth, entry
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: NintendoParentalConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
61
homeassistant/components/nintendo_parental/config_flow.py
Normal file
61
homeassistant/components/nintendo_parental/config_flow.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Config flow for the Nintendo Switch Parental Controls integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pynintendoparental import Authenticator
|
||||
from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_SESSION_TOKEN, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NintendoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Nintendo Switch Parental Controls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new config flow instance."""
|
||||
self.auth: Authenticator | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if self.auth is None:
|
||||
self.auth = Authenticator.generate_login(
|
||||
client_session=async_get_clientsession(self.hass)
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self.auth.complete_login(
|
||||
self.auth, user_input[CONF_API_TOKEN], False
|
||||
)
|
||||
except (ValueError, InvalidSessionTokenException, HttpException):
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
if TYPE_CHECKING:
|
||||
assert self.auth.account_id
|
||||
await self.async_set_unique_id(self.auth.account_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=self.auth.account_id,
|
||||
data={
|
||||
CONF_SESSION_TOKEN: self.auth.get_session_token,
|
||||
},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
description_placeholders={"link": self.auth.login_url},
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
|
||||
errors=errors,
|
||||
)
|
5
homeassistant/components/nintendo_parental/const.py
Normal file
5
homeassistant/components/nintendo_parental/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Nintendo Switch Parental Controls integration."""
|
||||
|
||||
DOMAIN = "nintendo_parental"
|
||||
CONF_UPDATE_INTERVAL = "update_interval"
|
||||
CONF_SESSION_TOKEN = "session_token"
|
52
homeassistant/components/nintendo_parental/coordinator.py
Normal file
52
homeassistant/components/nintendo_parental/coordinator.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Nintendo Parental Controls data coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pynintendoparental import Authenticator, NintendoParental
|
||||
from pynintendoparental.exceptions import InvalidOAuthConfigurationException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class NintendoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Nintendo data update coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
authenticator: Authenticator,
|
||||
config_entry: NintendoParentalConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize update coordinator."""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.api = NintendoParental(
|
||||
authenticator, hass.config.time_zone, hass.config.language
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data from Nintendo's API."""
|
||||
try:
|
||||
return await self.api.update()
|
||||
except InvalidOAuthConfigurationException as err:
|
||||
raise ConfigEntryError(
|
||||
err, translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
41
homeassistant/components/nintendo_parental/entity.py
Normal file
41
homeassistant/components/nintendo_parental/entity.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Base entity definition for Nintendo Parental."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pynintendoparental.device import Device
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NintendoUpdateCoordinator
|
||||
|
||||
|
||||
class NintendoDevice(CoordinatorEntity[NintendoUpdateCoordinator]):
|
||||
"""Represent a Nintendo Switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: NintendoUpdateCoordinator, device: Device, key: str
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._attr_unique_id = f"{device.device_id}_{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.device_id)},
|
||||
manufacturer="Nintendo",
|
||||
name=device.name,
|
||||
sw_version=device.extra["firmwareVersion"]["displayedVersion"],
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is loaded."""
|
||||
await super().async_added_to_hass()
|
||||
self._device.add_device_callback(self.async_write_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When will be removed from HASS."""
|
||||
self._device.remove_device_callback(self.async_write_ha_state)
|
||||
await super().async_will_remove_from_hass()
|
11
homeassistant/components/nintendo_parental/manifest.json
Normal file
11
homeassistant/components/nintendo_parental/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "nintendo_parental",
|
||||
"name": "Nintendo Switch Parental Controls",
|
||||
"codeowners": ["@pantherale0"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/nintendo_parental",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoparental==1.1.1"]
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
No IP discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
No discovery.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
No specific icons defined.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
comment: |
|
||||
No issues in integration
|
||||
status: exempt
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
91
homeassistant/components/nintendo_parental/sensor.py
Normal file
91
homeassistant/components/nintendo_parental/sensor.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Sensor platform for Nintendo Parental."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator
|
||||
from .entity import Device, NintendoDevice
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class NintendoParentalSensor(StrEnum):
|
||||
"""Store keys for Nintendo Parental sensors."""
|
||||
|
||||
PLAYING_TIME = "playing_time"
|
||||
TIME_REMAINING = "time_remaining"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class NintendoParentalSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description for Nintendo Parental sensor entities."""
|
||||
|
||||
value_fn: Callable[[Device], int | float | None]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = (
|
||||
NintendoParentalSensorEntityDescription(
|
||||
key=NintendoParentalSensor.PLAYING_TIME,
|
||||
translation_key=NintendoParentalSensor.PLAYING_TIME,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.today_playing_time,
|
||||
),
|
||||
NintendoParentalSensorEntityDescription(
|
||||
key=NintendoParentalSensor.TIME_REMAINING,
|
||||
translation_key=NintendoParentalSensor.TIME_REMAINING,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.today_time_remaining,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: NintendoParentalConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
async_add_devices(
|
||||
NintendoParentalSensorEntity(entry.runtime_data, device, sensor)
|
||||
for device in entry.runtime_data.api.devices.values()
|
||||
for sensor in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class NintendoParentalSensorEntity(NintendoDevice, SensorEntity):
|
||||
"""Represent a single sensor."""
|
||||
|
||||
entity_description: NintendoParentalSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NintendoUpdateCoordinator,
|
||||
device: Device,
|
||||
description: NintendoParentalSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator, device=device, key=description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
"""Return the native value."""
|
||||
return self.entity_description.value_fn(self._device)
|
38
homeassistant/components/nintendo_parental/strings.json
Normal file
38
homeassistant/components/nintendo_parental/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.",
|
||||
"data": {
|
||||
"api_token": "Access token"
|
||||
},
|
||||
"data_description": {
|
||||
"api_token": "The link copied from the Nintendo website"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"playing_time": {
|
||||
"name": "Used screen time"
|
||||
},
|
||||
"time_remaining": {
|
||||
"name": "Screen time remaining"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_expired": {
|
||||
"message": "Authentication expired. Please remove and re-add the integration to reconnect."
|
||||
}
|
||||
}
|
||||
}
|
@@ -157,7 +157,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
) from error
|
||||
except NordPoolEmptyResponseError:
|
||||
return {area: [] for area in areas}
|
||||
except NordPoolError as error:
|
||||
except (NordPoolError, TimeoutError) as error:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
|
@@ -22,7 +22,7 @@
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"auto": "Automatic",
|
||||
"box": "Box",
|
||||
"box": "Input field",
|
||||
"slider": "Slider"
|
||||
}
|
||||
},
|
||||
|
@@ -31,38 +31,39 @@ async def async_setup_entry(
|
||||
events = device.events.get_platform("binary_sensor")
|
||||
entity_names = build_event_entity_names(events)
|
||||
|
||||
entities = {
|
||||
event.uid: ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid])
|
||||
for event in events
|
||||
}
|
||||
uids = set()
|
||||
entities = []
|
||||
for event in events:
|
||||
uids.add(event.uid)
|
||||
entities.append(
|
||||
ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid])
|
||||
)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id):
|
||||
if entry.domain == "binary_sensor" and entry.unique_id not in entities:
|
||||
entities[entry.unique_id] = ONVIFBinarySensor(
|
||||
entry.unique_id, device, entry=entry
|
||||
)
|
||||
if entry.domain == "binary_sensor" and entry.unique_id not in uids:
|
||||
uids.add(entry.unique_id)
|
||||
entities.append(ONVIFBinarySensor(entry.unique_id, device, entry=entry))
|
||||
|
||||
async_add_entities(entities.values())
|
||||
async_add_entities(entities)
|
||||
uids_by_platform = device.events.get_uids_by_platform("binary_sensor")
|
||||
|
||||
@callback
|
||||
def async_check_entities() -> None:
|
||||
"""Check if we have added an entity for the event."""
|
||||
nonlocal uids_by_platform
|
||||
if not (missing := uids_by_platform.difference(entities)):
|
||||
if not (missing := uids_by_platform.difference(uids)):
|
||||
return
|
||||
|
||||
events = device.events.get_platform("binary_sensor")
|
||||
entity_names = build_event_entity_names(events)
|
||||
|
||||
new_entities: dict[str, ONVIFBinarySensor] = {
|
||||
uid: ONVIFBinarySensor(uid, device, name=entity_names[uid])
|
||||
for uid in missing
|
||||
}
|
||||
new_entities = [
|
||||
ONVIFBinarySensor(uid, device, name=entity_names[uid]) for uid in missing
|
||||
]
|
||||
if new_entities:
|
||||
entities.update(new_entities)
|
||||
async_add_entities(new_entities.values())
|
||||
uids.update(missing)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
device.events.async_add_listener(async_check_entities)
|
||||
|
||||
|
@@ -30,37 +30,37 @@ async def async_setup_entry(
|
||||
events = device.events.get_platform("sensor")
|
||||
entity_names = build_event_entity_names(events)
|
||||
|
||||
entities = {
|
||||
event.uid: ONVIFSensor(event.uid, device, name=entity_names[event.uid])
|
||||
for event in events
|
||||
}
|
||||
uids = set()
|
||||
entities = []
|
||||
for event in events:
|
||||
uids.add(event.uid)
|
||||
entities.append(ONVIFSensor(event.uid, device, name=entity_names[event.uid]))
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id):
|
||||
if entry.domain == "sensor" and entry.unique_id not in entities:
|
||||
entities[entry.unique_id] = ONVIFSensor(
|
||||
entry.unique_id, device, entry=entry
|
||||
)
|
||||
if entry.domain == "sensor" and entry.unique_id not in uids:
|
||||
uids.add(entry.unique_id)
|
||||
entities.append(ONVIFSensor(entry.unique_id, device, entry=entry))
|
||||
|
||||
async_add_entities(entities.values())
|
||||
async_add_entities(entities)
|
||||
uids_by_platform = device.events.get_uids_by_platform("sensor")
|
||||
|
||||
@callback
|
||||
def async_check_entities() -> None:
|
||||
"""Check if we have added an entity for the event."""
|
||||
nonlocal uids_by_platform
|
||||
if not (missing := uids_by_platform.difference(entities)):
|
||||
if not (missing := uids_by_platform.difference(uids)):
|
||||
return
|
||||
|
||||
events = device.events.get_platform("sensor")
|
||||
entity_names = build_event_entity_names(events)
|
||||
|
||||
new_entities: dict[str, ONVIFSensor] = {
|
||||
uid: ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing
|
||||
}
|
||||
new_entities = [
|
||||
ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing
|
||||
]
|
||||
if new_entities:
|
||||
entities.update(new_entities)
|
||||
async_add_entities(new_entities.values())
|
||||
uids.update(missing)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
device.events.async_add_listener(async_check_entities)
|
||||
|
||||
|
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==1.99.5", "python-open-router==0.3.1"]
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.1"]
|
||||
}
|
||||
|
@@ -316,16 +316,23 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
step_schema: VolDictType = {
|
||||
vol.Optional(
|
||||
CONF_CODE_INTERPRETER,
|
||||
default=RECOMMENDED_CODE_INTERPRETER,
|
||||
): bool,
|
||||
}
|
||||
step_schema: VolDictType = {}
|
||||
|
||||
model = options[CONF_CHAT_MODEL]
|
||||
|
||||
if model.startswith(("o", "gpt-5")):
|
||||
if not model.startswith(("gpt-5-pro", "gpt-5-codex")):
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CODE_INTERPRETER,
|
||||
default=RECOMMENDED_CODE_INTERPRETER,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
elif CONF_CODE_INTERPRETER in options:
|
||||
options.pop(CONF_CODE_INTERPRETER)
|
||||
|
||||
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
|
@@ -468,7 +468,9 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
model_args["reasoning"] = {
|
||||
"effort": options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
),
|
||||
)
|
||||
if not model_args["model"].startswith("gpt-5-pro")
|
||||
else "high", # GPT-5 pro only supports reasoning.effort: high
|
||||
"summary": "auto",
|
||||
}
|
||||
model_args["include"] = ["reasoning.encrypted_content"]
|
||||
@@ -487,7 +489,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchToolParam(
|
||||
type="web_search_preview",
|
||||
type="web_search",
|
||||
search_context_size=options.get(
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
|
||||
),
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["openai==1.99.5"]
|
||||
"requirements": ["openai==2.2.0"]
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import logging
|
||||
from pyopenweathermap import create_owm_client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS
|
||||
@@ -25,7 +25,6 @@ type OpenweathermapConfigEntry = ConfigEntry[OpenweathermapData]
|
||||
class OpenweathermapData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
name: str
|
||||
mode: str
|
||||
coordinator: OWMUpdateCoordinator
|
||||
|
||||
@@ -34,7 +33,6 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: OpenweathermapConfigEntry
|
||||
) -> bool:
|
||||
"""Set up OpenWeatherMap as config entry."""
|
||||
name = entry.data[CONF_NAME]
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
language = entry.options[CONF_LANGUAGE]
|
||||
mode = entry.options[CONF_MODE]
|
||||
@@ -51,7 +49,7 @@ async def async_setup_entry(
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
entry.runtime_data = OpenweathermapData(name, mode, owm_coordinator)
|
||||
entry.runtime_data = OpenweathermapData(mode, owm_coordinator)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
@@ -14,12 +14,17 @@ from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
LanguageSelector,
|
||||
LanguageSelectorConfig,
|
||||
LocationSelector,
|
||||
LocationSelectorConfig,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONFIG_FLOW_VERSION,
|
||||
@@ -34,10 +39,12 @@ from .utils import build_data_and_options, validate_api_key
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
|
||||
vol.Optional(CONF_LATITUDE): cv.latitude,
|
||||
vol.Optional(CONF_LONGITUDE): cv.longitude,
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES),
|
||||
vol.Required(CONF_LOCATION): LocationSelector(
|
||||
LocationSelectorConfig(radius=False)
|
||||
),
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector(
|
||||
LanguageSelectorConfig(languages=LANGUAGES, native_name=True)
|
||||
),
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
|
||||
}
|
||||
@@ -45,7 +52,9 @@ USER_SCHEMA = vol.Schema(
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES),
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector(
|
||||
LanguageSelectorConfig(languages=LANGUAGES, native_name=True)
|
||||
),
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
|
||||
}
|
||||
)
|
||||
@@ -70,8 +79,8 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders = {}
|
||||
|
||||
if user_input is not None:
|
||||
latitude = user_input[CONF_LATITUDE]
|
||||
longitude = user_input[CONF_LONGITUDE]
|
||||
latitude = user_input[CONF_LOCATION][CONF_LATITUDE]
|
||||
longitude = user_input[CONF_LOCATION][CONF_LONGITUDE]
|
||||
mode = user_input[CONF_MODE]
|
||||
|
||||
await self.async_set_unique_id(f"{latitude}-{longitude}")
|
||||
@@ -82,15 +91,21 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if not errors:
|
||||
# Flatten location
|
||||
location = user_input.pop(CONF_LOCATION)
|
||||
user_input[CONF_LATITUDE] = location[CONF_LATITUDE]
|
||||
user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE]
|
||||
data, options = build_data_and_options(user_input)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=data, options=options
|
||||
title=DEFAULT_NAME, data=data, options=options
|
||||
)
|
||||
schema_data = user_input
|
||||
else:
|
||||
schema_data = {
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
CONF_LOCATION: {
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
},
|
||||
CONF_LANGUAGE: self.hass.config.language,
|
||||
}
|
||||
|
||||
|
@@ -121,6 +121,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_CLOUDS,
|
||||
@@ -158,6 +159,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_CONDITION,
|
||||
@@ -227,7 +229,6 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up OpenWeatherMap sensor entities based on a config entry."""
|
||||
domain_data = config_entry.runtime_data
|
||||
name = domain_data.name
|
||||
unique_id = config_entry.unique_id
|
||||
assert unique_id is not None
|
||||
coordinator = domain_data.coordinator
|
||||
@@ -242,7 +243,6 @@ async def async_setup_entry(
|
||||
elif domain_data.mode == OWM_MODE_AIRPOLLUTION:
|
||||
async_add_entities(
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
unique_id,
|
||||
description,
|
||||
coordinator,
|
||||
@@ -252,7 +252,6 @@ async def async_setup_entry(
|
||||
else:
|
||||
async_add_entities(
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
unique_id,
|
||||
description,
|
||||
coordinator,
|
||||
@@ -270,7 +269,6 @@ class AbstractOpenWeatherMapSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
unique_id: str,
|
||||
description: SensorEntityDescription,
|
||||
coordinator: OWMUpdateCoordinator,
|
||||
@@ -284,7 +282,6 @@ class AbstractOpenWeatherMapSensor(SensorEntity):
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=name,
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -12,16 +12,14 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"language": "[%key:common::config_flow::data::language%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"mode": "[%key:common::config_flow::data::mode%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key for the OpenWeatherMap integration",
|
||||
"language": "Language for the OpenWeatherMap content",
|
||||
"latitude": "Latitude of the location",
|
||||
"longitude": "Longitude of the location",
|
||||
"location": "Location to get the weather data for",
|
||||
"mode": "Mode for the OpenWeatherMap API",
|
||||
"name": "Name for this OpenWeatherMap location"
|
||||
},
|
||||
|
@@ -57,14 +57,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up OpenWeatherMap weather entity based on a config entry."""
|
||||
domain_data = config_entry.runtime_data
|
||||
name = domain_data.name
|
||||
mode = domain_data.mode
|
||||
|
||||
if mode != OWM_MODE_AIRPOLLUTION:
|
||||
weather_coordinator = domain_data.coordinator
|
||||
|
||||
unique_id = f"{config_entry.unique_id}"
|
||||
owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator)
|
||||
owm_weather = OpenWeatherMapWeather(unique_id, mode, weather_coordinator)
|
||||
|
||||
async_add_entities([owm_weather], False)
|
||||
|
||||
@@ -93,7 +92,6 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
unique_id: str,
|
||||
mode: str,
|
||||
weather_coordinator: OWMUpdateCoordinator,
|
||||
@@ -105,7 +103,6 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=name,
|
||||
)
|
||||
self.mode = mode
|
||||
|
||||
|
@@ -18,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .coordinator import PortainerCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
|
||||
|
||||
|
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"image": {
|
||||
"default": "mdi:docker"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"container": {
|
||||
"default": "mdi:arrow-down-box",
|
||||
|
83
homeassistant/components/portainer/sensor.py
Normal file
83
homeassistant/components/portainer/sensor.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Sensor platform for Portainer integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyportainer.models.docker import DockerContainer
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import PortainerConfigEntry, PortainerCoordinator
|
||||
from .entity import PortainerContainerEntity, PortainerCoordinatorData
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold Portainer sensor description."""
|
||||
|
||||
value_fn: Callable[[DockerContainer], str | None]
|
||||
|
||||
|
||||
CONTAINER_SENSORS: tuple[PortainerSensorEntityDescription, ...] = (
|
||||
PortainerSensorEntityDescription(
|
||||
key="image",
|
||||
translation_key="image",
|
||||
value_fn=lambda data: data.image,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PortainerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Portainer sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
PortainerContainerSensor(
|
||||
coordinator,
|
||||
entity_description,
|
||||
container,
|
||||
endpoint,
|
||||
)
|
||||
for endpoint in coordinator.data.values()
|
||||
for container in endpoint.containers.values()
|
||||
for entity_description in CONTAINER_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
|
||||
"""Representation of a Portainer container sensor."""
|
||||
|
||||
entity_description: PortainerSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerSensorEntityDescription,
|
||||
device_info: DockerContainer,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer container sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return super().available and self.endpoint_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.endpoint_id].containers[self.device_id]
|
||||
)
|
@@ -46,6 +46,11 @@
|
||||
"name": "Status"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"image": {
|
||||
"name": "Image"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"container": {
|
||||
"name": "Container"
|
||||
|
@@ -48,7 +48,7 @@ from .const import (
|
||||
|
||||
DEFAULT_OFF_DELAY = 2.0
|
||||
|
||||
CONNECT_TIMEOUT = 30.0
|
||||
CONNECT_TIMEOUT = 60.0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"config_subentries": {
|
||||
"partition": {
|
||||
"entry_type": "Partition",
|
||||
"initiate_flow": {
|
||||
"user": "Add partition"
|
||||
},
|
||||
@@ -57,6 +58,7 @@
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"entry_type": "Zone",
|
||||
"initiate_flow": {
|
||||
"user": "Add zone"
|
||||
},
|
||||
@@ -91,6 +93,7 @@
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"entry_type": "Output",
|
||||
"initiate_flow": {
|
||||
"user": "Add output"
|
||||
},
|
||||
@@ -125,6 +128,7 @@
|
||||
}
|
||||
},
|
||||
"switchable_output": {
|
||||
"entry_type": "Switchable output",
|
||||
"initiate_flow": {
|
||||
"user": "Add switchable output"
|
||||
},
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "sharkiq",
|
||||
"name": "Shark IQ",
|
||||
"codeowners": ["@JeffResc", "@funkybunch"],
|
||||
"codeowners": ["@JeffResc", "@funkybunch", "@TheOneOgre"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
|
||||
"iot_class": "cloud_polling",
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
from aioshelly.ble.const import BLE_SCRIPT_NAME
|
||||
@@ -63,6 +64,7 @@ from .repairs import (
|
||||
)
|
||||
from .utils import (
|
||||
async_create_issue_unsupported_firmware,
|
||||
async_migrate_rpc_virtual_components_unique_ids,
|
||||
get_coap_context,
|
||||
get_device_entry_gen,
|
||||
get_http_port,
|
||||
@@ -323,6 +325,12 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
|
||||
translation_placeholders={"device": entry.title},
|
||||
) from err
|
||||
|
||||
await er.async_migrate_entries(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
partial(async_migrate_rpc_virtual_components_unique_ids, device.config),
|
||||
)
|
||||
|
||||
runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device)
|
||||
runtime_data.rpc.async_setup()
|
||||
runtime_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device)
|
||||
|
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD
|
||||
from .const import CONF_SLEEP_PERIOD, MODEL_FRANKEVER_WATER_VALVE
|
||||
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -157,21 +157,18 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
|
||||
key="input|input",
|
||||
name="Input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("relay", "input"): BlockBinarySensorDescription(
|
||||
key="relay|input",
|
||||
name="Input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("device", "input"): BlockBinarySensorDescription(
|
||||
key="device|input",
|
||||
name="Input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("sensor", "extInput"): BlockBinarySensorDescription(
|
||||
@@ -201,7 +198,6 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="state",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
removal_condition=is_rpc_momentary_input,
|
||||
),
|
||||
"cloud": RpcBinarySensorDescription(
|
||||
@@ -270,12 +266,21 @@ RPC_SENSORS: Final = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"boolean": RpcBinarySensorDescription(
|
||||
"boolean_generic": RpcBinarySensorDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
config, key, BINARY_SENSOR_PLATFORM
|
||||
),
|
||||
role="generic",
|
||||
),
|
||||
"boolean_has_power": RpcBinarySensorDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
role="has_power",
|
||||
models={MODEL_FRANKEVER_WATER_VALVE},
|
||||
),
|
||||
"calibration": RpcBinarySensorDescription(
|
||||
key="blutrv",
|
||||
|
@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
|
||||
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
|
||||
from aioshelly.rpc_device import RpcDevice
|
||||
|
||||
from homeassistant.components.button import (
|
||||
DOMAIN as BUTTON_PLATFORM,
|
||||
@@ -24,16 +23,24 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS
|
||||
from .const import DOMAIN, LOGGER, MODEL_FRANKEVER_WATER_VALVE, SHELLY_GAS_MODELS
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import get_entity_block_device_info, get_entity_rpc_device_info
|
||||
from .entity import (
|
||||
RpcEntityDescription,
|
||||
ShellyRpcAttributeEntity,
|
||||
async_setup_entry_rpc,
|
||||
get_entity_block_device_info,
|
||||
get_entity_rpc_device_info,
|
||||
rpc_call,
|
||||
)
|
||||
from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
format_ble_addr,
|
||||
get_blu_trv_device_info,
|
||||
get_device_entry_gen,
|
||||
get_rpc_entity_name,
|
||||
get_rpc_key_ids,
|
||||
get_rpc_key_instances,
|
||||
get_rpc_role_by_key,
|
||||
get_virtual_component_ids,
|
||||
)
|
||||
|
||||
@@ -51,6 +58,11 @@ class ShellyButtonDescription[
|
||||
supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RpcButtonDescription(RpcEntityDescription, ButtonEntityDescription):
|
||||
"""Class to describe a RPC button."""
|
||||
|
||||
|
||||
BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
|
||||
ShellyButtonDescription[ShellyBlockCoordinator | ShellyRpcCoordinator](
|
||||
key="reboot",
|
||||
@@ -96,12 +108,24 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
|
||||
),
|
||||
]
|
||||
|
||||
VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [
|
||||
ShellyButtonDescription[ShellyRpcCoordinator](
|
||||
RPC_VIRTUAL_BUTTONS = {
|
||||
"button_generic": RpcButtonDescription(
|
||||
key="button",
|
||||
press_action="single_push",
|
||||
)
|
||||
]
|
||||
role="generic",
|
||||
),
|
||||
"button_open": RpcButtonDescription(
|
||||
key="button",
|
||||
entity_registry_enabled_default=False,
|
||||
role="open",
|
||||
models={MODEL_FRANKEVER_WATER_VALVE},
|
||||
),
|
||||
"button_close": RpcButtonDescription(
|
||||
key="button",
|
||||
entity_registry_enabled_default=False,
|
||||
role="close",
|
||||
models={MODEL_FRANKEVER_WATER_VALVE},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
@@ -129,8 +153,10 @@ def async_migrate_unique_ids(
|
||||
)
|
||||
}
|
||||
|
||||
if not isinstance(coordinator, ShellyRpcCoordinator):
|
||||
return None
|
||||
|
||||
if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
|
||||
assert isinstance(coordinator.device, RpcDevice)
|
||||
for _id in blutrv_key_ids:
|
||||
key = f"{BLU_TRV_IDENTIFIER}:{_id}"
|
||||
ble_addr: str = coordinator.device.config[key]["addr"]
|
||||
@@ -149,6 +175,26 @@ def async_migrate_unique_ids(
|
||||
)
|
||||
}
|
||||
|
||||
if virtual_button_keys := get_rpc_key_instances(
|
||||
coordinator.device.config, "button"
|
||||
):
|
||||
for key in virtual_button_keys:
|
||||
old_unique_id = f"{coordinator.mac}-{key}"
|
||||
if entity_entry.unique_id == old_unique_id:
|
||||
role = get_rpc_role_by_key(coordinator.device.config, key)
|
||||
new_unique_id = f"{coordinator.mac}-{key}-button_{role}"
|
||||
LOGGER.debug(
|
||||
"Migrating unique_id for %s entity from [%s] to [%s]",
|
||||
entity_entry.entity_id,
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
old_unique_id, new_unique_id
|
||||
)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -172,7 +218,7 @@ async def async_setup_entry(
|
||||
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
|
||||
)
|
||||
|
||||
entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = []
|
||||
entities: list[ShellyButton | ShellyBluTrvButton] = []
|
||||
|
||||
entities.extend(
|
||||
ShellyButton(coordinator, button)
|
||||
@@ -185,12 +231,9 @@ async def async_setup_entry(
|
||||
return
|
||||
|
||||
# add virtual buttons
|
||||
if virtual_button_ids := get_rpc_key_ids(coordinator.device.status, "button"):
|
||||
entities.extend(
|
||||
ShellyVirtualButton(coordinator, button, id_)
|
||||
for id_ in virtual_button_ids
|
||||
for button in VIRTUAL_BUTTONS
|
||||
)
|
||||
async_setup_entry_rpc(
|
||||
hass, config_entry, async_add_entities, RPC_VIRTUAL_BUTTONS, RpcVirtualButton
|
||||
)
|
||||
|
||||
# add BLU TRV buttons
|
||||
if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
|
||||
@@ -332,30 +375,16 @@ class ShellyBluTrvButton(ShellyBaseButton):
|
||||
await method(self._id)
|
||||
|
||||
|
||||
class ShellyVirtualButton(ShellyBaseButton):
|
||||
"""Defines a Shelly virtual component button."""
|
||||
class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
|
||||
"""Defines a Shelly RPC virtual component button."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
description: ShellyButtonDescription,
|
||||
_id: int,
|
||||
) -> None:
|
||||
"""Initialize Shelly virtual component button."""
|
||||
super().__init__(coordinator, description)
|
||||
entity_description: RpcButtonDescription
|
||||
_id: int
|
||||
|
||||
self._attr_unique_id = f"{coordinator.mac}-{description.key}:{_id}"
|
||||
self._attr_device_info = get_entity_rpc_device_info(coordinator)
|
||||
self._attr_name = get_rpc_entity_name(
|
||||
coordinator.device, f"{description.key}:{_id}"
|
||||
)
|
||||
self._id = _id
|
||||
|
||||
async def _press_method(self) -> None:
|
||||
"""Press method."""
|
||||
@rpc_call
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the Shelly button press service."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self.coordinator, ShellyRpcCoordinator)
|
||||
|
||||
await self.coordinator.device.button_trigger(
|
||||
self._id, self.entity_description.press_action
|
||||
)
|
||||
await self.coordinator.device.button_trigger(self._id, "single_push")
|
||||
|
@@ -308,3 +308,5 @@ MODEL_NEO_WATER_VALVE = "NeoWaterValve"
|
||||
MODEL_FRANKEVER_WATER_VALVE = "WaterValve"
|
||||
MODEL_LINKEDGO_ST802_THERMOSTAT = "ST-802"
|
||||
MODEL_LINKEDGO_ST1820_THERMOSTAT = "ST1820"
|
||||
MODEL_TOP_EV_CHARGER_EVE01 = "EVE01"
|
||||
MODEL_FRANKEVER_IRRIGATION_CONTROLLER = "Irrigation"
|
||||
|
@@ -29,6 +29,7 @@ from .utils import (
|
||||
get_rpc_device_info,
|
||||
get_rpc_entity_name,
|
||||
get_rpc_key_instances,
|
||||
get_rpc_role_by_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -189,14 +190,16 @@ def async_setup_rpc_attribute_entities(
|
||||
if description.models and coordinator.model not in description.models:
|
||||
continue
|
||||
|
||||
if description.role and description.role != coordinator.device.config[
|
||||
key
|
||||
].get("role", "generic"):
|
||||
if description.role and description.role != get_rpc_role_by_key(
|
||||
coordinator.device.config, key
|
||||
):
|
||||
continue
|
||||
|
||||
if description.sub_key not in coordinator.device.status[
|
||||
key
|
||||
] and not description.supported(coordinator.device.status[key]):
|
||||
if (
|
||||
description.sub_key
|
||||
and description.sub_key not in coordinator.device.status[key]
|
||||
and not description.supported(coordinator.device.status[key])
|
||||
):
|
||||
continue
|
||||
|
||||
# Filter and remove entities that according to settings/status
|
||||
@@ -308,7 +311,7 @@ class RpcEntityDescription(EntityDescription):
|
||||
# restrict the type to str.
|
||||
name: str = ""
|
||||
|
||||
sub_key: str
|
||||
sub_key: str | None = None
|
||||
|
||||
value: Callable[[Any, Any], Any] | None = None
|
||||
available: Callable[[dict], bool] | None = None
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioshelly==13.11.0"],
|
||||
"requirements": ["aioshelly==13.12.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@@ -24,7 +24,16 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP
|
||||
from .const import (
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MODEL_FRANKEVER_WATER_VALVE,
|
||||
MODEL_LINKEDGO_ST802_THERMOSTAT,
|
||||
MODEL_LINKEDGO_ST1820_THERMOSTAT,
|
||||
MODEL_TOP_EV_CHARGER_EVE01,
|
||||
VIRTUAL_NUMBER_MODE_MAP,
|
||||
)
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -183,7 +192,7 @@ RPC_NUMBERS: Final = {
|
||||
method="blu_trv_set_external_temperature",
|
||||
entity_class=RpcBluTrvExtTempNumber,
|
||||
),
|
||||
"number": RpcNumberDescription(
|
||||
"number_generic": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
@@ -197,6 +206,58 @@ RPC_NUMBERS: Final = {
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
role="generic",
|
||||
),
|
||||
"number_current_limit": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
role="current_limit",
|
||||
models={MODEL_TOP_EV_CHARGER_EVE01},
|
||||
),
|
||||
"number_position": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
role="position",
|
||||
models={MODEL_FRANKEVER_WATER_VALVE},
|
||||
),
|
||||
"number_target_humidity": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
role="target_humidity",
|
||||
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
|
||||
),
|
||||
"number_target_temperature": RpcNumberDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
max_fn=lambda config: config["max"],
|
||||
min_fn=lambda config: config["min"],
|
||||
mode_fn=lambda config: NumberMode.SLIDER,
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
unit=get_virtual_component_unit,
|
||||
method="number_set",
|
||||
role="target_temperature",
|
||||
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
|
||||
),
|
||||
"valve_position": RpcNumberDescription(
|
||||
key="blutrv",
|
||||
|
@@ -38,12 +38,13 @@ class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription):
|
||||
|
||||
|
||||
RPC_SELECT_ENTITIES: Final = {
|
||||
"enum": RpcSelectDescription(
|
||||
"enum_generic": RpcSelectDescription(
|
||||
key="enum",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
config, key, SELECT_PLATFORM
|
||||
),
|
||||
role="generic",
|
||||
),
|
||||
}
|
||||
|
||||
|
@@ -3,8 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Final, cast
|
||||
from typing import Final, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
@@ -37,13 +36,12 @@ from homeassistant.const import (
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, LOGGER
|
||||
from .const import CONF_SLEEP_PERIOD
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -551,7 +549,7 @@ RPC_SENSORS: Final = {
|
||||
"a_act_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="a_act_power",
|
||||
name="Active power",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -561,7 +559,7 @@ RPC_SENSORS: Final = {
|
||||
"b_act_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="b_act_power",
|
||||
name="Active power",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -571,7 +569,7 @@ RPC_SENSORS: Final = {
|
||||
"c_act_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="c_act_power",
|
||||
name="Active power",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -581,7 +579,7 @@ RPC_SENSORS: Final = {
|
||||
"total_act_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="total_act_power",
|
||||
name="Total active power",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -627,7 +625,7 @@ RPC_SENSORS: Final = {
|
||||
"total_aprt_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="total_aprt_power",
|
||||
name="Total apparent power",
|
||||
name="Apparent power",
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -882,7 +880,7 @@ RPC_SENSORS: Final = {
|
||||
"n_current": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="n_current",
|
||||
name="Phase N current",
|
||||
name="Neutral current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -892,7 +890,7 @@ RPC_SENSORS: Final = {
|
||||
"total_current": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="total_current",
|
||||
name="Total current",
|
||||
name="Current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -1384,7 +1382,7 @@ RPC_SENSORS: Final = {
|
||||
native_unit_of_measurement="pulse",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda status, _: status["total"],
|
||||
removal_condition=lambda config, _status, key: (
|
||||
removal_condition=lambda config, _, key: (
|
||||
config[key]["type"] != "count" or config[key]["enable"] is False
|
||||
),
|
||||
),
|
||||
@@ -1424,7 +1422,7 @@ RPC_SENSORS: Final = {
|
||||
"text_generic": RpcSensorDescription(
|
||||
key="text",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||
config, key, SENSOR_PLATFORM
|
||||
),
|
||||
role="generic",
|
||||
@@ -1432,7 +1430,7 @@ RPC_SENSORS: Final = {
|
||||
"number_generic": RpcSensorDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||
config, key, SENSOR_PLATFORM
|
||||
),
|
||||
unit=get_virtual_component_unit,
|
||||
@@ -1441,7 +1439,7 @@ RPC_SENSORS: Final = {
|
||||
"enum_generic": RpcSensorDescription(
|
||||
key="enum",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||
config, key, SENSOR_PLATFORM
|
||||
),
|
||||
options_fn=lambda config: config["options"],
|
||||
@@ -1456,7 +1454,7 @@ RPC_SENSORS: Final = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
removal_condition=lambda config, _status, key: config[key].get("enable", False)
|
||||
removal_condition=lambda config, _, key: config[key].get("enable", False)
|
||||
is False,
|
||||
entity_class=RpcBluTrvSensor,
|
||||
),
|
||||
@@ -1606,7 +1604,7 @@ RPC_SENSORS: Final = {
|
||||
"object_total_act_energy": RpcSensorDescription(
|
||||
key="object",
|
||||
sub_key="value",
|
||||
name="Total Active Energy",
|
||||
name="Energy",
|
||||
value=lambda status, _: float(status["total_act_energy"]),
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
@@ -1618,7 +1616,7 @@ RPC_SENSORS: Final = {
|
||||
"object_total_power": RpcSensorDescription(
|
||||
key="object",
|
||||
sub_key="value",
|
||||
name="Total Power",
|
||||
name="Power",
|
||||
value=lambda status, _: float(status["total_power"]),
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
@@ -1663,39 +1661,6 @@ RPC_SENSORS: Final = {
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_unique_ids(
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
entity_entry: er.RegistryEntry,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Migrate sensor unique IDs to include role."""
|
||||
if not entity_entry.entity_id.startswith("sensor."):
|
||||
return None
|
||||
|
||||
for sensor_id in ("text", "number", "enum"):
|
||||
old_unique_id = entity_entry.unique_id
|
||||
if old_unique_id.endswith(f"-{sensor_id}"):
|
||||
if entity_entry.original_device_class == SensorDeviceClass.HUMIDITY:
|
||||
new_unique_id = f"{old_unique_id}_current_humidity"
|
||||
elif entity_entry.original_device_class == SensorDeviceClass.TEMPERATURE:
|
||||
new_unique_id = f"{old_unique_id}_current_temperature"
|
||||
else:
|
||||
new_unique_id = f"{old_unique_id}_generic"
|
||||
LOGGER.debug(
|
||||
"Migrating unique_id for %s entity from [%s] to [%s]",
|
||||
entity_entry.entity_id,
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
old_unique_id, new_unique_id
|
||||
)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ShellyConfigEntry,
|
||||
@@ -1715,12 +1680,6 @@ async def async_setup_entry(
|
||||
coordinator = config_entry.runtime_data.rpc
|
||||
assert coordinator
|
||||
|
||||
await er.async_migrate_entries(
|
||||
hass,
|
||||
config_entry.entry_id,
|
||||
partial(async_migrate_unique_ids, coordinator),
|
||||
)
|
||||
|
||||
async_setup_entry_rpc(
|
||||
hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
|
||||
)
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
@@ -21,6 +21,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import (
|
||||
MODEL_FRANKEVER_IRRIGATION_CONTROLLER,
|
||||
MODEL_LINKEDGO_ST802_THERMOSTAT,
|
||||
MODEL_LINKEDGO_ST1820_THERMOSTAT,
|
||||
MODEL_NEO_WATER_VALVE,
|
||||
MODEL_TOP_EV_CHARGER_EVE01,
|
||||
)
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -30,6 +37,7 @@ from .entity import (
|
||||
ShellySleepingBlockAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_rpc,
|
||||
rpc_call,
|
||||
)
|
||||
from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
@@ -71,7 +79,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription):
|
||||
is_on: Callable[[dict[str, Any]], bool]
|
||||
method_on: str
|
||||
method_off: str
|
||||
method_params_fn: Callable[[int | None, bool], dict]
|
||||
method_params_fn: Callable[[int | None, bool], tuple]
|
||||
|
||||
|
||||
RPC_RELAY_SWITCHES = {
|
||||
@@ -80,31 +88,145 @@ RPC_RELAY_SWITCHES = {
|
||||
sub_key="output",
|
||||
removal_condition=is_rpc_exclude_from_relay,
|
||||
is_on=lambda status: bool(status["output"]),
|
||||
method_on="Switch.Set",
|
||||
method_off="Switch.Set",
|
||||
method_params_fn=lambda id, value: {"id": id, "on": value},
|
||||
method_on="switch_set",
|
||||
method_off="switch_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
),
|
||||
}
|
||||
|
||||
RPC_SWITCHES = {
|
||||
"boolean": RpcSwitchDescription(
|
||||
"boolean_generic": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
config, key, SWITCH_PLATFORM
|
||||
),
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="Boolean.Set",
|
||||
method_off="Boolean.Set",
|
||||
method_params_fn=lambda id, value: {"id": id, "value": value},
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="generic",
|
||||
),
|
||||
"boolean_anti_freeze": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="anti_freeze",
|
||||
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
|
||||
),
|
||||
"boolean_child_lock": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="child_lock",
|
||||
models={MODEL_LINKEDGO_ST1820_THERMOSTAT},
|
||||
),
|
||||
"boolean_enable": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="enable",
|
||||
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
|
||||
),
|
||||
"boolean_start_charging": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="start_charging",
|
||||
models={MODEL_TOP_EV_CHARGER_EVE01},
|
||||
),
|
||||
"boolean_state": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="state",
|
||||
models={MODEL_NEO_WATER_VALVE},
|
||||
),
|
||||
"boolean_zone0": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone0",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone1": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone1",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone2": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone2",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone3": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone3",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone4": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone4",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone5": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
method_params_fn=lambda id, value: (id, value),
|
||||
role="zone5",
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"script": RpcSwitchDescription(
|
||||
key="script",
|
||||
sub_key="running",
|
||||
is_on=lambda status: bool(status["running"]),
|
||||
method_on="Script.Start",
|
||||
method_off="Script.Stop",
|
||||
method_params_fn=lambda id, _: {"id": id},
|
||||
method_on="script_start",
|
||||
method_off="script_stop",
|
||||
method_params_fn=lambda id, _: (id,),
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
@@ -301,19 +423,27 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity):
|
||||
"""If switch is on."""
|
||||
return self.entity_description.is_on(self.status)
|
||||
|
||||
@rpc_call
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on relay."""
|
||||
await self.call_rpc(
|
||||
self.entity_description.method_on,
|
||||
self.entity_description.method_params_fn(self._id, True),
|
||||
)
|
||||
"""Turn on switch."""
|
||||
method = getattr(self.coordinator.device, self.entity_description.method_on)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert method is not None
|
||||
|
||||
params = self.entity_description.method_params_fn(self._id, True)
|
||||
await method(*params)
|
||||
|
||||
@rpc_call
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off relay."""
|
||||
await self.call_rpc(
|
||||
self.entity_description.method_off,
|
||||
self.entity_description.method_params_fn(self._id, False),
|
||||
)
|
||||
"""Turn off switch."""
|
||||
method = getattr(self.coordinator.device, self.entity_description.method_off)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert method is not None
|
||||
|
||||
params = self.entity_description.method_params_fn(self._id, False)
|
||||
await method(*params)
|
||||
|
||||
|
||||
class RpcRelaySwitch(RpcSwitch):
|
||||
|
@@ -38,12 +38,13 @@ class RpcTextDescription(RpcEntityDescription, TextEntityDescription):
|
||||
|
||||
|
||||
RPC_TEXT_ENTITIES: Final = {
|
||||
"text": RpcTextDescription(
|
||||
"text_generic": RpcTextDescription(
|
||||
key="text",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
config, key, TEXT_PLATFORM
|
||||
),
|
||||
role="generic",
|
||||
),
|
||||
}
|
||||
|
||||
|
@@ -484,6 +484,11 @@ def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_rpc_role_by_key(keys_dict: dict[str, Any], key: str) -> str:
|
||||
"""Return role by key for RPC device from a dict."""
|
||||
return cast(str, keys_dict[key].get("role", "generic"))
|
||||
|
||||
|
||||
def id_from_key(key: str) -> int:
|
||||
"""Return id from key."""
|
||||
return int(key.split(":")[-1])
|
||||
@@ -934,3 +939,35 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
def format_ble_addr(ble_addr: str) -> str:
|
||||
"""Format BLE address to use in unique_id."""
|
||||
return ble_addr.replace(":", "").upper()
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_rpc_virtual_components_unique_ids(
|
||||
config: dict[str, Any], entity_entry: er.RegistryEntry
|
||||
) -> dict[str, Any] | None:
|
||||
"""Migrate RPC virtual components unique_ids to include role in the ID.
|
||||
|
||||
This is needed to support multiple components with the same key.
|
||||
The old unique_id format is: {mac}-{key}-{component}
|
||||
The new unique_id format is: {mac}-{key}-{component}_{role}
|
||||
"""
|
||||
for component in VIRTUAL_COMPONENTS:
|
||||
if entity_entry.unique_id.endswith(f"-{component!s}"):
|
||||
key = entity_entry.unique_id.split("-")[-2]
|
||||
if key not in config:
|
||||
continue
|
||||
role = get_rpc_role_by_key(config, key)
|
||||
new_unique_id = f"{entity_entry.unique_id}_{role}"
|
||||
LOGGER.debug(
|
||||
"Migrating unique_id for %s entity from [%s] to [%s]",
|
||||
entity_entry.entity_id,
|
||||
entity_entry.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
entity_entry.unique_id, new_unique_id
|
||||
)
|
||||
}
|
||||
|
||||
return None
|
||||
|
@@ -179,6 +179,13 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
is_on_key="open",
|
||||
)
|
||||
},
|
||||
Capability.GAS_DETECTOR: {
|
||||
Attribute.GAS: SmartThingsBinarySensorEntityDescription(
|
||||
key=Attribute.GAS,
|
||||
device_class=BinarySensorDeviceClass.GAS,
|
||||
is_on_key="detected",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@@ -34,6 +34,17 @@
|
||||
"climate": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"wind_free": "mdi:weather-dust",
|
||||
"wind_free_sleep": "mdi:sleep",
|
||||
"quiet": "mdi:volume-off",
|
||||
"long_wind": "mdi:weather-windy",
|
||||
"smart": "mdi:leaf",
|
||||
"motion_direct": "mdi:account-arrow-left",
|
||||
"motion_indirect": "mdi:account-arrow-right"
|
||||
}
|
||||
},
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"turbo": "mdi:wind-power"
|
||||
|
@@ -530,7 +530,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
)
|
||||
],
|
||||
},
|
||||
# Haven't seen at devices yet
|
||||
Capability.ILLUMINANCE_MEASUREMENT: {
|
||||
Attribute.ILLUMINANCE: [
|
||||
SmartThingsSensorEntityDescription(
|
||||
@@ -842,7 +841,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
)
|
||||
]
|
||||
},
|
||||
# Haven't seen at devices yet
|
||||
Capability.SIGNAL_STRENGTH: {
|
||||
Attribute.LQI: [
|
||||
SmartThingsSensorEntityDescription(
|
||||
@@ -1001,7 +999,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
)
|
||||
],
|
||||
},
|
||||
# Haven't seen at devices yet
|
||||
Capability.TVOC_MEASUREMENT: {
|
||||
Attribute.TVOC_LEVEL: [
|
||||
SmartThingsSensorEntityDescription(
|
||||
@@ -1012,7 +1009,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
)
|
||||
]
|
||||
},
|
||||
# Haven't seen at devices yet
|
||||
Capability.ULTRAVIOLET_INDEX: {
|
||||
Attribute.ULTRAVIOLET_INDEX: [
|
||||
SmartThingsSensorEntityDescription(
|
||||
|
@@ -87,7 +87,7 @@
|
||||
"wind_free_sleep": "WindFree sleep",
|
||||
"quiet": "Quiet",
|
||||
"long_wind": "Long wind",
|
||||
"smart": "Smart",
|
||||
"smart": "Smart saver",
|
||||
"motion_direct": "Motion direct",
|
||||
"motion_indirect": "Motion indirect"
|
||||
}
|
||||
|
@@ -241,7 +241,6 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
|
||||
) -> StatisticMetaData:
|
||||
"""Build statistics metadata for requested configuration."""
|
||||
return StatisticMetaData(
|
||||
has_mean=False,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"Suez water {name} {self._counter_id}",
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["psutil"],
|
||||
"requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"],
|
||||
"requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -20,8 +20,9 @@ set_temperature:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
max: 250
|
||||
step: 0.5
|
||||
mode: box
|
||||
unit_of_measurement: "°"
|
||||
operation_mode:
|
||||
example: eco
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.81"]
|
||||
"requirements": ["holidays==0.82"]
|
||||
}
|
||||
|
@@ -744,8 +744,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Without confirmation, discovery can automatically progress into parts of the
|
||||
# config flow logic that interacts with hardware.
|
||||
# Ignore Zeroconf discoveries during onboarding, as they may be in use already.
|
||||
if user_input is not None or (
|
||||
not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
|
||||
not onboarding.async_is_onboarded(self.hass)
|
||||
and not zha_config_entries
|
||||
and self.source != SOURCE_ZEROCONF
|
||||
):
|
||||
# Probe the radio type if we don't have one yet
|
||||
if self._radio_mgr.radio_type is None:
|
||||
|
@@ -11,7 +11,13 @@ from typing import Any
|
||||
from propcache.api import cached_property
|
||||
from zha.mixins import LogMixin
|
||||
|
||||
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory
|
||||
from homeassistant.const import (
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NAME,
|
||||
ATTR_VIA_DEVICE,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import State, callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -85,14 +91,19 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
|
||||
ieee = zha_device_info["ieee"]
|
||||
zha_gateway = self.entity_data.device_proxy.gateway_proxy.gateway
|
||||
|
||||
return DeviceInfo(
|
||||
device_info = DeviceInfo(
|
||||
connections={(CONNECTION_ZIGBEE, ieee)},
|
||||
identifiers={(DOMAIN, ieee)},
|
||||
manufacturer=zha_device_info[ATTR_MANUFACTURER],
|
||||
model=zha_device_info[ATTR_MODEL],
|
||||
name=zha_device_info[ATTR_NAME],
|
||||
via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
|
||||
)
|
||||
if ieee != str(zha_gateway.state.node_info.ieee):
|
||||
device_info[ATTR_VIA_DEVICE] = (
|
||||
DOMAIN,
|
||||
str(zha_gateway.state.node_info.ieee),
|
||||
)
|
||||
return device_info
|
||||
|
||||
@callback
|
||||
def _handle_entity_events(self, event: Any) -> None:
|
||||
|
@@ -134,7 +134,6 @@ ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription]
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
(
|
||||
ENTITY_DESC_KEY_VOLTAGE,
|
||||
|
@@ -8,9 +8,7 @@ from typing import TYPE_CHECKING, Final
|
||||
|
||||
from .generated.entity_platforms import EntityPlatforms
|
||||
from .helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
DeprecatedConstantEnum,
|
||||
EnumWithDeprecatedMembers,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
@@ -316,60 +314,6 @@ STATE_OK: Final = "ok"
|
||||
STATE_PROBLEM: Final = "problem"
|
||||
|
||||
|
||||
# #### ALARM CONTROL PANEL STATES ####
|
||||
# STATE_ALARM_* below are deprecated as of 2024.11
|
||||
# use the AlarmControlPanelState enum instead.
|
||||
_DEPRECATED_STATE_ALARM_DISARMED: Final = DeprecatedConstant(
|
||||
"disarmed",
|
||||
"AlarmControlPanelState.DISARMED",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMED_HOME: Final = DeprecatedConstant(
|
||||
"armed_home",
|
||||
"AlarmControlPanelState.ARMED_HOME",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMED_AWAY: Final = DeprecatedConstant(
|
||||
"armed_away",
|
||||
"AlarmControlPanelState.ARMED_AWAY",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMED_NIGHT: Final = DeprecatedConstant(
|
||||
"armed_night",
|
||||
"AlarmControlPanelState.ARMED_NIGHT",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMED_VACATION: Final = DeprecatedConstant(
|
||||
"armed_vacation",
|
||||
"AlarmControlPanelState.ARMED_VACATION",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMED_CUSTOM_BYPASS: Final = DeprecatedConstant(
|
||||
"armed_custom_bypass",
|
||||
"AlarmControlPanelState.ARMED_CUSTOM_BYPASS",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_PENDING: Final = DeprecatedConstant(
|
||||
"pending",
|
||||
"AlarmControlPanelState.PENDING",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_ARMING: Final = DeprecatedConstant(
|
||||
"arming",
|
||||
"AlarmControlPanelState.ARMING",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_DISARMING: Final = DeprecatedConstant(
|
||||
"disarming",
|
||||
"AlarmControlPanelState.DISARMING",
|
||||
"2025.11",
|
||||
)
|
||||
_DEPRECATED_STATE_ALARM_TRIGGERED: Final = DeprecatedConstant(
|
||||
"triggered",
|
||||
"AlarmControlPanelState.TRIGGERED",
|
||||
"2025.11",
|
||||
)
|
||||
|
||||
# #### STATE AND EVENT ATTRIBUTES ####
|
||||
# Attribution
|
||||
ATTR_ATTRIBUTION: Final = "attribution"
|
||||
@@ -759,35 +703,13 @@ class UnitOfMass(StrEnum):
|
||||
STONES = "st"
|
||||
|
||||
|
||||
class UnitOfConductivity(
|
||||
StrEnum,
|
||||
metaclass=EnumWithDeprecatedMembers,
|
||||
deprecated={
|
||||
"SIEMENS": ("UnitOfConductivity.SIEMENS_PER_CM", "2025.11.0"),
|
||||
"MICROSIEMENS": ("UnitOfConductivity.MICROSIEMENS_PER_CM", "2025.11.0"),
|
||||
"MILLISIEMENS": ("UnitOfConductivity.MILLISIEMENS_PER_CM", "2025.11.0"),
|
||||
},
|
||||
):
|
||||
class UnitOfConductivity(StrEnum):
|
||||
"""Conductivity units."""
|
||||
|
||||
SIEMENS_PER_CM = "S/cm"
|
||||
MICROSIEMENS_PER_CM = "μS/cm"
|
||||
MILLISIEMENS_PER_CM = "mS/cm"
|
||||
|
||||
# Deprecated aliases
|
||||
SIEMENS = "S/cm"
|
||||
"""Deprecated: Please use UnitOfConductivity.SIEMENS_PER_CM"""
|
||||
MICROSIEMENS = "μS/cm"
|
||||
"""Deprecated: Please use UnitOfConductivity.MICROSIEMENS_PER_CM"""
|
||||
MILLISIEMENS = "mS/cm"
|
||||
"""Deprecated: Please use UnitOfConductivity.MILLISIEMENS_PER_CM"""
|
||||
|
||||
|
||||
_DEPRECATED_CONDUCTIVITY: Final = DeprecatedConstantEnum(
|
||||
UnitOfConductivity.MICROSIEMENS_PER_CM,
|
||||
"2025.11",
|
||||
)
|
||||
"""Deprecated: please use UnitOfConductivity.MICROSIEMENS_PER_CM"""
|
||||
|
||||
# Light units
|
||||
LIGHT_LUX: Final = "lx"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user