Compare commits

..

69 Commits

Author SHA1 Message Date
Wendelin
39d970347e Refactor input_weekday configuration validation and update test cases to use unique_id 2025-10-09 12:00:35 +02:00
Wendelin
9cccc96f63 Fix test 2025-10-09 10:35:03 +02:00
Wendelin
a32ada3155 Use input_weekday in automations 2025-10-09 08:48:41 +02:00
Wendelin
77f078e57d Add weekdays to build-in helpers 2025-10-08 17:23:19 +02:00
Wendelin
8657bfd0bf Add input helper weekdays 2025-10-08 17:08:48 +02:00
Joost Lekkerkerker
fe4eb8766d Don't mark ZHA coordinator as via_device with itself (#154004) 2025-10-08 16:05:54 +02:00
Mark Adkins
2d9f14c401 Add 3rd maintainer to sharkiq (#153961) 2025-10-08 15:17:52 +02:00
dependabot[bot]
7b6ccb07fd Bump github/codeql-action from 3.30.6 to 4.30.7 (#153979)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 13:42:25 +02:00
Shay Levy
2ba5728060 Enable Shelly binary input sensors by default (#154001) 2025-10-08 14:41:53 +03:00
epenet
b5f163cc85 Update Tuya fixture for product ID IAYz2WK1th0cMLmL (#154000) 2025-10-08 13:28:11 +02:00
Marc Mueller
65540a3e0b Update mypy dev to 1.19.0a4 (#153995) 2025-10-08 13:24:54 +02:00
Erwin Douna
cbf1b39edb Portainer add sensor platform (#153059)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2025-10-08 11:02:20 +02:00
G Johansson
142daf5e49 Call async_track_template_result with template without hass now fails (#153473)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-08 10:14:51 +02:00
Erik Montnemery
8bd0ff7cca Replace has_mean with mean_type in mill external statistics (#153985) 2025-10-08 09:52:07 +02:00
Erik Montnemery
ac676e12f6 Remove has_mean from suez_water external statistics (#153986) 2025-10-08 09:51:44 +02:00
Glenn Vandeuren (aka Iondependent)
c0ac3292cd FIx brightness always 100% when toggling the light (#153765)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-10-08 09:48:41 +02:00
Denis Shulyaka
80fd07c128 Add GPT-5 Pro and GPT-5 Codex support (#153936) 2025-10-08 09:48:07 +02:00
Michael Davie
3701d8859a Bump env-canada to 0.11.3 (#153967) 2025-10-08 09:40:55 +02:00
Jesse Hills
6dd26bae88 Bump aioesphomeapi to 41.13.0 (#153974) 2025-10-07 18:28:56 -10:00
Dave T
1a0abe296c Remove deprecated conductivity constants (#153942) 2025-10-07 23:20:36 +01:00
G Johansson
de6c61a4ab Bump psutil 7.1.0 (#153954) 2025-10-07 23:16:49 +01:00
Glenn Vandeuren (aka Iondependent)
33c677596e Update nhc to 0.6.1 (#153962) 2025-10-07 23:16:04 +01:00
peetersch
e9b4b8e99b Modbus Fix message_wait_milliseconds is no longer applied (#153709) 2025-10-07 23:38:05 +02:00
Maciej Bieniek
0525c04c42 Fix update interval for AccuWeather hourly forecast (#153957) 2025-10-07 23:25:04 +02:00
Shay Levy
d57b502551 Migrate Shelly virtual button platfrom unique IDs to include roles (#153865) 2025-10-07 23:01:30 +03:00
G Johansson
9fb708baf4 Bump holidays to 0.82 (#153952) 2025-10-07 23:00:38 +03:00
Josef Zweck
abdf24b7a0 Bump pylamarzocco to 2.1.2 (#153950) 2025-10-07 22:07:39 +03:00
TheJulianJES
29bfbd27bb Do not auto-set up ZHA zeroconf discoveries during onboarding (#153914) 2025-10-07 15:02:02 -04:00
starkillerOG
224553f8d9 Reverse Motion Blinds tilt direction (#149777)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-07 18:50:39 +01:00
mbo18
7c9f6a061f Add icons for SmartThings climate presets (#153929) 2025-10-07 19:15:15 +02:00
Marc Mueller
8e115d4685 Update pydantic to 2.12.0 (#153937) 2025-10-07 17:50:40 +01:00
Denis Shulyaka
00c189844f Bump openai to 2.2.0 (#153926) 2025-10-07 17:41:52 +01:00
Ståle Storø Hauknes
4587c286bb Add new sensors for Airthings Wave Enhance (#153879) 2025-10-07 17:44:30 +02:00
Artur Pragacz
b46097a7fc Move agent functionality from http (#153917) 2025-10-07 14:49:11 +02:00
mbo18
299cb6a2ff Change smart preset name to smart saver (#153916) 2025-10-07 14:11:00 +02:00
Erik Montnemery
1b7b91b328 Remove unused test fixtures from nintendo_parental (#153894) 2025-10-07 14:03:29 +02:00
Maciej Bieniek
01a1480ebd Use aioshelly methods for switches (#153746) 2025-10-07 13:28:58 +02:00
Jordan Harvey
26b8abb118 Bump pynintendoparental to 1.1.1 (#153874) 2025-10-07 13:28:08 +02:00
FMKaiba
53d1bbb530 Add support for gas detector status to SmartThings (#153831)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-10-07 12:56:53 +02:00
Tom Matheussen
a3ef55274e Add missing translation string for Satel Integra subentry type (#153905) 2025-10-07 12:18:51 +02:00
Joost Lekkerkerker
2034915457 Add fixture to SmartThings (#153902) 2025-10-07 12:13:12 +02:00
Joost Lekkerkerker
9e46d7964a Update SmartThings comments (#153903) 2025-10-07 11:46:44 +02:00
Maciej Bieniek
f9828a227b Bump aioshelly to version 13.12.0 (#153899) 2025-10-07 11:43:56 +02:00
Simone Chemelli
3341fa5f33 Code optimization for Comelit SimpleHome (#153029) 2025-10-07 10:31:44 +01:00
Christopher Fenner
e38ae47e76 Add language and location selector to OpenWeatherMap config flow (#153645)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-07 11:06:04 +02:00
Christopher Fenner
934c0e3c4c fix typo in icon assignment of AccuWeather integration (#153890) 2025-10-07 10:15:01 +02:00
Simone Chemelli
994a6ae7ed Fix restore cover state for Comelit SimpleHome (#153887) 2025-10-07 09:06:55 +02:00
Christopher Fenner
cdbe93c289 Set display precision for sensors in OpenWeatherMap integration (#153858) 2025-10-07 08:58:18 +02:00
Marc Mueller
56f90e4d96 Update pytest warnings filter (#153881) 2025-10-07 09:55:50 +03:00
TheJulianJES
34977abfec Remove Z-Wave JS voltage sensor overriding suggested precision (#153882) 2025-10-07 08:44:53 +02:00
Marc Mueller
5622103eb1 Fix nintendo_parental RuntimeWarning in tests (#153884) 2025-10-07 08:44:34 +02:00
Artur Pragacz
b9a1ab4a44 Clean up core references in conversation (#153880) 2025-10-07 00:46:47 +02:00
David Rapan
18997833c4 Shelly's power sensors naming paradigm standardization (#153822)
Signed-off-by: David Rapan <david@rapan.cz>
2025-10-07 01:32:44 +03:00
David Rapan
f99b194afc Shelly's current sensors naming paradigm standardization (#153827)
Signed-off-by: David Rapan <david@rapan.cz>
2025-10-07 01:32:25 +03:00
Dave T
566a347da7 Remove deprecated alarm panel constants (#153876) 2025-10-06 23:03:29 +01:00
Shay Levy
881306f6a4 Migrate Shelly virtual component unique IDs to include roles (#153844) 2025-10-07 00:50:47 +03:00
Marc Mueller
f63504af01 Update aiohttp to 3.13.0 (#153875) 2025-10-06 15:47:33 -05:00
derytive
d140b82a70 Add plate_count for Miele KM7575 (#153868) 2025-10-06 21:53:09 +02:00
Allen Porter
681211b1a5 Add Model Context Protocol support for OAuth scopes (#153150) 2025-10-06 15:32:42 -04:00
Joost Lekkerkerker
6c8b1f3618 Catch update exception in AirGradient (#153828) 2025-10-06 21:31:55 +02:00
Abílio Costa
d341065c34 Replace inner function with lambda in Idasen Desk (#153862) 2025-10-06 21:25:10 +02:00
G Johansson
81b1346080 Handle timeout errors gracefully in Nord Pool services (#153856) 2025-10-06 22:15:38 +03:00
J. Nick Koston
5613be3980 Bump yarl to 1.22.0 (#153860) 2025-10-06 13:43:37 -05:00
Alec
fbcf0eb94c Increase connect and configuration time for rfxtrx (#153834)
Increase the allowed time for connection and configuration. Some devices take a long time to respond to configuration changes and this time is counted for both network and configuration of the device.
2025-10-06 20:25:44 +02:00
Felipe Santos
1c7b9cc354 Avoid storing entities list in ONVIF binary_sensor and sensor (#153857) 2025-10-06 19:52:24 +02:00
William Scanlon
75e900606e Update water heater max temperature (#150970) 2025-10-06 19:21:21 +02:00
Norbert Rittel
7c665c53b5 Change translation of box in number to "Input field" for consistency (#153850) 2025-10-06 19:07:48 +02:00
Jordan Harvey
f72047eb02 Add new Nintendo Parental Controls integration (#145343)
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-06 18:36:46 +02:00
Marc Mueller
ade424c074 Update attrs to 25.4.0 (#153849) 2025-10-06 17:54:19 +02:00
173 changed files with 4872 additions and 1258 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -231,6 +231,7 @@ DEFAULT_INTEGRATIONS = {
"input_datetime",
"input_number",
"input_select",
"input_weekday",
"input_text",
"schedule",
"timer",

View File

@@ -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)

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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

View File

@@ -41,6 +41,9 @@
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"ambient_noise": {
"name": "Ambient noise"
}
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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"]
}

View File

@@ -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"
],

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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:

View 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()

View 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"
}
}
}

View 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"
}

View 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
)

View 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:

View 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."
}
}
}

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.1"]
"requirements": ["pylamarzocco==2.1.2"]
}

View File

@@ -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:

View File

@@ -5,3 +5,4 @@ DOMAIN = "mcp"
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"

View File

@@ -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()

View File

@@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_PLATE_COUNT = 4
PLATE_COUNT = {
"KM7575": 6,
"KM7678": 6,
"KM7697": 6,
"KM7878": 6,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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%]"
}

View File

@@ -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."""

View File

@@ -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"]
}

View 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)

View 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,
)

View 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"

View 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

View 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()

View 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"]
}

View File

@@ -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

View 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)

View 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."
}
}
}

View File

@@ -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",

View File

@@ -22,7 +22,7 @@
"name": "Mode",
"state": {
"auto": "Automatic",
"box": "Box",
"box": "Input field",
"slider": "Slider"
}
},

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]
}

View File

@@ -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(

View File

@@ -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
),

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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]

View File

@@ -1,5 +1,10 @@
{
"entity": {
"sensor": {
"image": {
"default": "mdi:docker"
}
},
"switch": {
"container": {
"default": "mdi:arrow-down-box",

View 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]
)

View File

@@ -46,6 +46,11 @@
"name": "Status"
}
},
"sensor": {
"image": {
"name": "Image"
}
},
"switch": {
"container": {
"name": "Container"

View File

@@ -48,7 +48,7 @@ from .const import (
DEFAULT_OFF_DELAY = 2.0
CONNECT_TIMEOUT = 30.0
CONNECT_TIMEOUT = 60.0
_LOGGER = logging.getLogger(__name__)

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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)

View File

@@ -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",

View File

@@ -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")

View File

@@ -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"

View File

@@ -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

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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",
),
}

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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",
),
}

View File

@@ -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

View File

@@ -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",
)
},
}

View File

@@ -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"

View File

@@ -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(

View File

@@ -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"
}

View File

@@ -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}",

View File

@@ -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
}

View File

@@ -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

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.81"]
"requirements": ["holidays==0.82"]
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,

View File

@@ -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