Compare commits

..

1 Commits

Author SHA1 Message Date
jbouwh
96c111c96c Add MQTT select subentry support 2025-10-06 15:39:21 +00:00
182 changed files with 1424 additions and 5094 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@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
category: "/language:python"

6
CODEOWNERS generated
View File

@@ -1065,8 +1065,6 @@ 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
@@ -1413,8 +1411,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 @TheOneOgre
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco

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(minutes=30)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)

View File

@@ -1,9 +1,6 @@
{
"entity": {
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
},
"cloud_ceiling": {
"default": "mdi:weather-fog"
},
@@ -37,6 +34,9 @@
"thunderstorm_probability_night": {
"default": "mdi:weather-lightning"
},
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": {
"default": "mdi:tree-outline"
},

View File

@@ -1,9 +1,7 @@
"""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
@@ -15,7 +13,6 @@ from .entity import AirGradientEntity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@@ -34,7 +31,6 @@ 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."""
@@ -51,27 +47,10 @@ 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."""
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,12 +16,10 @@ 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
@@ -114,21 +112,6 @@ 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,9 +41,6 @@
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"ambient_noise": {
"name": "Ambient noise"
}
}
}

View File

@@ -7,8 +7,6 @@ from typing import Any
from pyaprilaire.const import Attribute
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
FAN_ON,
PRESET_AWAY,
@@ -18,12 +16,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
UnitOfTemperature,
)
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -239,15 +232,15 @@ class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
cool_setpoint = 0
heat_setpoint = 0
if temperature := kwargs.get(ATTR_TEMPERATURE):
if temperature := kwargs.get("temperature"):
if self.coordinator.data.get(Attribute.MODE) == 3:
cool_setpoint = temperature
else:
heat_setpoint = temperature
else:
if target_temp_low := kwargs.get(ATTR_TARGET_TEMP_LOW):
if target_temp_low := kwargs.get("target_temp_low"):
heat_setpoint = target_temp_low
if target_temp_high := kwargs.get(ATTR_TARGET_TEMP_HIGH):
if target_temp_high := kwargs.get("target_temp_high"):
cool_setpoint = target_temp_high
if cool_setpoint == 0 and heat_setpoint == 0:

View File

@@ -7,14 +7,12 @@ from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -210,24 +208,24 @@ class BryantEvolutionClimate(ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if value := kwargs.get(ATTR_TARGET_TEMP_HIGH):
temp = int(value)
if kwargs.get("target_temp_high"):
temp = int(kwargs["target_temp_high"])
if not await self._client.set_cooling_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
)
self._attr_target_temperature_high = temp
if value := kwargs.get(ATTR_TARGET_TEMP_LOW):
temp = int(value)
if kwargs.get("target_temp_low"):
temp = int(kwargs["target_temp_low"])
if not await self._client.set_heating_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
)
self._attr_target_temperature_low = temp
if value := kwargs.get(ATTR_TEMPERATURE):
temp = int(value)
if kwargs.get("temperature"):
temp = int(kwargs["temperature"])
fn = (
self._client.set_heating_setpoint
if self.hvac_mode == HVACMode.HEAT

View File

@@ -169,7 +169,7 @@ class CalendarEventListener:
def __init__(
self,
hass: HomeAssistant,
job: HassJob[..., Coroutine[Any, Any, None] | Any],
job: HassJob[..., Coroutine[Any, Any, None]],
trigger_data: dict[str, Any],
fetcher: QueuedEventFetcher,
) -> None:

View File

@@ -15,7 +15,6 @@ 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
@@ -30,19 +29,23 @@ async def async_setup_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
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)
known_devices: set[int] = set()
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
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))
class ComelitVedoBinarySensorEntity(

View File

@@ -7,21 +7,14 @@ 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 (
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
CoverDeviceClass,
CoverEntity,
)
from homeassistant.components.cover import 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 DeviceType, bridge_api_call, new_device_listener
from .utils import bridge_api_call
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -36,20 +29,22 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
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)
known_devices: set[int] = set()
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, COVER)
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))
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
"""Cover device."""
@@ -67,6 +62,7 @@ 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."""
@@ -102,6 +98,7 @@ 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()
@@ -127,10 +124,5 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
await super().async_added_to_hass()
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
if last_state := await self.async_get_last_state():
self._last_state = last_state.state

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 DeviceType, bridge_api_call, new_device_listener
from .utils import bridge_api_call
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -27,20 +27,22 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
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)
known_devices: set[int] = set()
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, LIGHT)
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))
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
"""Light device."""

View File

@@ -20,7 +20,6 @@ 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
@@ -66,23 +65,25 @@ async def async_setup_bridge_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
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[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, OTHER)
for device in coordinator.data[OTHER].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
async def async_setup_vedo_entry(
hass: HomeAssistant,
@@ -93,23 +94,25 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
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 in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
"""Sensor device."""

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 DeviceType, bridge_api_call, new_device_listener
from .utils import bridge_api_call
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -28,21 +28,36 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
entities: list[ComelitSwitchEntity] = []
entities.extend(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
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)
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):
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, dev_type)
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))
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device."""

View File

@@ -4,11 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aiocomelit.api import (
ComelitSerialBridgeObject,
ComelitVedoAreaObject,
ComelitVedoZoneObject,
)
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
@@ -23,11 +19,8 @@ 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."""
@@ -120,41 +113,3 @@ 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,30 +38,22 @@ 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,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.core import Event, 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,
@@ -200,7 +192,7 @@ class IntentCache:
async def async_setup_default_agent(
hass: HomeAssistant,
hass: core.HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None:
@@ -209,13 +201,15 @@ async def async_setup_default_agent(
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@callback
def async_entity_state_listener(event: Event[EventStateChangedData]) -> None:
@core.callback
def async_entity_state_listener(
event: core.Event[core.EventStateChangedData],
) -> None:
"""Set expose flag on new entities."""
async_should_expose(hass, DOMAIN, event.data["entity_id"])
@callback
def async_hass_started(hass: HomeAssistant) -> None:
@core.callback
def async_hass_started(hass: core.HomeAssistant) -> None:
"""Set expose flag on all entities."""
for state in hass.states.async_all():
async_should_expose(hass, DOMAIN, state.entity_id)
@@ -230,7 +224,9 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
def __init__(
self, hass: core.HomeAssistant, config_intents: dict[str, Any]
) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
@@ -263,7 +259,7 @@ class DefaultAgent(ConversationEntity):
"""Return a list of supported languages."""
return get_languages()
@callback
@core.callback
def _filter_entity_registry_changes(
self, event_data: er.EventEntityRegistryUpdatedData
) -> bool:
@@ -272,12 +268,12 @@ class DefaultAgent(ConversationEntity):
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
)
@callback
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
@core.callback
def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
"""Filter state changed events."""
return not event_data["old_state"] or not event_data["new_state"]
@callback
@core.callback
def _listen_clear_slot_list(self) -> None:
"""Listen for changes that can invalidate slot list."""
assert self._unsub_clear_slot_list is None
@@ -346,81 +342,6 @@ 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,
@@ -969,7 +890,7 @@ class DefaultAgent(ConversationEntity):
) -> str:
# Get first matched or unmatched state.
# This is available in the response template as "state".
state1: State | None = None
state1: core.State | None = None
if intent_response.matched_states:
state1 = intent_response.matched_states[0]
elif intent_response.unmatched_states:
@@ -1607,10 +1528,6 @@ 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,
@@ -1672,7 +1589,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
def _get_match_error_response(
hass: HomeAssistant,
hass: core.HomeAssistant,
match_error: intent.MatchFailedError,
) -> tuple[ErrorKey, dict[str, Any]]:
"""Return key and template arguments for error when target matching fails."""
@@ -1807,75 +1724,3 @@ 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,16 +2,21 @@
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, callback
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.util import language as language_util
from .agent_manager import (
@@ -21,6 +26,11 @@ 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
@@ -196,12 +206,150 @@ async def websocket_hass_agent_debug(
language=msg.get("language", hass.config.language),
agent_id=agent.entity_id,
)
result_dict = await agent.async_debug_recognize(user_input)
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_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",
@@ -216,13 +364,10 @@ 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 agent.async_get_language_scores()
scores = await hass.async_add_executor_job(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.3"]
"requirements": ["env-canada==0.11.2"]
}

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.13.0",
"aioesphomeapi==41.12.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -29,12 +29,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import (
ATTR_MODE,
ATTR_TEMPERATURE,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -248,7 +243,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature."""
temperature = kwargs[ATTR_TEMPERATURE]
temperature = kwargs["temperature"]
if (until := kwargs.get("until")) is None:
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.82", "babel==2.15.0"]
"requirements": ["holidays==0.81", "babel==2.15.0"]
}

View File

@@ -456,7 +456,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
return self._available
@ha_callback
@pyhap_callback # type: ignore[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
@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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
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[untyped-decorator]
@pyhap_callback # type: ignore[misc]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -108,7 +108,7 @@ class DeviceTriggerAccessory(HomeAccessory):
_LOGGER.log,
)
@pyhap_callback # type: ignore[untyped-decorator]
@pyhap_callback # type: ignore[misc]
@callback
def run(self) -> None:
"""Run the accessory."""

View File

@@ -41,12 +41,16 @@ 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=callback(lambda: self.async_set_updated_data(self._height)),
function=async_update_data,
)
async def async_connect(self) -> bool:

View File

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

View File

@@ -1,36 +1 @@
"""The london_underground component."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN as DOMAIN
from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry, TubeData
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: LondonUndergroundConfigEntry
) -> bool:
"""Set up London Underground from a config entry."""
session = async_get_clientsession(hass)
data = TubeData(session)
coordinator = LondonTubeCoordinator(hass, data, config_entry=entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Forward the setup to the sensor platform
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: LondonUndergroundConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,152 +0,0 @@
"""Config flow for London Underground integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from london_tube_status import TubeData
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_LINE, DEFAULT_LINES, DOMAIN, TUBE_LINES
_LOGGER = logging.getLogger(__name__)
class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for London Underground."""
VERSION = 1
MINOR_VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
_: ConfigEntry,
) -> LondonUndergroundOptionsFlow:
"""Get the options flow for this handler."""
return LondonUndergroundOptionsFlow()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
data = TubeData(session)
try:
async with asyncio.timeout(10):
await data.update()
except TimeoutError:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected error")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="London Underground",
data={},
options={CONF_LINE: user_input.get(CONF_LINE, DEFAULT_LINES)},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LINE,
default=DEFAULT_LINES,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=TUBE_LINES,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult:
"""Handle import from configuration.yaml."""
session = async_get_clientsession(self.hass)
data = TubeData(session)
try:
async with asyncio.timeout(10):
await data.update()
except Exception:
_LOGGER.exception(
"Unexpected error trying to connect before importing config, aborting import "
)
return self.async_abort(reason="cannot_connect")
_LOGGER.warning(
"Importing London Underground config from configuration.yaml: %s",
import_data,
)
# Extract lines from the sensor platform config
lines = import_data.get(CONF_LINE, DEFAULT_LINES)
if "London Overground" in lines:
_LOGGER.warning(
"London Overground was removed from the configuration as the line has been divided and renamed"
)
lines.remove("London Overground")
return self.async_create_entry(
title="London Underground",
data={},
options={CONF_LINE: import_data.get(CONF_LINE, DEFAULT_LINES)},
)
class LondonUndergroundOptionsFlow(OptionsFlowWithReload):
"""Handle options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug(
"Updating london underground with options flow user_input: %s",
user_input,
)
return self.async_create_entry(
title="",
data={CONF_LINE: user_input[CONF_LINE]},
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LINE,
default=self.config_entry.options.get(
CONF_LINE,
self.config_entry.data.get(CONF_LINE, DEFAULT_LINES),
),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=TUBE_LINES,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
),
)

View File

@@ -6,6 +6,7 @@ DOMAIN = "london_underground"
CONF_LINE = "line"
SCAN_INTERVAL = timedelta(seconds=30)
TUBE_LINES = [
@@ -17,7 +18,7 @@ TUBE_LINES = [
"Elizabeth line",
"Hammersmith & City",
"Jubilee",
"London Overground", # no longer supported
"London Overground",
"Metropolitan",
"Northern",
"Piccadilly",
@@ -30,20 +31,3 @@ TUBE_LINES = [
"Weaver",
"Windrush",
]
# Default lines to monitor if none selected
DEFAULT_LINES = [
"Bakerloo",
"Central",
"Circle",
"District",
"DLR",
"Elizabeth line",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
]

View File

@@ -8,7 +8,6 @@ from typing import cast
from london_tube_status import TubeData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -16,23 +15,16 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type LondonUndergroundConfigEntry = ConfigEntry[LondonTubeCoordinator]
class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]):
"""London Underground sensor coordinator."""
def __init__(
self,
hass: HomeAssistant,
data: TubeData,
config_entry: LondonUndergroundConfigEntry,
) -> None:
def __init__(self, hass: HomeAssistant, data: TubeData) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
config_entry=None,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)

View File

@@ -2,12 +2,9 @@
"domain": "london_underground",
"name": "London Underground",
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/london_underground",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
"quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"],
"single_config_entry": true
"requirements": ["london-tube-status==0.5"]
}

View File

@@ -5,26 +5,23 @@ from __future__ import annotations
import logging
from typing import Any
from london_tube_status import TubeData
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_LINE, DOMAIN, TUBE_LINES
from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry
from .const import CONF_LINE, TUBE_LINES
from .coordinator import LondonTubeCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -41,54 +38,18 @@ async def async_setup_platform(
) -> None:
"""Set up the Tube sensor."""
# If configuration.yaml config exists, trigger the import flow.
# If the config entry already exists, this will not be triggered as only one config is allowed.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "London Underground",
},
)
return
session = async_get_clientsession(hass)
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "London Underground",
},
)
data = TubeData(session)
coordinator = LondonTubeCoordinator(hass, data)
await coordinator.async_refresh()
async def async_setup_entry(
hass: HomeAssistant,
entry: LondonUndergroundConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the London Underground sensor from config entry."""
if not coordinator.last_update_success:
raise PlatformNotReady
async_add_entities(
LondonTubeSensor(entry.runtime_data, line) for line in entry.options[CONF_LINE]
LondonTubeSensor(coordinator, line) for line in config[CONF_LINE]
)
@@ -97,21 +58,11 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity):
_attr_attribution = "Powered by TfL Open Data"
_attr_icon = "mdi:subway"
_attr_has_entity_name = True # Use modern entity naming
def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None:
"""Initialize the London Underground sensor."""
super().__init__(coordinator)
self._name = name
# Add unique_id for proper entity registry
self._attr_unique_id = f"tube_{name.lower().replace(' ', '_')}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, DOMAIN)},
name="London Underground",
manufacturer="Transport for London",
model="Tube Status",
entry_type=DeviceEntryType.SERVICE,
)
@property
def name(self) -> str:

View File

@@ -1,38 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up London Underground",
"description": "Select which tube lines you want to monitor",
"data": {
"line": "Tube lines"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"options": {
"step": {
"init": {
"title": "Configure London Underground",
"description": "[%key:component::london_underground::config::step::user::description%]",
"data": {
"line": "[%key:component::london_underground::config::step::user::data::line%]"
}
}
}
},
"issues": {
"deprecated_yaml_import_issue": {
"title": "London Underground YAML configuration deprecated",
"description": "Configuring London Underground using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an error occurred when trying to connect to the Transport for London API. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI."
}
}
}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any, cast
@@ -24,13 +23,7 @@ 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_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
@@ -48,17 +41,9 @@ 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
) -> OAuthConfig:
) -> AuthorizationServer:
"""Discover the OAuth configuration for the MCP server.
This implements the functionality in the MCP spec for discovery. If the MCP server URL
@@ -80,12 +65,10 @@ 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 OAuthConfig(
authorization_server=AuthorizationServer(
return 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:
_LOGGER.info("Cannot discover OAuth configuration: %s", error)
@@ -98,15 +81,9 @@ 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))
# 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(
return AuthorizationServer(
authorize_url=authorize_url,
token_url=token_url,
),
scopes=scopes,
)
@@ -153,7 +130,6 @@ 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
@@ -194,7 +170,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
to find the OAuth medata then run the OAuth authentication flow.
"""
try:
oauth_config = await async_discover_oauth_config(
authorization_server = await async_discover_oauth_config(
self.hass, self.data[CONF_URL]
)
except TimeoutConnectError:
@@ -205,13 +181,11 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
_LOGGER.info("OAuth configuration: %s", oauth_config)
self.oauth_config = oauth_config
_LOGGER.info("OAuth configuration: %s", authorization_server)
self.data.update(
{
CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url,
CONF_TOKEN_URL: oauth_config.authorization_server.token_url,
CONF_SCOPE: oauth_config.scopes,
CONF_AUTHORIZATION_URL: authorization_server.authorize_url,
CONF_TOKEN_URL: authorization_server.token_url,
}
)
return await self.async_step_credentials_choice()
@@ -223,15 +197,6 @@ 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,4 +5,3 @@ 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,untyped-decorator]
@server.list_prompts() # type: ignore[no-untyped-call, misc]
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,untyped-decorator]
@server.get_prompt() # type: ignore[no-untyped-call, misc]
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,untyped-decorator]
@server.list_tools() # type: ignore[no-untyped-call, misc]
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[untyped-decorator]
@server.call_tool() # type: ignore[misc]
async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
"""Handle calling tools."""
llm_api = await get_api_instance()

View File

@@ -408,5 +408,5 @@ class AtwDeviceZoneClimate(MelCloudClimate):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self._zone.set_target_temperature(
kwargs.get(ATTR_TEMPERATURE, self.target_temperature)
kwargs.get("temperature", self.target_temperature)
)

View File

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

View File

@@ -10,11 +10,7 @@ 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,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -151,7 +147,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
)
)
metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_mean=False,
has_sum=True,
name=f"{heater.name}",
source=DOMAIN,

View File

@@ -253,7 +253,6 @@ 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]
@@ -416,9 +415,7 @@ class ModbusHub:
"""Convert async to sync pymodbus call."""
if not self._client:
return None
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) -> None:
def __init__(self, coordinator, blind, device_class):
"""Initialize the blind."""
super().__init__(coordinator, blind)
@@ -275,7 +275,7 @@ class MotionTiltDevice(MotionPositionDevice):
"""
if self._blind.angle is None:
return None
return 100 - (self._blind.angle * 100 / 180)
return 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, 0)
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
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, 180)
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
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, 180 - angle)
await self.hass.async_add_executor_job(self._blind.Set_angle, 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 100 - (self._blind.angle * 100 / 180)
return self._blind.angle * 100 / 180
return 100 - self._blind.position
return 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 == 180
return self._blind.angle == 0
return self._blind.position == 100
return self._blind.position == 0
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
@@ -381,14 +381,10 @@ 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, 180 - angle
)
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
else:
async with self._api_lock:
await self.hass.async_add_executor_job(
self._blind.Set_position, 100 - angle
)
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.async_request_position_till_stop()
@@ -401,14 +397,10 @@ 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, 180 - angle
)
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
else:
async with self._api_lock:
await self.hass.async_add_executor_job(
self._blind.Set_position, 100 - angle
)
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.async_request_position_till_stop()
@@ -416,7 +408,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) -> None:
def __init__(self, coordinator, blind, device_class, motor):
"""Initialize the blind."""
super().__init__(coordinator, blind, device_class)
self._motor = motor

View File

@@ -458,6 +458,7 @@ SUBENTRY_PLATFORMS = [
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -1141,6 +1142,7 @@ 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,
}
@@ -1367,6 +1369,7 @@ 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
@@ -3103,6 +3106,34 @@ 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,6 +346,7 @@
"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\"",
@@ -393,6 +394,7 @@
"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.",
@@ -1334,6 +1336,7 @@
"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))
await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255))
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.6.1"]
"requirements": ["nhc==0.4.12"]
}

View File

@@ -1,51 +0,0 @@
"""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

@@ -1,61 +0,0 @@
"""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

@@ -1,5 +0,0 @@
"""Constants for the Nintendo Switch Parental Controls integration."""
DOMAIN = "nintendo_parental"
CONF_UPDATE_INTERVAL = "update_interval"
CONF_SESSION_TOKEN = "session_token"

View File

@@ -1,52 +0,0 @@
"""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

@@ -1,41 +0,0 @@
"""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

@@ -1,11 +0,0 @@
{
"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

@@ -1,81 +0,0 @@
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

@@ -1,91 +0,0 @@
"""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

@@ -1,38 +0,0 @@
{
"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, TimeoutError) as error:
except NordPoolError as error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connection_error",

View File

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

View File

@@ -31,39 +31,38 @@ async def async_setup_entry(
events = device.events.get_platform("binary_sensor")
entity_names = build_event_entity_names(events)
uids = set()
entities = []
for event in events:
uids.add(event.uid)
entities.append(
ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid])
)
entities = {
event.uid: ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid])
for event in events
}
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 uids:
uids.add(entry.unique_id)
entities.append(ONVIFBinarySensor(entry.unique_id, device, entry=entry))
if entry.domain == "binary_sensor" and entry.unique_id not in entities:
entities[entry.unique_id] = ONVIFBinarySensor(
entry.unique_id, device, entry=entry
)
async_add_entities(entities)
async_add_entities(entities.values())
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(uids)):
if not (missing := uids_by_platform.difference(entities)):
return
events = device.events.get_platform("binary_sensor")
entity_names = build_event_entity_names(events)
new_entities = [
ONVIFBinarySensor(uid, device, name=entity_names[uid]) for uid in missing
]
new_entities: dict[str, ONVIFBinarySensor] = {
uid: ONVIFBinarySensor(uid, device, name=entity_names[uid])
for uid in missing
}
if new_entities:
uids.update(missing)
async_add_entities(new_entities)
entities.update(new_entities)
async_add_entities(new_entities.values())
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)
uids = set()
entities = []
for event in events:
uids.add(event.uid)
entities.append(ONVIFSensor(event.uid, device, name=entity_names[event.uid]))
entities = {
event.uid: ONVIFSensor(event.uid, device, name=entity_names[event.uid])
for event in events
}
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 uids:
uids.add(entry.unique_id)
entities.append(ONVIFSensor(entry.unique_id, device, entry=entry))
if entry.domain == "sensor" and entry.unique_id not in entities:
entities[entry.unique_id] = ONVIFSensor(
entry.unique_id, device, entry=entry
)
async_add_entities(entities)
async_add_entities(entities.values())
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(uids)):
if not (missing := uids_by_platform.difference(entities)):
return
events = device.events.get_platform("sensor")
entity_names = build_event_entity_names(events)
new_entities = [
ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing
]
new_entities: dict[str, ONVIFSensor] = {
uid: ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing
}
if new_entities:
uids.update(missing)
async_add_entities(new_entities)
entities.update(new_entities)
async_add_entities(new_entities.values())
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==2.2.0", "python-open-router==0.3.1"]
"requirements": ["openai==1.99.5", "python-open-router==0.3.1"]
}

View File

@@ -316,23 +316,16 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options = self.options
errors: dict[str, str] = {}
step_schema: VolDictType = {}
model = options[CONF_CHAT_MODEL]
if not model.startswith(("gpt-5-pro", "gpt-5-codex")):
step_schema.update(
{
step_schema: VolDictType = {
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"):
model = options[CONF_CHAT_MODEL]
if model.startswith(("o", "gpt-5")):
step_schema.update(
{
vol.Optional(

View File

@@ -468,9 +468,7 @@ 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"]
@@ -489,7 +487,7 @@ class OpenAIBaseLLMEntity(Entity):
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchToolParam(
type="web_search",
type="web_search_preview",
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==2.2.0"]
"requirements": ["openai==1.99.5"]
}

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
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
from homeassistant.core import HomeAssistant
from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS
@@ -25,6 +25,7 @@ type OpenweathermapConfigEntry = ConfigEntry[OpenweathermapData]
class OpenweathermapData:
"""Runtime data definition."""
name: str
mode: str
coordinator: OWMUpdateCoordinator
@@ -33,6 +34,7 @@ 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]
@@ -49,7 +51,7 @@ async def async_setup_entry(
entry.async_on_unload(entry.add_update_listener(async_update_options))
entry.runtime_data = OpenweathermapData(mode, owm_coordinator)
entry.runtime_data = OpenweathermapData(name, mode, owm_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -14,17 +14,12 @@ 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.selector import (
LanguageSelector,
LanguageSelectorConfig,
LocationSelector,
LocationSelectorConfig,
)
from homeassistant.helpers import config_validation as cv
from .const import (
CONFIG_FLOW_VERSION,
@@ -39,12 +34,10 @@ from .utils import build_data_and_options, validate_api_key
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCATION): LocationSelector(
LocationSelectorConfig(radius=False)
),
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector(
LanguageSelectorConfig(languages=LANGUAGES, native_name=True)
),
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_API_KEY): str,
vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
}
@@ -52,9 +45,7 @@ USER_SCHEMA = vol.Schema(
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector(
LanguageSelectorConfig(languages=LANGUAGES, native_name=True)
),
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES),
vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
}
)
@@ -79,8 +70,8 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders = {}
if user_input is not None:
latitude = user_input[CONF_LOCATION][CONF_LATITUDE]
longitude = user_input[CONF_LOCATION][CONF_LONGITUDE]
latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE]
mode = user_input[CONF_MODE]
await self.async_set_unique_id(f"{latitude}-{longitude}")
@@ -91,21 +82,15 @@ 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=DEFAULT_NAME, data=data, options=options
title=user_input[CONF_NAME], data=data, options=options
)
schema_data = user_input
else:
schema_data = {
CONF_LOCATION: {
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
},
CONF_LANGUAGE: self.hass.config.language,
}

View File

@@ -121,7 +121,6 @@ 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,
@@ -159,7 +158,6 @@ 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,
@@ -229,6 +227,7 @@ 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
@@ -243,6 +242,7 @@ async def async_setup_entry(
elif domain_data.mode == OWM_MODE_AIRPOLLUTION:
async_add_entities(
OpenWeatherMapSensor(
name,
unique_id,
description,
coordinator,
@@ -252,6 +252,7 @@ async def async_setup_entry(
else:
async_add_entities(
OpenWeatherMapSensor(
name,
unique_id,
description,
coordinator,
@@ -269,6 +270,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity):
def __init__(
self,
name: str,
unique_id: str,
description: SensorEntityDescription,
coordinator: OWMUpdateCoordinator,
@@ -282,6 +284,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity):
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
manufacturer=MANUFACTURER,
name=name,
)
@property

View File

@@ -12,14 +12,16 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"language": "[%key:common::config_flow::data::language%]",
"location": "[%key:common::config_flow::data::location%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"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",
"location": "Location to get the weather data for",
"latitude": "Latitude of the location",
"longitude": "Longitude of the location",
"mode": "Mode for the OpenWeatherMap API",
"name": "Name for this OpenWeatherMap location"
},

View File

@@ -57,13 +57,14 @@ 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(unique_id, mode, weather_coordinator)
owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator)
async_add_entities([owm_weather], False)
@@ -92,6 +93,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
def __init__(
self,
name: str,
unique_id: str,
mode: str,
weather_coordinator: OWMUpdateCoordinator,
@@ -103,6 +105,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
manufacturer=MANUFACTURER,
name=name,
)
self.mode = mode

View File

@@ -18,8 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .coordinator import PortainerCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]

View File

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

View File

@@ -1,83 +0,0 @@
"""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,11 +46,6 @@
"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 = 60.0
CONNECT_TIMEOUT = 30.0
_LOGGER = logging.getLogger(__name__)

View File

@@ -24,7 +24,6 @@
},
"config_subentries": {
"partition": {
"entry_type": "Partition",
"initiate_flow": {
"user": "Add partition"
},
@@ -58,7 +57,6 @@
}
},
"zone": {
"entry_type": "Zone",
"initiate_flow": {
"user": "Add zone"
},
@@ -93,7 +91,6 @@
}
},
"output": {
"entry_type": "Output",
"initiate_flow": {
"user": "Add output"
},
@@ -128,7 +125,6 @@
}
},
"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", "@TheOneOgre"],
"codeowners": ["@JeffResc", "@funkybunch"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from functools import partial
from typing import Final
from aioshelly.ble.const import BLE_SCRIPT_NAME
@@ -64,7 +63,6 @@ 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,
@@ -325,12 +323,6 @@ 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, MODEL_FRANKEVER_WATER_VALVE
from .const import CONF_SLEEP_PERIOD
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -157,18 +157,21 @@ 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(
@@ -198,6 +201,7 @@ 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(
@@ -266,21 +270,12 @@ RPC_SENSORS: Final = {
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"boolean_generic": RpcBinarySensorDescription(
"boolean": 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,6 +9,7 @@ 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,
@@ -23,24 +24,16 @@ 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, MODEL_FRANKEVER_WATER_VALVE, SHELLY_GAS_MODELS
from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
RpcEntityDescription,
ShellyRpcAttributeEntity,
async_setup_entry_rpc,
get_entity_block_device_info,
get_entity_rpc_device_info,
rpc_call,
)
from .entity import get_entity_block_device_info, get_entity_rpc_device_info
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,
)
@@ -58,11 +51,6 @@ 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",
@@ -108,24 +96,12 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
),
]
RPC_VIRTUAL_BUTTONS = {
"button_generic": RpcButtonDescription(
VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [
ShellyButtonDescription[ShellyRpcCoordinator](
key="button",
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},
),
}
press_action="single_push",
)
]
@callback
@@ -153,10 +129,8 @@ 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"]
@@ -175,26 +149,6 @@ 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
@@ -218,7 +172,7 @@ async def async_setup_entry(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
entities: list[ShellyButton | ShellyBluTrvButton] = []
entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = []
entities.extend(
ShellyButton(coordinator, button)
@@ -231,8 +185,11 @@ async def async_setup_entry(
return
# add virtual buttons
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_VIRTUAL_BUTTONS, RpcVirtualButton
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
)
# add BLU TRV buttons
@@ -375,16 +332,30 @@ class ShellyBluTrvButton(ShellyBaseButton):
await method(self._id)
class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
"""Defines a Shelly RPC virtual component button."""
class ShellyVirtualButton(ShellyBaseButton):
"""Defines a Shelly virtual component button."""
entity_description: RpcButtonDescription
_id: int
def __init__(
self,
coordinator: ShellyRpcCoordinator,
description: ShellyButtonDescription,
_id: int,
) -> None:
"""Initialize Shelly virtual component button."""
super().__init__(coordinator, description)
@rpc_call
async def async_press(self) -> None:
"""Triggers the Shelly button press service."""
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."""
if TYPE_CHECKING:
assert isinstance(self.coordinator, ShellyRpcCoordinator)
await self.coordinator.device.button_trigger(self._id, "single_push")
await self.coordinator.device.button_trigger(
self._id, self.entity_description.press_action
)

View File

@@ -308,5 +308,3 @@ 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,7 +29,6 @@ from .utils import (
get_rpc_device_info,
get_rpc_entity_name,
get_rpc_key_instances,
get_rpc_role_by_key,
)
@@ -190,16 +189,14 @@ def async_setup_rpc_attribute_entities(
if description.models and coordinator.model not in description.models:
continue
if description.role and description.role != get_rpc_role_by_key(
coordinator.device.config, key
):
if description.role and description.role != coordinator.device.config[
key
].get("role", "generic"):
continue
if (
description.sub_key
and description.sub_key not in coordinator.device.status[key]
and not description.supported(coordinator.device.status[key])
):
if 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
@@ -311,7 +308,7 @@ class RpcEntityDescription(EntityDescription):
# restrict the type to str.
name: str = ""
sub_key: str | None = None
sub_key: str
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.12.0"],
"requirements": ["aioshelly==13.11.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -24,16 +24,7 @@ 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,
MODEL_FRANKEVER_WATER_VALVE,
MODEL_LINKEDGO_ST802_THERMOSTAT,
MODEL_LINKEDGO_ST1820_THERMOSTAT,
MODEL_TOP_EV_CHARGER_EVE01,
VIRTUAL_NUMBER_MODE_MAP,
)
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -192,7 +183,7 @@ RPC_NUMBERS: Final = {
method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber,
),
"number_generic": RpcNumberDescription(
"number": RpcNumberDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform(
@@ -206,58 +197,6 @@ 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,13 +38,12 @@ class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription):
RPC_SELECT_ENTITIES: Final = {
"enum_generic": RpcSelectDescription(
"enum": 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,7 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, cast
from functools import partial
from typing import Any, Final, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
@@ -36,12 +37,13 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
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
from .const import CONF_SLEEP_PERIOD, LOGGER
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -549,7 +551,7 @@ RPC_SENSORS: Final = {
"a_act_power": RpcSensorDescription(
key="em",
sub_key="a_act_power",
name="Power",
name="Active power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -559,7 +561,7 @@ RPC_SENSORS: Final = {
"b_act_power": RpcSensorDescription(
key="em",
sub_key="b_act_power",
name="Power",
name="Active power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -569,7 +571,7 @@ RPC_SENSORS: Final = {
"c_act_power": RpcSensorDescription(
key="em",
sub_key="c_act_power",
name="Power",
name="Active power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -579,7 +581,7 @@ RPC_SENSORS: Final = {
"total_act_power": RpcSensorDescription(
key="em",
sub_key="total_act_power",
name="Power",
name="Total active power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -625,7 +627,7 @@ RPC_SENSORS: Final = {
"total_aprt_power": RpcSensorDescription(
key="em",
sub_key="total_aprt_power",
name="Apparent power",
name="Total apparent power",
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -880,7 +882,7 @@ RPC_SENSORS: Final = {
"n_current": RpcSensorDescription(
key="em",
sub_key="n_current",
name="Neutral current",
name="Phase N current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -890,7 +892,7 @@ RPC_SENSORS: Final = {
"total_current": RpcSensorDescription(
key="em",
sub_key="total_current",
name="Current",
name="Total current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -1382,7 +1384,7 @@ RPC_SENSORS: Final = {
native_unit_of_measurement="pulse",
state_class=SensorStateClass.TOTAL,
value=lambda status, _: status["total"],
removal_condition=lambda config, _, key: (
removal_condition=lambda config, _status, key: (
config[key]["type"] != "count" or config[key]["enable"] is False
),
),
@@ -1422,7 +1424,7 @@ RPC_SENSORS: Final = {
"text_generic": RpcSensorDescription(
key="text",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, SENSOR_PLATFORM
),
role="generic",
@@ -1430,7 +1432,7 @@ RPC_SENSORS: Final = {
"number_generic": RpcSensorDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, SENSOR_PLATFORM
),
unit=get_virtual_component_unit,
@@ -1439,7 +1441,7 @@ RPC_SENSORS: Final = {
"enum_generic": RpcSensorDescription(
key="enum",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, SENSOR_PLATFORM
),
options_fn=lambda config: config["options"],
@@ -1454,7 +1456,7 @@ RPC_SENSORS: Final = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
removal_condition=lambda config, _, key: config[key].get("enable", False)
removal_condition=lambda config, _status, key: config[key].get("enable", False)
is False,
entity_class=RpcBluTrvSensor,
),
@@ -1604,7 +1606,7 @@ RPC_SENSORS: Final = {
"object_total_act_energy": RpcSensorDescription(
key="object",
sub_key="value",
name="Energy",
name="Total Active Energy",
value=lambda status, _: float(status["total_act_energy"]),
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
@@ -1616,7 +1618,7 @@ RPC_SENSORS: Final = {
"object_total_power": RpcSensorDescription(
key="object",
sub_key="value",
name="Power",
name="Total Power",
value=lambda status, _: float(status["total_power"]),
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
@@ -1661,6 +1663,39 @@ 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,
@@ -1680,6 +1715,12 @@ 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 TYPE_CHECKING, Any, cast
from typing import Any, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
@@ -21,13 +21,6 @@ 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,
@@ -37,7 +30,6 @@ from .entity import (
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
rpc_call,
)
from .utils import (
async_remove_orphaned_entities,
@@ -79,7 +71,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], tuple]
method_params_fn: Callable[[int | None, bool], dict]
RPC_RELAY_SWITCHES = {
@@ -88,145 +80,31 @@ 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, value),
method_on="Switch.Set",
method_off="Switch.Set",
method_params_fn=lambda id, value: {"id": id, "on": value},
),
}
RPC_SWITCHES = {
"boolean_generic": RpcSwitchDescription(
"boolean": 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, 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},
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
),
"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,),
method_on="Script.Start",
method_off="Script.Stop",
method_params_fn=lambda id, _: {"id": id},
entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG,
),
@@ -423,27 +301,19 @@ 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 switch."""
method = getattr(self.coordinator.device, self.entity_description.method_on)
"""Turn on relay."""
await self.call_rpc(
self.entity_description.method_on,
self.entity_description.method_params_fn(self._id, True),
)
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 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)
"""Turn off relay."""
await self.call_rpc(
self.entity_description.method_off,
self.entity_description.method_params_fn(self._id, False),
)
class RpcRelaySwitch(RpcSwitch):

View File

@@ -38,13 +38,12 @@ class RpcTextDescription(RpcEntityDescription, TextEntityDescription):
RPC_TEXT_ENTITIES: Final = {
"text_generic": RpcTextDescription(
"text": 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,11 +484,6 @@ 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])
@@ -939,35 +934,3 @@ 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

@@ -100,9 +100,8 @@ ATTR_PIN_VALUE = "pin"
ATTR_TIMESTAMP = "timestamp"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
DEFAULT_SOCKET_MIN_RETRY = 15
WEBSOCKET_RECONNECT_RETRIES = 3
WEBSOCKET_RETRY_DELAY = 2
EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT"
EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION"
@@ -420,7 +419,6 @@ class SimpliSafe:
self._api = api
self._hass = hass
self._system_notifications: dict[int, set[SystemNotification]] = {}
self._websocket_reconnect_retries: int = 0
self._websocket_reconnect_task: asyncio.Task | None = None
self.entry = entry
self.initial_event_to_use: dict[int, dict[str, Any]] = {}
@@ -471,8 +469,6 @@ class SimpliSafe:
"""Start a websocket reconnection loop."""
assert self._api.websocket
self._websocket_reconnect_retries += 1
try:
await self._api.websocket.async_connect()
await self._api.websocket.async_listen()
@@ -483,21 +479,9 @@ class SimpliSafe:
LOGGER.error("Failed to connect to websocket: %s", err)
except Exception as err: # noqa: BLE001
LOGGER.error("Unknown exception while connecting to websocket: %s", err)
else:
self._websocket_reconnect_retries = 0
if self._websocket_reconnect_retries >= WEBSOCKET_RECONNECT_RETRIES:
LOGGER.error("Max websocket connection retries exceeded")
return
delay = WEBSOCKET_RETRY_DELAY * (2 ** (self._websocket_reconnect_retries - 1))
LOGGER.info(
"Retrying websocket connection in %s seconds (attempt %s/%s)",
delay,
self._websocket_reconnect_retries,
WEBSOCKET_RECONNECT_RETRIES,
)
await asyncio.sleep(delay)
LOGGER.debug("Reconnecting to websocket")
await self._async_cancel_websocket_loop()
self._websocket_reconnect_task = self._hass.async_create_task(
self._async_start_websocket_loop()
)

View File

@@ -179,13 +179,6 @@ 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,17 +34,6 @@
"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,6 +530,7 @@ CAPABILITY_TO_SENSORS: dict[
)
],
},
# Haven't seen at devices yet
Capability.ILLUMINANCE_MEASUREMENT: {
Attribute.ILLUMINANCE: [
SmartThingsSensorEntityDescription(
@@ -841,6 +842,7 @@ CAPABILITY_TO_SENSORS: dict[
)
]
},
# Haven't seen at devices yet
Capability.SIGNAL_STRENGTH: {
Attribute.LQI: [
SmartThingsSensorEntityDescription(
@@ -999,6 +1001,7 @@ CAPABILITY_TO_SENSORS: dict[
)
],
},
# Haven't seen at devices yet
Capability.TVOC_MEASUREMENT: {
Attribute.TVOC_LEVEL: [
SmartThingsSensorEntityDescription(
@@ -1009,6 +1012,7 @@ 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 saver",
"smart": "Smart",
"motion_direct": "Motion direct",
"motion_indirect": "Motion indirect"
}

View File

@@ -241,6 +241,7 @@ 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.1.0"],
"requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"],
"single_config_entry": true
}

View File

@@ -18,7 +18,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -352,7 +352,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
{
"code": self._set_temperature.dpcode,
"value": round(
self._set_temperature.scale_value_back(kwargs[ATTR_TEMPERATURE])
self._set_temperature.scale_value_back(kwargs["temperature"])
),
}
]

Some files were not shown because too many files have changed in this diff Show More