Compare commits

..

3 Commits
dev ... power

Author SHA1 Message Date
Petar Petrov
bda24333fb Update homeassistant/components/energy/data.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-06 14:03:01 +03:00
Petar Petrov
dac9af88d8 update tests 2025-10-06 10:41:50 +03:00
Petar Petrov
50f8786311 Add power configuration to Energy dashboard 2025-10-06 10:37:03 +03:00
210 changed files with 1621 additions and 5445 deletions

View File

@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

2
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

View File

@@ -635,15 +635,25 @@ async def async_enable_logging(
err_log_path = os.path.abspath(log_file)
if err_log_path:
err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days
)
err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger.addHandler(err_handler)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK)
):
err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days
)
# Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger.addHandler(err_handler)
# Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass)

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

@@ -6,11 +6,7 @@ import dataclasses
import logging
from typing import Any
from airthings_ble import (
AirthingsBluetoothDeviceData,
AirthingsDevice,
UnsupportedDeviceError,
)
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
from bleak import BleakError
from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol
@@ -32,7 +28,6 @@ SERVICE_UUIDS = [
"b42e4a8e-ade7-11e4-89d3-123b93f75cba",
"b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e3882-ade7-11e4-89d3-123b93f75cba",
"b42e90a2-ade7-11e4-89d3-123b93f75cba",
]
@@ -43,7 +38,6 @@ class Discovery:
name: str
discovery_info: BluetoothServiceInfo
device: AirthingsDevice
data: AirthingsBluetoothDeviceData
def get_name(device: AirthingsDevice) -> str:
@@ -69,8 +63,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Discovery | None = None
self._discovered_devices: dict[str, Discovery] = {}
async def _get_device(
self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo
async def _get_device_data(
self, discovery_info: BluetoothServiceInfo
) -> AirthingsDevice:
ble_device = bluetooth.async_ble_device_from_address(
self.hass, discovery_info.address
@@ -79,8 +73,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("no ble_device in _get_device_data")
raise AirthingsDeviceUpdateError("No ble_device")
airthings = AirthingsBluetoothDeviceData(_LOGGER)
try:
device = await data.update_device(ble_device)
data = await airthings.update_device(ble_device)
except BleakError as err:
_LOGGER.error(
"Error connecting to and getting data from %s: %s",
@@ -88,15 +84,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
err,
)
raise AirthingsDeviceUpdateError("Failed getting device data") from err
except UnsupportedDeviceError:
_LOGGER.debug("Skipping unsupported device: %s", discovery_info.name)
raise
except Exception as err:
_LOGGER.error(
"Unknown error occurred from %s: %s", discovery_info.address, err
)
raise
return device
return data
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
@@ -106,21 +99,17 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try:
device = await self._get_device(data=data, discovery_info=discovery_info)
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
except UnsupportedDeviceError:
return self.async_abort(reason="unsupported_device")
except Exception:
_LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
self.context["title_placeholders"] = {"name": name}
self._discovered_device = Discovery(name, discovery_info, device, data=data)
self._discovered_device = Discovery(name, discovery_info, device)
return await self.async_step_bluetooth_confirm()
@@ -175,28 +164,16 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
if MFCT_ID not in discovery_info.manufacturer_data:
continue
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
_LOGGER.debug(
"Skipping unsupported device: %s (%s)", discovery_info.name, address
)
continue
devices.append(discovery_info)
for discovery_info in devices:
address = discovery_info.address
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try:
device = await self._get_device(data, discovery_info)
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
_LOGGER.error(
"Error connecting to and getting data from %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except UnsupportedDeviceError:
_LOGGER.debug(
"Skipping unsupported device: %s (%s)",
discovery_info.name,
"Error connecting to and getting data from %s",
discovery_info.address,
)
continue
@@ -204,10 +181,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
_LOGGER.debug("Discovered Airthings device: %s (%s)", name, address)
self._discovered_devices[address] = Discovery(
name, discovery_info, device, data
)
self._discovered_devices[address] = Discovery(name, discovery_info, device)
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")

View File

@@ -17,10 +17,6 @@
{
"manufacturer_id": 820,
"service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
},
{
"manufacturer_id": 820,
"service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
}
],
"codeowners": ["@vincegio", "@LaStrada"],

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

@@ -21,7 +21,6 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
"unsupported_device": "Unsupported device",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
@@ -41,9 +40,6 @@
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"ambient_noise": {
"name": "Ambient noise"
}
}
}

View File

@@ -18,9 +18,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_update_unique_id
@@ -60,40 +58,6 @@ BINARY_SENSORS: Final = (
),
)
DEPRECATED_BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -104,8 +68,6 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
# Replace unique id for "detectionState" binary sensor
await async_update_unique_id(
hass,
@@ -115,16 +77,6 @@ async def async_setup_entry(
"detectionState",
)
# Clean up deprecated sensors
for sensor_desc in DEPRECATED_BINARY_SENSORS:
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{sensor_desc.key}"
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set()
def _check_device() -> None:

View File

@@ -18,11 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
from .utils import alexa_api_call, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -64,9 +60,6 @@ async def async_setup_entry(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
known_devices: set[str] = set()
def _check_device() -> None:

View File

@@ -4,10 +4,8 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
@@ -63,21 +61,3 @@ async def async_update_unique_id(
# Update the registry with the new unique_id
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-do_not_disturb"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)

View File

@@ -65,31 +65,6 @@ SENSOR_DESCRIPTIONS = [
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key="BME680_humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BME680_pressure",
translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.MMHG,
suggested_display_precision=0,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="BME680_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BMP_pressure",

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

@@ -4,7 +4,6 @@ from __future__ import annotations
from asyncio.exceptions import TimeoutError
from collections.abc import Mapping
import re
from typing import Any
from aiocomelit import (
@@ -28,20 +27,25 @@ from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111"
pin_regex = r"^[0-9]{4,10}$"
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
)
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
}
)
@@ -51,9 +55,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
api: ComelitCommonApi
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_PIN]):
raise InvalidPin
session = await async_client_session(hass)
if data.get(CONF_TYPE, BRIDGE) == BRIDGE:
api = ComeliteSerialBridgeApi(
@@ -104,8 +105,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -147,8 +146,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -192,8 +189,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -215,7 +210,3 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""

View File

@@ -161,7 +161,7 @@ class ComelitSerialBridge(
entry: ComelitConfigEntry,
host: str,
port: int,
pin: str,
pin: int,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
@@ -195,7 +195,7 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
entry: ComelitConfigEntry,
host: str,
port: int,
pin: str,
pin: int,
session: ClientSession,
) -> None:
"""Initialize the scanner."""

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,19 +29,21 @@ 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):
@@ -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,19 +27,21 @@ 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):

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==1.1.1"]
"requirements": ["aiocomelit==0.12.3"]
}

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,22 +65,24 @@ 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 = [
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)
known_devices: set[int] = set()
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, OTHER)
)
def _check_device() -> None:
current_devices = set(coordinator.data[OTHER])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_BRIDGE_TYPES
for device in coordinator.data[OTHER].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
async def async_setup_vedo_entry(
@@ -93,22 +94,24 @@ 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 = [
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)
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(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):

View File

@@ -43,13 +43,11 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},

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,20 +28,35 @@ 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 = [
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
entities: list[ComelitSwitchEntity] = []
entities.extend(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[IRRIGATION].values()
)
entities.extend(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[OTHER].values()
)
async_add_entities(entities)
for dev_type in (IRRIGATION, OTHER):
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, dev_type)
)
known_devices: dict[str, set[int]] = {
dev_type: set() for dev_type in (IRRIGATION, OTHER)
}
def _check_device() -> None:
for dev_type in (IRRIGATION, OTHER):
current_devices = set(coordinator.data[dev_type])
new_devices = current_devices - known_devices[dev_type]
if new_devices:
known_devices[dev_type].update(new_devices)
async_add_entities(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):

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

@@ -116,9 +116,6 @@
}
},
"select": {
"active_map": {
"default": "mdi:floor-plan"
},
"water_amount": {
"default": "mdi:water"
},

View File

@@ -2,13 +2,12 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any
from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes
from deebot_client.capabilities import CapabilitySetTypes
from deebot_client.device import Device
from deebot_client.events import WorkModeEvent
from deebot_client.events.base import Event
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent
from deebot_client.events.water_info import WaterAmountEvent
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -17,11 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
EcovacsEntity,
)
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
from .util import get_name_key, get_supported_entities
@@ -71,12 +66,6 @@ async def async_setup_entry(
entities = get_supported_entities(
controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
)
entities.extend(
EcovacsActiveMapSelectEntity(device, device.capabilities.map)
for device in controller.devices
if (map_cap := device.capabilities.map)
and isinstance(map_cap.major, CapabilitySet)
)
if entities:
async_add_entities(entities)
@@ -114,76 +103,3 @@ class EcovacsSelectEntity[EventT: Event](
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self._device.execute_command(self._capability.set(option))
class EcovacsActiveMapSelectEntity(
EcovacsEntity[CapabilityMap],
SelectEntity,
):
"""Ecovacs active map select entity."""
entity_description = SelectEntityDescription(
key="active_map",
translation_key="active_map",
entity_category=EntityCategory.CONFIG,
)
def __init__(
self,
device: Device,
capability: CapabilityMap,
**kwargs: Any,
) -> None:
"""Initialize entity."""
super().__init__(device, capability, **kwargs)
self._option_to_id: dict[str, str] = {}
self._id_to_option: dict[str, str] = {}
self._handle_on_cached_map(
device.events.get_last_event(CachedMapInfoEvent)
or CachedMapInfoEvent(set())
)
def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None:
self._id_to_option.clear()
self._option_to_id.clear()
for map_info in event.maps:
name = map_info.name if map_info.name else map_info.id
self._id_to_option[map_info.id] = name
self._option_to_id[name] = map_info.id
if map_info.using:
self._attr_current_option = name
if self._attr_current_option not in self._option_to_id:
self._attr_current_option = None
# Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order.
self._attr_options = sorted(
self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower())
)
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_cached_map(event: CachedMapInfoEvent) -> None:
self._handle_on_cached_map(event)
self.async_write_ha_state()
self._subscribe(self._capability.cached_info.event, on_cached_map)
async def on_major_map(event: MajorMapEvent) -> None:
self._attr_current_option = self._id_to_option.get(event.map_id)
self.async_write_ha_state()
self._subscribe(self._capability.major.event, on_major_map)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if TYPE_CHECKING:
assert isinstance(self._capability.major, CapabilitySet)
await self._device.execute_command(
self._capability.major.set(self._option_to_id[option])
)

View File

@@ -178,9 +178,6 @@
}
},
"select": {
"active_map": {
"name": "Active map"
},
"water_amount": {
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
"state": {

View File

@@ -29,9 +29,12 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
class FlowFromGridSourceType(TypedDict):
"""Dictionary describing the 'from' stat for the grid source."""
# statistic_id of a an energy meter (kWh)
# statistic_id of an energy meter (kWh)
stat_energy_from: str
# statistic_id of an power meter (kW)
stat_power_from: str | None
# statistic_id of costs ($) incurred from the energy meter
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -48,6 +51,9 @@ class FlowToGridSourceType(TypedDict):
# kWh meter
stat_energy_to: str
# kW meter
stat_power_to: str | None
# statistic_id of compensation ($) received for contributing back
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -75,6 +81,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]
stat_energy_from: str
stat_power_from: str | None
config_entry_solar_forecast: list[str] | None
@@ -85,6 +92,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
stat_power_from: str | None
stat_power_to: str | None
class GasSourceType(TypedDict):
@@ -136,6 +145,9 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
# optional power meter
stat_power: str | None
# An optional custom name for display in energy graphs
name: str | None
@@ -172,6 +184,7 @@ FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("stat_energy_from"): str,
vol.Optional("stat_power_from"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
@@ -186,6 +199,7 @@ FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_energy_to"): str,
vol.Optional("stat_power_to"): str,
vol.Optional("stat_compensation"): vol.Any(str, None),
# entity_energy_to was removed in HA Core 2022.10
vol.Remove("entity_energy_to"): vol.Any(str, None),
@@ -231,6 +245,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_power_from"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
@@ -239,6 +254,8 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_power_from"): str,
vol.Optional("stat_power_to"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +311,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_power"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}

View File

@@ -396,7 +396,6 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
]
on_phase: str | None
cttype: str | None = None
CT_NET_CONSUMPTION_SENSORS = (
@@ -410,7 +409,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="lifetime_net_production",
@@ -422,7 +420,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption",
@@ -434,7 +431,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="frequency",
@@ -446,7 +442,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="voltage",
@@ -459,7 +454,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_current",
@@ -472,7 +466,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_powerfactor",
@@ -483,7 +476,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_metering_status",
@@ -494,7 +486,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_status_flags",
@@ -504,7 +495,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
)
@@ -535,7 +525,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_voltage",
@@ -548,7 +537,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_current",
@@ -561,7 +549,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_powerfactor",
@@ -572,7 +559,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_metering_status",
@@ -583,7 +569,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_status_flags",
@@ -593,7 +578,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.PRODUCTION,
),
)
@@ -623,7 +607,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="lifetime_battery_charged",
@@ -635,7 +618,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="battery_discharge",
@@ -647,7 +629,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_frequency",
@@ -659,7 +640,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_voltage",
@@ -672,7 +652,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_current",
@@ -685,7 +664,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_powerfactor",
@@ -696,7 +674,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_metering_status",
@@ -707,7 +684,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_status_flags",
@@ -717,7 +693,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.STORAGE,
),
)
@@ -1040,31 +1015,50 @@ async def async_setup_entry(
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None
)
# Add Current Transformer entities
if envoy_data.ctmeters:
# Add net consumption CT entities
if ctmeter := envoy_data.ctmeter_consumption:
entities.extend(
EnvoyCTEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_SENSORS,
CT_PRODUCTION_SENSORS,
CT_STORAGE_SENSORS,
)
for description in sensors
if description.cttype in envoy_data.ctmeters
EnvoyConsumptionCTEntity(coordinator, description)
for description in CT_NET_CONSUMPTION_SENSORS
if ctmeter.measurement_type == CtType.NET_CONSUMPTION
)
# Add Current Transformer phase entities
if ctmeters_phases := envoy_data.ctmeters_phases:
# For each net consumption ct phase reported add net consumption entities
if phase_data := envoy_data.ctmeter_consumption_phases:
entities.extend(
EnvoyCTPhaseEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_PHASE_SENSORS,
CT_PRODUCTION_PHASE_SENSORS,
CT_STORAGE_PHASE_SENSORS,
)
for phase, descriptions in sensors.items()
for description in descriptions
if (cttype := description.cttype) in ctmeters_phases
and phase in ctmeters_phases[cttype]
EnvoyConsumptionCTPhaseEntity(coordinator, description)
for use_phase, phase in phase_data.items()
for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase]
if phase.measurement_type == CtType.NET_CONSUMPTION
)
# Add production CT entities
if ctmeter := envoy_data.ctmeter_production:
entities.extend(
EnvoyProductionCTEntity(coordinator, description)
for description in CT_PRODUCTION_SENSORS
if ctmeter.measurement_type == CtType.PRODUCTION
)
# For each production ct phase reported add production ct entities
if phase_data := envoy_data.ctmeter_production_phases:
entities.extend(
EnvoyProductionCTPhaseEntity(coordinator, description)
for use_phase, phase in phase_data.items()
for description in CT_PRODUCTION_PHASE_SENSORS[use_phase]
if phase.measurement_type == CtType.PRODUCTION
)
# Add storage CT entities
if ctmeter := envoy_data.ctmeter_storage:
entities.extend(
EnvoyStorageCTEntity(coordinator, description)
for description in CT_STORAGE_SENSORS
if ctmeter.measurement_type == CtType.STORAGE
)
# For each storage ct phase reported add storage ct entities
if phase_data := envoy_data.ctmeter_storage_phases:
entities.extend(
EnvoyStorageCTPhaseEntity(coordinator, description)
for use_phase, phase in phase_data.items()
for description in CT_STORAGE_PHASE_SENSORS[use_phase]
if phase.measurement_type == CtType.STORAGE
)
if envoy_data.inverters:
@@ -1251,8 +1245,8 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
return self.entity_description.value_fn(system_net_consumption)
class EnvoyCTEntity(EnvoySystemSensorEntity):
"""Envoy CT entity."""
class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT entity."""
entity_description: EnvoyCTSensorEntityDescription
@@ -1261,13 +1255,13 @@ class EnvoyCTEntity(EnvoySystemSensorEntity):
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor."""
if (cttype := self.entity_description.cttype) not in self.data.ctmeters:
if (ctmeter := self.data.ctmeter_consumption) is None:
return None
return self.entity_description.value_fn(self.data.ctmeters[cttype])
return self.entity_description.value_fn(ctmeter)
class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy CT phase entity."""
class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription
@@ -1278,14 +1272,78 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
"""Return the state of the CT phase sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
if (cttype := self.entity_description.cttype) not in self.data.ctmeters_phases:
return None
if (phase := self.entity_description.on_phase) not in self.data.ctmeters_phases[
cttype
]:
if (ctmeter := self.data.ctmeter_consumption_phases) is None:
return None
return self.entity_description.value_fn(
self.data.ctmeters_phases[cttype][phase]
ctmeter[self.entity_description.on_phase]
)
class EnvoyProductionCTEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor."""
if (ctmeter := self.data.ctmeter_production) is None:
return None
return self.entity_description.value_fn(ctmeter)
class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT phase sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
if (ctmeter := self.data.ctmeter_production_phases) is None:
return None
return self.entity_description.value_fn(
ctmeter[self.entity_description.on_phase]
)
class EnvoyStorageCTEntity(EnvoySystemSensorEntity):
"""Envoy net storage CT entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor."""
if (ctmeter := self.data.ctmeter_storage) is None:
return None
return self.entity_description.value_fn(ctmeter)
class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy net storage CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT phase sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
if (ctmeter := self.data.ctmeter_storage_phases) is None:
return None
return self.entity_description.value_fn(
ctmeter[self.entity_description.on_phase]
)

View File

@@ -67,7 +67,7 @@ def suitable_nextchange_time(device: FritzhomeDevice) -> bool:
def suitable_temperature(device: FritzhomeDevice) -> bool:
"""Check suitablity for temperature sensor."""
return bool(device.has_temperature_sensor)
return device.has_temperature_sensor and not device.has_thermostat
def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None:

View File

@@ -54,7 +54,7 @@ async def async_setup_entry(
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="reauth_required"
"OAuth session is not valid, reauth required"
) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:

View File

@@ -70,9 +70,6 @@
},
"grpc_error": {
"message": "Failed to communicate with Google Assistant"
},
"reauth_required": {
"message": "Credentials are invalid, re-authentication required"
}
}
}

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

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

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["letpot"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["letpot==0.6.2"]
}

View File

@@ -41,10 +41,7 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: |
Logging handled by library when (un)available once (push) or coordinator (pull).
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -146,13 +146,6 @@
"off": "mdi:lock-off"
}
},
"speaker_mute": {
"default": "mdi:volume-high",
"state": {
"on": "mdi:volume-mute",
"off": "mdi:volume-high"
}
},
"evse_charging_switch": {
"default": "mdi:ev-station"
},

View File

@@ -176,7 +176,6 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.OnLevel,),
not_device_type=(device_types.Speaker,),
# allow None value to account for 'default' value
allow_none_value=True,
),

View File

@@ -152,7 +152,6 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
@@ -309,7 +308,7 @@ DISCOVERY_SCHEMAS = [
key="TemperatureSensor",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
device_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -345,7 +344,7 @@ DISCOVERY_SCHEMAS = [
key="HumiditySensor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
device_to_ha=lambda x: x / HUMIDITY_SCALING_FACTOR,
device_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -1137,7 +1136,7 @@ DISCOVERY_SCHEMAS = [
key="ThermostatLocalTemperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
device_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,

View File

@@ -514,9 +514,6 @@
"power": {
"name": "Power"
},
"speaker_mute": {
"name": "Mute"
},
"child_lock": {
"name": "Child lock"
},

View File

@@ -203,6 +203,7 @@ DISCOVERY_SCHEMAS = [
device_types.Refrigerator,
device_types.RoboticVacuumCleaner,
device_types.RoomAirConditioner,
device_types.Speaker,
),
),
MatterDiscoverySchema(
@@ -241,24 +242,6 @@ DISCOVERY_SCHEMAS = [
device_types.Speaker,
),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(
key="MatterMuteToggle",
translation_key="speaker_mute",
device_to_ha={
True: False, # True means volume is on, so HA should show mute as off
False: True, # False means volume is off (muted), so HA should show mute as on
}.get,
ha_to_device={
False: True, # HA showing mute as off means volume is on, so send True
True: False, # HA showing mute as on means volume is off (muted), so send False
}.get,
),
entity_class=MatterNumericSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
device_type=(device_types.Speaker,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(

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,11 +65,9 @@ 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(
authorize_url=str(parsed_url.with_path("/authorize")),
token_url=str(parsed_url.with_path("/token")),
)
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:
@@ -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(
authorize_url=authorize_url,
token_url=token_url,
),
scopes=scopes,
return AuthorizationServer(
authorize_url=authorize_url,
token_url=token_url,
)
@@ -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

@@ -51,7 +51,7 @@ from .types import MCPServerConfigEntry
_LOGGER = logging.getLogger(__name__)
# Streamable HTTP endpoint
STREAMABLE_API = "/api/mcp"
STREAMABLE_API = f"/api/{DOMAIN}"
TIMEOUT = 60 # Seconds
# Content types

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/mealie",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["aiomealie==1.0.0"]
}

View File

@@ -49,11 +49,11 @@ rules:
The integration will discover a Mealie addon posting a discovery message.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: done
comment: |

View File

@@ -37,8 +37,8 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
serial_number=device_id,
name=device.device_name or appliance_type or device.tech_type,
translation_key=None if device.device_name else appliance_type,
name=appliance_type or device.tech_type,
translation_key=appliance_type,
manufacturer=MANUFACTURER,
model=device.tech_type,
hw_version=device.xkm_tech_type,

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

@@ -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
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
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

@@ -46,14 +46,6 @@ from homeassistant.components.light import (
VALID_COLOR_MODES,
valid_supported_color_modes,
)
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS,
NumberDeviceClass,
NumberMode,
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASS_UNITS,
@@ -88,7 +80,6 @@ from homeassistant.const import (
CONF_EFFECT,
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_MODE,
CONF_NAME,
CONF_OPTIMISTIC,
CONF_PASSWORD,
@@ -221,9 +212,7 @@ from .const import (
CONF_IMAGE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MIN,
CONF_MIN_KELVIN,
CONF_MODE_COMMAND_TEMPLATE,
CONF_MODE_COMMAND_TOPIC,
@@ -305,7 +294,6 @@ from .const import (
CONF_STATE_UNLOCKED,
CONF_STATE_UNLOCKING,
CONF_STATE_VALUE_TEMPLATE,
CONF_STEP,
CONF_SUGGESTED_DISPLAY_PRECISION,
CONF_SUPPORTED_COLOR_MODES,
CONF_SUPPORTED_FEATURES,
@@ -457,7 +445,6 @@ SUBENTRY_PLATFORMS = [
Platform.LIGHT,
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -693,24 +680,6 @@ LIGHT_SCHEMA_SELECTOR = SelectSelector(
translation_key="light_schema",
)
)
MIN_MAX_SELECTOR = NumberSelector(NumberSelectorConfig(step=1e-3))
NUMBER_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in NumberDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
# The number device classes are all shared with the sensor device classes
translation_key="device_class_sensor",
sort=True,
)
)
NUMBER_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[mode.value for mode in NumberMode],
mode=SelectSelectorMode.DROPDOWN,
translation_key="number_mode",
sort=True,
)
)
ON_COMMAND_TYPE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=VALUES_ON_COMMAND_TYPE,
@@ -758,7 +727,6 @@ SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
translation_key=CONF_STATE_CLASS,
)
)
STEP_SELECTOR = NumberSelector(NumberSelectorConfig(min=1e-3, step=1e-3))
SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[platform.value for platform in VALID_COLOR_MODES],
@@ -915,23 +883,6 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
)
@callback
def number_unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
"""Return a context based unit of measurement selector for number entities."""
if (
device_class := user_data.get(CONF_DEVICE_CLASS)
) is None or device_class not in NUMBER_DEVICE_CLASS_UNITS:
return TEXT_SELECTOR
return SelectSelector(
SelectSelectorConfig(
options=[str(uom) for uom in NUMBER_DEVICE_CLASS_UNITS[device_class]],
sort=True,
custom_value=True,
)
)
@callback
def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""Run validator, then return the unmodified input."""
@@ -1055,29 +1006,6 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
return errors
@callback
def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]:
"""Validate MQTT number configuration."""
errors: dict[str, Any] = {}
if (
CONF_MIN in config
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors[CONF_MIN] = "max_below_min"
errors[CONF_MAX] = "max_below_min"
if (
(device_class := config.get(CONF_DEVICE_CLASS)) is not None
and device_class in NUMBER_DEVICE_CLASS_UNITS
and config.get(CONF_UNIT_OF_MEASUREMENT)
not in NUMBER_DEVICE_CLASS_UNITS[device_class]
):
errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom"
return errors
@callback
def validate_sensor_platform_config(
config: dict[str, Any],
@@ -1140,7 +1068,6 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.LIGHT.value: validate_light_platform_config,
Platform.LOCK.value: None,
Platform.NOTIFY.value: None,
Platform.NUMBER.value: validate_number_platform_config,
Platform.SENSOR.value: validate_sensor_platform_config,
Platform.SWITCH.value: None,
}
@@ -1356,17 +1283,6 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
},
Platform.LOCK.value: {},
Platform.NOTIFY.value: {},
Platform.NUMBER: {
CONF_DEVICE_CLASS: PlatformField(
selector=NUMBER_DEVICE_CLASS_SELECTOR,
required=False,
),
CONF_UNIT_OF_MEASUREMENT: PlatformField(
selector=number_unit_of_measurement_selector,
required=False,
custom_filtering=True,
),
},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
@@ -3051,58 +2967,6 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.NUMBER.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_MIN: PlatformField(
selector=MIN_MAX_SELECTOR,
required=True,
default=DEFAULT_MIN_VALUE,
),
CONF_MAX: PlatformField(
selector=MIN_MAX_SELECTOR,
required=True,
default=DEFAULT_MAX_VALUE,
),
CONF_STEP: PlatformField(
selector=STEP_SELECTOR,
required=True,
default=DEFAULT_STEP,
),
CONF_MODE: PlatformField(
selector=NUMBER_MODE_SELECTOR,
required=True,
default=NumberMode.AUTO.value,
),
CONF_PAYLOAD_RESET: PlatformField(
selector=TEXT_SELECTOR,
required=False,
default=DEFAULT_PAYLOAD_RESET,
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.SENSOR.value: {
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,

View File

@@ -120,10 +120,8 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
CONF_HUMIDITY_MAX = "max_humidity"
CONF_HUMIDITY_MIN = "min_humidity"
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_MAX = "max"
CONF_MAX_KELVIN = "max_kelvin"
CONF_MAX_MIREDS = "max_mireds"
CONF_MIN = "min"
CONF_MIN_KELVIN = "min_kelvin"
CONF_MIN_MIREDS = "min_mireds"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
@@ -198,7 +196,6 @@ CONF_STATE_OPENING = "state_opening"
CONF_STATE_STOPPED = "state_stopped"
CONF_STATE_UNLOCKED = "state_unlocked"
CONF_STATE_UNLOCKING = "state_unlocking"
CONF_STEP = "step"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"

View File

@@ -37,12 +37,8 @@ from .config import MQTT_RW_SCHEMA
from .const import (
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_MAX,
CONF_MIN,
CONF_PAYLOAD_RESET,
CONF_STATE_TOPIC,
CONF_STEP,
DEFAULT_PAYLOAD_RESET,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import (
@@ -57,7 +53,12 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
DEFAULT_NAME = "MQTT Number"
DEFAULT_PAYLOAD_RESET = "None"
MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
{

View File

@@ -300,7 +300,7 @@
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
"supported_features": "The features that the entity supports.",
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
"unit_of_measurement": "Defines the unit of measurement, if any."
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any."
},
"sections": {
"advanced_settings": {
@@ -336,9 +336,6 @@
"image_encoding": "Image encoding",
"image_topic": "Image topic",
"last_reset_value_template": "Last reset value template",
"max": "Maximum",
"min": "Minimum",
"mode": "Mode",
"modes": "Supported operation modes",
"mode_command_topic": "Operation mode command topic",
"mode_command_template": "Operation mode command template",
@@ -349,7 +346,6 @@
"payload_off": "Payload \"off\"",
"payload_on": "Payload \"on\"",
"payload_press": "Payload \"press\"",
"payload_reset": "Payload \"reset\"",
"qos": "QoS",
"red_template": "Red template",
"retain": "Retain",
@@ -358,7 +354,6 @@
"state_template": "State template",
"state_topic": "State topic",
"state_value_template": "State value template",
"step": "Step",
"supported_color_modes": "Supported color modes",
"url_template": "URL template",
"url_topic": "URL topic",
@@ -383,9 +378,6 @@
"image_encoding": "Select the encoding of the received image data",
"image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)",
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
"max": "Maximum value. [Learn more.]({url}#max)",
"min": "Minimum value. [Learn more.]({url}#min)",
"mode": "Control how the number should be displayed in the UI. [Learn more.]({url}#mode)",
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
"mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)",
@@ -396,7 +388,6 @@
"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.",
"payload_reset": "The payload received at the state topic that resets the entity to an unknown state.",
"qos": "The QoS value a {platform} entity should use.",
"red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.",
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
@@ -404,7 +395,6 @@
"state_on": "The incoming payload that represents the \"on\" state. Use only when the value that represents \"on\" state in the state topic is different from value that should be sent to the command topic to turn the device on.",
"state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.",
"state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
"step": "Step value. Smallest value 0.001.",
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
"url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
@@ -1007,7 +997,6 @@
"invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list",
"invalid_url": "Invalid URL",
"last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only",
"max_below_min": "Max value should be greater or equal to min value",
"max_below_min_humidity": "Max humidity value should be greater than min humidity value",
"max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value",
"max_below_min_temperature": "Max temperature value should be greater than min temperature value",
@@ -1307,13 +1296,6 @@
"template": "Template"
}
},
"number_mode": {
"options": {
"auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]",
"box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]",
"slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]"
}
},
"on_command_type": {
"options": {
"brightness": "Brightness",
@@ -1333,7 +1315,6 @@
"light": "[%key:component::light::title%]",
"lock": "[%key:component::lock::title%]",
"notify": "[%key:component::notify::title%]",
"number": "[%key:component::number::title%]",
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]"
}

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

@@ -17,7 +17,6 @@ from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN
from .device import ONVIFDevice
from .entity import ONVIFBaseEntity
from .util import build_event_entity_names
async def async_setup_entry(
@@ -25,45 +24,36 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ONVIF binary sensor platform."""
"""Set up a ONVIF binary sensor."""
device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id]
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)
for event in device.events.get_platform("binary_sensor")
}
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
)
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) 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)
@@ -75,11 +65,7 @@ class ONVIFBinarySensor(ONVIFBaseEntity, RestoreEntity, BinarySensorEntity):
_attr_unique_id: str
def __init__(
self,
uid: str,
device: ONVIFDevice,
name: str | None = None,
entry: er.RegistryEntry | None = None,
self, uid: str, device: ONVIFDevice, entry: er.RegistryEntry | None = None
) -> None:
"""Initialize the ONVIF binary sensor."""
self._attr_unique_id = uid
@@ -92,13 +78,12 @@ class ONVIFBinarySensor(ONVIFBaseEntity, RestoreEntity, BinarySensorEntity):
else:
event = device.events.get_uid(uid)
assert event
assert name
self._attr_device_class = try_parse_enum(
BinarySensorDeviceClass, event.device_class
)
self._attr_entity_category = event.entity_category
self._attr_entity_registry_enabled_default = event.entity_enabled
self._attr_name = f"{device.name} {name}"
self._attr_name = f"{device.name} {event.name}"
self._attr_is_on = event.value
super().__init__(device)

View File

@@ -16,7 +16,6 @@ from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN
from .device import ONVIFDevice
from .entity import ONVIFBaseEntity
from .util import build_event_entity_names
async def async_setup_entry(
@@ -24,43 +23,34 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ONVIF sensor platform."""
"""Set up a ONVIF binary sensor."""
device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id]
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)
for event in device.events.get_platform("sensor")
}
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)
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) 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)
@@ -71,13 +61,9 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor):
_attr_should_poll = False
def __init__(
self,
uid,
device: ONVIFDevice,
name: str | None = None,
entry: er.RegistryEntry | None = None,
self, uid, device: ONVIFDevice, entry: er.RegistryEntry | None = None
) -> None:
"""Initialize the ONVIF sensor."""
"""Initialize the ONVIF binary sensor."""
self._attr_unique_id = uid
if entry is not None:
self._attr_device_class = try_parse_enum(
@@ -89,13 +75,12 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor):
else:
event = device.events.get_uid(uid)
assert event
assert name
self._attr_device_class = try_parse_enum(
SensorDeviceClass, event.device_class
)
self._attr_entity_category = event.entity_category
self._attr_entity_registry_enabled_default = event.entity_enabled
self._attr_name = f"{device.name} {name}"
self._attr_name = f"{device.name} {event.name}"
self._attr_native_unit_of_measurement = event.unit_of_measurement
self._attr_native_value = event.value

View File

@@ -2,47 +2,10 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any
from zeep.exceptions import Fault
from .models import Event
def build_event_entity_names(events: list[Event]) -> dict[str, str]:
"""Build entity names for events, with index appended for duplicates.
When multiple events share the same base name, a sequential index
is appended to distinguish them (sorted by UID).
Args:
events: List of events to build entity names for.
Returns:
Dictionary mapping event UIDs to their entity names.
"""
# Group events by name
events_by_name: dict[str, list[Event]] = defaultdict(list)
for event in events:
events_by_name[event.name].append(event)
# Build entity names, appending index when there are duplicates
entity_names: dict[str, str] = {}
for name, name_events in events_by_name.items():
if len(name_events) == 1:
# No duplicates, use name as-is
entity_names[name_events[0].uid] = name
continue
# Sort by UID and assign sequential indices
sorted_events = sorted(name_events, key=lambda e: e.uid)
for index, event in enumerate(sorted_events, start=1):
entity_names[event.uid] = f"{name} {index}"
return entity_names
def extract_subcodes_as_strings(subcodes: Any) -> list[str]:
"""Stringify ONVIF subcodes."""

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

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

@@ -6,5 +6,5 @@
"iot_class": "assumed_state",
"loggers": ["raspyrfm_client"],
"quality_scale": "legacy",
"requirements": ["raspyrfm-client==1.2.9"]
"requirements": ["raspyrfm-client==1.2.8"]
}

View File

@@ -2490,7 +2490,7 @@ class BaseMigration(ABC):
start_schema_version: int,
migration_changes: dict[str, int],
) -> None:
"""Initialize a new BaseMigration.
"""Initialize a new BaseRunTimeMigration.
:param initial_schema_version: The schema version the database was created with.
:param start_schema_version: The schema version when starting the migration.
@@ -2964,12 +2964,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
_drop_foreign_key_constraints(
session_maker, instance.engine, TABLE_STATES, "event_id"
)
except (InternalError, OperationalError) as err:
_LOGGER.debug(
"Could not drop foreign key constraint on states.event_id, "
"will try again later",
exc_info=err,
)
except (InternalError, OperationalError):
fk_remove_ok = False
else:
fk_remove_ok = True

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

@@ -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,
@@ -270,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,9 +185,12 @@ 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
if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
@@ -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

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

@@ -109,8 +109,6 @@ PRESET_MODE_TO_HA = {
"quiet": "quiet",
"longWind": "long_wind",
"smart": "smart",
"motionIndirect": "motion_indirect",
"motionDirect": "motion_direct",
}
HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()}

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"

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