Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Bottein
b70bd8402e Add frontend system storage 2025-11-06 14:57:31 +01:00
186 changed files with 1444 additions and 10371 deletions

View File

@@ -88,10 +88,6 @@ jobs:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- arch: armv7
- arch: armhf
- arch: i386
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

4
CODEOWNERS generated
View File

@@ -1539,8 +1539,8 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali/ @niracler
/tests/components/sunricher_dali/ @niracler
/homeassistant/components/sunricher_dali_center/ @niracler
/tests/components/sunricher_dali_center/ @niracler
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

View File

@@ -1,10 +1,7 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -39,11 +39,11 @@ from .const import (
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_ADB_SERVER_PORT,
DEFAULT_DEVICE_CLASS,
DEFAULT_EXCLUDE_UNNAMED_APPS,
DEFAULT_GET_SOURCES,
DEFAULT_PORT,
DEFAULT_SCREENCAP_INTERVAL,
DEVICE_AUTO,
DEVICE_CLASSES,
DOMAIN,
PROP_ETHMAC,
@@ -89,14 +89,8 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_DEVICE_CLASS, default=DEVICE_AUTO): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=k, label=v)
for k, v in DEVICE_CLASSES.items()
],
translation_key="device_class",
)
vol.Required(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In(
DEVICE_CLASSES
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
},

View File

@@ -15,19 +15,15 @@ CONF_TURN_OFF_COMMAND = "turn_off_command"
CONF_TURN_ON_COMMAND = "turn_on_command"
DEFAULT_ADB_SERVER_PORT = 5037
DEFAULT_DEVICE_CLASS = "auto"
DEFAULT_EXCLUDE_UNNAMED_APPS = False
DEFAULT_GET_SOURCES = True
DEFAULT_PORT = 5555
DEFAULT_SCREENCAP_INTERVAL = 5
DEVICE_AUTO = "auto"
DEVICE_ANDROIDTV = "androidtv"
DEVICE_FIRETV = "firetv"
DEVICE_CLASSES = {
DEVICE_AUTO: "auto",
DEVICE_ANDROIDTV: "Android TV",
DEVICE_FIRETV: "Fire TV",
}
DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
PROP_ETHMAC = "ethmac"
PROP_SERIALNO = "serialno"

View File

@@ -65,13 +65,6 @@
}
}
},
"selector": {
"device_class": {
"options": {
"auto": "Auto-detect device type"
}
}
},
"services": {
"adb_command": {
"description": "Sends an ADB command to an Android / Fire TV device.",

View File

@@ -9,7 +9,7 @@ from brother import Brother, SnmpError
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
CONF_COMMUNITY,
@@ -50,15 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
coordinator = BrotherDataUpdateCoordinator(hass, entry, brother)
await coordinator.async_config_entry_first_refresh()
if brother.serial.lower() != entry.unique_id:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="serial_mismatch",
translation_placeholders={
"device": entry.title,
},
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -1,30 +0,0 @@
"""Define the Brother entity."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BrotherDataUpdateCoordinator
class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
"""Define a Brother Printer entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BrotherDataUpdateCoordinator,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.brother.host}/",
identifiers={(DOMAIN, coordinator.brother.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)

View File

@@ -19,12 +19,13 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
from .entity import BrotherPrinterEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -332,9 +333,12 @@ async def async_setup_entry(
)
class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
"""Define a Brother Printer sensor."""
class BrotherPrinterSensor(
CoordinatorEntity[BrotherDataUpdateCoordinator], SensorEntity
):
"""Define an Brother Printer sensor."""
_attr_has_entity_name = True
entity_description: BrotherSensorEntityDescription
def __init__(
@@ -344,7 +348,16 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.brother.host}/",
identifiers={(DOMAIN, coordinator.brother.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)
self._attr_native_value = description.value(coordinator.data)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description

View File

@@ -207,9 +207,6 @@
"cannot_connect": {
"message": "An error occurred while connecting to the {device} printer: {error}"
},
"serial_mismatch": {
"message": "The serial number for {device} doesn't match the one in the configuration. It's possible that the two Brother printers have swapped IP addresses. Restore the previous IP address configuration or reconfigure the devices with Home Assistant."
},
"update_error": {
"message": "An error occurred while retrieving data from the {device} printer: {error}"
}

View File

@@ -71,11 +71,8 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
services = await account_link.async_fetch_available_services(
hass.data[DATA_CLOUD]
)
except (aiohttp.ClientError, TimeoutError) as err:
raise config_entry_oauth2_flow.ImplementationUnavailableError(
"Cannot provide OAuth2 implementation for cloud services. "
"Failed to fetch from account link server."
) from err
except (aiohttp.ClientError, TimeoutError):
return []
hass.data[DATA_SERVICES] = services

View File

@@ -151,12 +151,14 @@ ECOWITT_SENSORS_MAPPING: Final = {
key="RAIN_COUNT_MM",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=1,
),
EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription(
key="RAIN_COUNT_INCHES",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
),
EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription(

View File

@@ -2,9 +2,7 @@
from __future__ import annotations
import logging
from aioesphomeapi import APIClient, APIConnectionError
from aioesphomeapi import APIClient
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
@@ -22,12 +20,9 @@ from homeassistant.helpers.typing import ConfigType
from . import assist_satellite, dashboard, ffmpeg_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
CLIENT_INFO = f"Home Assistant {ha_version}"
@@ -80,12 +75,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
"""Unload an esphome config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, entry.runtime_data.loaded_platforms
entry_data = await cleanup_instance(entry)
return await hass.config_entries.async_unload_platforms(
entry, entry_data.loaded_platforms
)
if unload_ok:
await cleanup_instance(entry)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
@@ -96,57 +89,3 @@ async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) ->
hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
)
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
await _async_clear_dynamic_encryption_key(hass, entry)
async def _async_clear_dynamic_encryption_key(
hass: HomeAssistant, entry: ESPHomeConfigEntry
) -> None:
"""Clear the dynamic encryption key on the device and from storage."""
if entry.unique_id is None or entry.data.get(CONF_NOISE_PSK) is None:
return
# Only clear the key if it's stored in our storage, meaning it was
# dynamically generated by us and not user-provided
storage = await async_get_encryption_key_storage(hass)
if await storage.async_get_key(entry.unique_id) is None:
return
host: str = entry.data[CONF_HOST]
port: int = entry.data[CONF_PORT]
password: str | None = entry.data[CONF_PASSWORD]
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
zeroconf_instance = await zeroconf.async_get_instance(hass)
cli = APIClient(
host,
port,
password,
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
timezone=hass.config.time_zone,
)
try:
await cli.connect()
# Clear the encryption key on the device by passing an empty key
if not await cli.noise_encryption_set_key(b""):
_LOGGER.debug(
"Could not clear dynamic encryption key for ESPHome device %s: Device rejected key removal",
entry.unique_id,
)
return
except APIConnectionError as exc:
_LOGGER.debug(
"Could not connect to ESPHome device %s to clear dynamic encryption key: %s",
entry.unique_id,
exc,
)
return
finally:
await cli.disconnect()
await storage.async_remove_key(entry.unique_id)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.9"]
"requirements": ["libpyfoscamcgi==0.0.8"]
}

View File

@@ -263,9 +263,6 @@ class Panel:
# Title to show in the sidebar
sidebar_title: str | None = None
# If the panel should be visible by default in the sidebar
sidebar_default_visible: bool = True
# Url to show the panel in the frontend
frontend_url_path: str
@@ -283,7 +280,6 @@ class Panel:
component_name: str,
sidebar_title: str | None,
sidebar_icon: str | None,
sidebar_default_visible: bool,
frontend_url_path: str | None,
config: dict[str, Any] | None,
require_admin: bool,
@@ -297,7 +293,6 @@ class Panel:
self.config = config
self.require_admin = require_admin
self.config_panel_domain = config_panel_domain
self.sidebar_default_visible = sidebar_default_visible
@callback
def to_response(self) -> PanelResponse:
@@ -306,7 +301,6 @@ class Panel:
"component_name": self.component_name,
"icon": self.sidebar_icon,
"title": self.sidebar_title,
"default_visible": self.sidebar_default_visible,
"config": self.config,
"url_path": self.frontend_url_path,
"require_admin": self.require_admin,
@@ -321,7 +315,6 @@ def async_register_built_in_panel(
component_name: str,
sidebar_title: str | None = None,
sidebar_icon: str | None = None,
sidebar_default_visible: bool = True,
frontend_url_path: str | None = None,
config: dict[str, Any] | None = None,
require_admin: bool = False,
@@ -334,7 +327,6 @@ def async_register_built_in_panel(
component_name,
sidebar_title,
sidebar_icon,
sidebar_default_visible,
frontend_url_path,
config,
require_admin,
@@ -887,7 +879,6 @@ class PanelResponse(TypedDict):
component_name: str
icon: str | None
title: str | None
default_visible: bool
config: dict[str, Any] | None
url_path: str
require_admin: bool

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251105.0"]
"requirements": ["home-assistant-frontend==20251103.0"]
}

View File

@@ -15,7 +15,9 @@ from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
@@ -23,6 +25,9 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_set_user_data)
websocket_api.async_register_command(hass, websocket_get_user_data)
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
websocket_api.async_register_command(hass, websocket_set_system_data)
websocket_api.async_register_command(hass, websocket_get_system_data)
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
@@ -83,6 +88,62 @@ class _UserStore(Store[dict[str, Any]]):
)
async def async_system_store(hass: HomeAssistant) -> SystemStore:
"""Access the system store."""
if DATA_SYSTEM_STORAGE not in hass.data:
store = hass.data[DATA_SYSTEM_STORAGE] = SystemStore(hass)
await store.async_load()
return hass.data[DATA_SYSTEM_STORAGE]
class SystemStore:
"""System store for frontend data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the system store."""
self._store = _SystemStore(hass)
self.data: dict[str, Any] = {}
self.subscriptions: dict[str | None, list[Callable[[], None]]] = {}
async def async_load(self) -> None:
"""Load the data from the store."""
self.data = await self._store.async_load() or {}
async def async_set_item(self, key: str, value: Any) -> None:
"""Set an item and save the store."""
self.data[key] = value
await self._store.async_save(self.data)
for cb in self.subscriptions.get(None, []):
cb()
for cb in self.subscriptions.get(key, []):
cb()
@callback
def async_subscribe(
self, key: str | None, on_update_callback: Callable[[], None]
) -> Callable[[], None]:
"""Subscribe to store updates."""
self.subscriptions.setdefault(key, []).append(on_update_callback)
def unsubscribe() -> None:
"""Unsubscribe from the store."""
self.subscriptions[key].remove(on_update_callback)
return unsubscribe
class _SystemStore(Store[dict[str, Any]]):
"""System store for frontend data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the system store."""
super().__init__(
hass,
STORAGE_VERSION_SYSTEM_DATA,
"frontend.system_data",
)
def with_user_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
@@ -107,6 +168,28 @@ def with_user_store(
return with_user_store_func
def with_system_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate function to provide system store."""
@wraps(orig_func)
async def with_system_store_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide system store to function."""
store = await async_system_store(hass)
await orig_func(hass, connection, msg, store)
return with_system_store_func
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_user_data",
@@ -169,3 +252,74 @@ async def websocket_subscribe_user_data(
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_system_data",
vol.Required("key"): str,
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
}
)
@websocket_api.async_response
@with_system_store
async def websocket_set_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle set system data command."""
if not connection.user.is_admin:
connection.send_error(msg["id"], "unauthorized", "Admin access required")
return
await store.async_set_item(msg["key"], msg["value"])
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{vol.Required("type"): "frontend/get_system_data", vol.Optional("key"): str}
)
@websocket_api.async_response
@with_system_store
async def websocket_get_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle get system data command."""
data = store.data
connection.send_result(
msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/subscribe_system_data",
vol.Optional("key"): str,
}
)
@websocket_api.async_response
@with_system_store
async def websocket_subscribe_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle subscribe to system data command."""
key: str | None = msg.get("key")
def on_data_update() -> None:
"""Handle system data update."""
data = store.data
connection.send_event(
msg["id"], {"value": data.get(key) if key is not None else data}
)
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])

View File

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

View File

@@ -39,8 +39,6 @@ from .const import (
NABU_CASA_FIRMWARE_RELEASES_URL,
PID,
PRODUCT,
RADIO_TX_POWER_DBM_BY_COUNTRY,
RADIO_TX_POWER_DBM_DEFAULT,
SERIAL_NUMBER,
VID,
)
@@ -105,21 +103,6 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
next_step_id="finish_thread_installation",
)
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
country = self.hass.config.country
if country is None:
tx_power = RADIO_TX_POWER_DBM_DEFAULT
else:
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
country, RADIO_TX_POWER_DBM_DEFAULT
)
return {
"tx_power": tx_power,
}
class HomeAssistantConnectZBT2ConfigFlow(
ZBT2FirmwareMixin,

View File

@@ -1,7 +1,5 @@
"""Constants for the Home Assistant Connect ZBT-2 integration."""
from homeassistant.generated.countries import COUNTRIES
DOMAIN = "homeassistant_connect_zbt2"
NABU_CASA_FIRMWARE_RELEASES_URL = (
@@ -19,59 +17,3 @@ VID = "vid"
DEVICE = "device"
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
RADIO_TX_POWER_DBM_DEFAULT = 8
RADIO_TX_POWER_DBM_BY_COUNTRY = {
# EU Member States
"AT": 10,
"BE": 10,
"BG": 10,
"HR": 10,
"CY": 10,
"CZ": 10,
"DK": 10,
"EE": 10,
"FI": 10,
"FR": 10,
"DE": 10,
"GR": 10,
"HU": 10,
"IE": 10,
"IT": 10,
"LV": 10,
"LT": 10,
"LU": 10,
"MT": 10,
"NL": 10,
"PL": 10,
"PT": 10,
"RO": 10,
"SK": 10,
"SI": 10,
"ES": 10,
"SE": 10,
# EEA Members
"IS": 10,
"LI": 10,
"NO": 10,
# Standards harmonized with RED or ETSI
"CH": 10,
"GB": 10,
"TR": 10,
"AL": 10,
"BA": 10,
"GE": 10,
"MD": 10,
"ME": 10,
"MK": 10,
"RS": 10,
"UA": 10,
# Other CEPT nations
"AD": 10,
"AZ": 10,
"MC": 10,
"SM": 10,
"VA": 10,
}
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES

View File

@@ -456,10 +456,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_continue_zigbee()
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
return {}
async def async_step_continue_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -482,7 +478,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
"radio_type": "ezsp",
"flow_strategy": self._zigbee_flow_strategy,
**self._extra_zha_hardware_options(),
},
)
return self._continue_zha_flow(result)

View File

@@ -38,7 +38,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401
@@ -110,7 +109,7 @@ HTTP_SCHEMA: Final = vol.All(
cv.deprecated(CONF_BASE_URL),
vol.Schema(
{
vol.Optional(CONF_SERVER_HOST): vol.All(
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
@@ -208,17 +207,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if conf is None:
conf = cast(ConfData, HTTP_SCHEMA({}))
if CONF_SERVER_HOST in conf and is_hassio(hass):
ir.async_create_issue(
hass,
DOMAIN,
"server_host_may_break_hassio",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="server_host_may_break_hassio",
)
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
server_host = conf[CONF_SERVER_HOST]
server_port = conf[CONF_SERVER_PORT]
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)

View File

@@ -1,9 +1,5 @@
{
"issues": {
"server_host_may_break_hassio": {
"description": "The `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
},
"ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL"

View File

@@ -54,14 +54,15 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
API_VERSION_2,
BATCH_BUFFER_SIZE,
BATCH_TIMEOUT,
CATCHING_UP_MESSAGE,
CLIENT_ERROR_V1,
CLIENT_ERROR_V2,
CODE_INVALID_INPUTS,
COMPONENT_CONFIG_SCHEMA_BATCH,
COMPONENT_CONFIG_SCHEMA_CONNECTION,
CONF_API_VERSION,
CONF_BATCH_BUFFER_SIZE,
CONF_BATCH_TIMEOUT,
CONF_BUCKET,
CONF_COMPONENT_CONFIG,
CONF_COMPONENT_CONFIG_DOMAIN,
@@ -193,7 +194,12 @@ _INFLUX_BASE_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend(
)
INFLUX_SCHEMA = vol.All(
_INFLUX_BASE_SCHEMA.extend(COMPONENT_CONFIG_SCHEMA_CONNECTION),
_INFLUX_BASE_SCHEMA.extend(
{
**COMPONENT_CONFIG_SCHEMA_CONNECTION,
**COMPONENT_CONFIG_SCHEMA_BATCH,
}
),
validate_version_specific_config,
create_influx_url,
)
@@ -496,7 +502,9 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
event_to_json = _generate_event_to_json(conf)
max_tries = conf.get(CONF_RETRY_COUNT)
instance = hass.data[DOMAIN] = InfluxThread(hass, influx, event_to_json, max_tries)
instance = hass.data[DOMAIN] = InfluxThread(
hass, influx, event_to_json, max_tries, conf
)
instance.start()
def shutdown(event):
@@ -513,7 +521,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class InfluxThread(threading.Thread):
"""A threaded event handler class."""
def __init__(self, hass, influx, event_to_json, max_tries):
def __init__(self, hass, influx, event_to_json, max_tries, config):
"""Initialize the listener."""
threading.Thread.__init__(self, name=DOMAIN)
self.queue: queue.SimpleQueue[threading.Event | tuple[float, Event] | None] = (
@@ -524,6 +532,8 @@ class InfluxThread(threading.Thread):
self.max_tries = max_tries
self.write_errors = 0
self.shutdown = False
self._batch_timeout = config[CONF_BATCH_TIMEOUT]
self.batch_buffer_size = config[CONF_BATCH_BUFFER_SIZE]
hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener)
@callback
@@ -532,23 +542,31 @@ class InfluxThread(threading.Thread):
item = (time.monotonic(), event)
self.queue.put(item)
@staticmethod
def batch_timeout():
@property
def batch_timeout(self):
"""Return number of seconds to wait for more events."""
return BATCH_TIMEOUT
return self._batch_timeout
def get_events_json(self):
"""Return a batch of events formatted for writing."""
queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries * RETRY_DELAY
start_time = time.monotonic()
batch_timeout = self.batch_timeout()
count = 0
json = []
dropped = 0
with suppress(queue.Empty):
while len(json) < BATCH_BUFFER_SIZE and not self.shutdown:
timeout = None if count == 0 else self.batch_timeout()
while len(json) < self.batch_buffer_size and not self.shutdown:
if count > 0 and time.monotonic() - start_time >= batch_timeout:
break
timeout = (
None
if count == 0
else batch_timeout - (time.monotonic() - start_time)
)
item = self.queue.get(timeout=timeout)
count += 1

View File

@@ -47,6 +47,9 @@ CONF_FUNCTION = "function"
CONF_QUERY = "query"
CONF_IMPORTS = "imports"
CONF_BATCH_BUFFER_SIZE = "batch_buffer_size"
CONF_BATCH_TIMEOUT = "batch_timeout"
DEFAULT_DATABASE = "home_assistant"
DEFAULT_HOST_V2 = "us-west-2-1.aws.cloud2.influxdata.com"
DEFAULT_SSL_V2 = True
@@ -60,6 +63,9 @@ DEFAULT_RANGE_STOP = "now()"
DEFAULT_FUNCTION_FLUX = "|> limit(n: 1)"
DEFAULT_MEASUREMENT_ATTR = "unit_of_measurement"
DEFAULT_BATCH_BUFFER_SIZE = 100
DEFAULT_BATCH_TIMEOUT = 1
INFLUX_CONF_MEASUREMENT = "measurement"
INFLUX_CONF_TAGS = "tags"
INFLUX_CONF_TIME = "time"
@@ -76,8 +82,6 @@ TIMEOUT = 10 # seconds
RETRY_DELAY = 20
QUEUE_BACKLOG_SECONDS = 30
RETRY_INTERVAL = 60 # seconds
BATCH_TIMEOUT = 1
BATCH_BUFFER_SIZE = 100
LANGUAGE_INFLUXQL = "influxQL"
LANGUAGE_FLUX = "flux"
TEST_QUERY_V1 = "SHOW DATABASES;"
@@ -152,3 +156,10 @@ COMPONENT_CONFIG_SCHEMA_CONNECTION = {
vol.Inclusive(CONF_ORG, "v2_authentication"): cv.string,
vol.Optional(CONF_BUCKET, default=DEFAULT_BUCKET): cv.string,
}
COMPONENT_CONFIG_SCHEMA_BATCH = {
vol.Optional(
CONF_BATCH_BUFFER_SIZE, default=DEFAULT_BATCH_BUFFER_SIZE
): cv.positive_int,
vol.Optional(CONF_BATCH_TIMEOUT, default=DEFAULT_BATCH_TIMEOUT): cv.positive_float,
}

View File

@@ -622,7 +622,6 @@ ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
usage_period=USAGE_MONTHLY,
start_date_fn=lambda today: today,
end_date_fn=lambda today: today,
state_class=SensorStateClass.TOTAL_INCREASING,
),
ThinQEnergySensorEntityDescription(
key="last_month",

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2025.0.0"]
"requirements": ["pylitterbot==2024.2.7"]
}

View File

@@ -408,20 +408,6 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
if not alarm_code or code == alarm_code:
return
current_context = (
self._context if hasattr(self, "_context") and self._context else None
)
user_id_from_context = current_context.user_id if current_context else None
self.hass.bus.async_fire(
"manual_alarm_bad_code_attempt",
{
"entity_id": self.entity_id,
"user_id": user_id_from_context,
"target_state": state,
},
)
raise ServiceValidationError(
"Invalid alarm code provided",
translation_domain=DOMAIN,

View File

@@ -26,8 +26,8 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .actions import get_music_assistant_client, register_actions
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
from .services import get_music_assistant_client, register_actions
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.climate import (
@@ -14,44 +13,18 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from .const import DOMAIN, MASTER_THERMOSTATS
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
ERROR_NO_SCHEDULE = "set_schedule_first"
PARALLEL_UPDATES = 0
@dataclass
class PlugwiseClimateExtraStoredData(ExtraStoredData):
"""Object to hold extra stored data."""
last_active_schedule: str | None
previous_action_mode: str | None
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the text data."""
return {
"last_active_schedule": self.last_active_schedule,
"previous_action_mode": self.previous_action_mode,
}
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData:
"""Initialize a stored data object from a dict."""
return cls(
last_active_schedule=restored.get("last_active_schedule"),
previous_action_mode=restored.get("previous_action_mode"),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
@@ -83,26 +56,14 @@ async def async_setup_entry(
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Representation of a Plugwise thermostat."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
_last_active_schedule: str | None = None
_previous_action_mode: str | None = HVACAction.HEATING.value
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added."""
await super().async_added_to_hass()
if extra_data := await self.async_get_last_extra_data():
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
extra_data.as_dict()
)
self._last_active_schedule = plugwise_extra_data.last_active_schedule
self._previous_action_mode = plugwise_extra_data.previous_action_mode
_previous_mode: str = "heating"
def __init__(
self,
@@ -115,6 +76,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
gateway_id: str = coordinator.api.gateway_id
self._gateway_data = coordinator.data[gateway_id]
self._location = device_id
if (location := self.device.get("location")) is not None:
self._location = location
@@ -143,19 +105,25 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
self.device["thermostat"]["resolution"], 0.1
)
def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None:
"""Return the previous action-mode when the regulation-mode is not heating or cooling.
Helper for set_hvac_mode().
"""
# When no cooling available, _previous_mode is always heating
if (
"regulation_modes" in self._gateway_data
and "cooling" in self._gateway_data["regulation_modes"]
):
mode = self._gateway_data["select_regulation_mode"]
if mode in ("cooling", "heating"):
self._previous_mode = mode
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
@property
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
"""Return text specific state data to be restored."""
return PlugwiseClimateExtraStoredData(
last_active_schedule=self._last_active_schedule,
previous_action_mode=self._previous_action_mode,
)
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach.
@@ -202,10 +170,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
if self.coordinator.api.cooling_present:
if "regulation_modes" in self._gateway_data:
selected = self._gateway_data.get("select_regulation_mode")
if selected == HVACAction.COOLING.value:
if self._gateway_data["select_regulation_mode"] == "cooling":
hvac_modes.append(HVACMode.COOL)
if selected == HVACAction.HEATING.value:
if self._gateway_data["select_regulation_mode"] == "heating":
hvac_modes.append(HVACMode.HEAT)
else:
hvac_modes.append(HVACMode.HEAT_COOL)
@@ -217,16 +184,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
@property
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation if supported."""
# Keep track of the previous hvac_action mode.
# When no cooling available, _previous_action_mode is always heating
if (
"regulation_modes" in self._gateway_data
and HVACAction.COOLING.value in self._gateway_data["regulation_modes"]
):
mode = self._gateway_data["select_regulation_mode"]
if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value):
self._previous_action_mode = mode
# Keep track of the previous action-mode
self._previous_action_mode(self.coordinator)
if (action := self.device.get("control_state")) is not None:
return HVACAction(action)
@@ -260,33 +219,14 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
return
if hvac_mode == HVACMode.OFF:
await self.coordinator.api.set_regulation_mode(hvac_mode.value)
await self.coordinator.api.set_regulation_mode(hvac_mode)
else:
current = self.device.get("select_schedule")
desired = current
# Capture the last valid schedule
if desired and desired != "off":
self._last_active_schedule = desired
elif desired == "off":
desired = self._last_active_schedule
# Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring
if hvac_mode == HVACMode.AUTO and not desired:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=ERROR_NO_SCHEDULE,
)
await self.coordinator.api.set_schedule_state(
self._location,
STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF,
desired,
"on" if hvac_mode == HVACMode.AUTO else "off",
)
if self.hvac_mode == HVACMode.OFF and self._previous_action_mode:
await self.coordinator.api.set_regulation_mode(
self._previous_action_mode
)
if self.hvac_mode == HVACMode.OFF:
await self.coordinator.api.set_regulation_mode(self._previous_mode)
@plugwise_command
async def async_set_preset_mode(self, preset_mode: str) -> None:

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.8.3"],
"requirements": ["plugwise==1.8.2"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -314,9 +314,6 @@
"invalid_xml_data": {
"message": "[%key:component::plugwise::config::error::response_error%]"
},
"set_schedule_first": {
"message": "Failed setting HVACMode, set a schedule first."
},
"unsupported_firmware": {
"message": "[%key:component::plugwise::config::error::unsupported%]"
}

View File

@@ -3,15 +3,13 @@
from __future__ import annotations
import logging
from typing import Any
from pooldose.client import PooldoseClient
from pooldose.request_status import RequestStatus
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from .coordinator import PooldoseConfigEntry, PooldoseCoordinator
@@ -20,36 +18,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
"""Migrate old entry."""
# Version 1.1 -> 1.2: Migrate entity unique IDs
# - ofa_orp_value -> ofa_orp_time
# - ofa_ph_value -> ofa_ph_time
if entry.version == 1 and entry.minor_version < 2:
@callback
def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
"""Migrate entity unique IDs for pooldose sensors."""
new_unique_id = entity_entry.unique_id
# Check if this entry needs migration
if "_ofa_orp_value" in new_unique_id:
new_unique_id = new_unique_id.replace("_ofa_orp_value", "_ofa_orp_time")
elif "_ofa_ph_value" in new_unique_id:
new_unique_id = new_unique_id.replace("_ofa_ph_value", "_ofa_ph_time")
else:
# No migration needed
return None
return {"new_unique_id": new_unique_id}
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(entry, version=1, minor_version=2)
return True
async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
"""Set up Seko PoolDose from a config entry."""
# Get host from config entry data (connection-critical configuration)

View File

@@ -31,7 +31,6 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for the Pooldose integration including DHCP discovery."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow and store the discovered IP address and MAC."""

View File

@@ -1,10 +1,10 @@
{
"entity": {
"sensor": {
"ofa_orp_time": {
"ofa_orp_value": {
"default": "mdi:clock"
},
"ofa_ph_time": {
"ofa_ph_value": {
"default": "mdi:clock"
},
"orp": {

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-pooldose==0.7.8"]
"requirements": ["python-pooldose==0.7.0"]
}

View File

@@ -48,8 +48,8 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
options=["proportional", "on_off", "timed"],
),
SensorEntityDescription(
key="ofa_ph_time",
translation_key="ofa_ph_time",
key="ofa_ph_value",
translation_key="ofa_ph_value",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
@@ -72,8 +72,8 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
options=["off", "proportional", "on_off", "timed"],
),
SensorEntityDescription(
key="ofa_orp_time",
translation_key="ofa_orp_time",
key="ofa_orp_value",
translation_key="ofa_orp_value",
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,

View File

@@ -34,10 +34,10 @@
},
"entity": {
"sensor": {
"ofa_orp_time": {
"ofa_orp_value": {
"name": "ORP overfeed alert time"
},
"ofa_ph_time": {
"ofa_ph_value": {
"name": "pH overfeed alert time"
},
"orp": {

View File

@@ -19,7 +19,6 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -258,11 +257,10 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 2:
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
# 1.2 Migrate subentries to include configured numbers to title
if config_entry.version == 1 and config_entry.minor_version == 1:
for subentry in config_entry.subentries.values():
property_map = {
@@ -280,21 +278,6 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(config_entry, minor_version=2)
# 2.1 Migrate all entity unique IDs to replace "satel" prefix with config entry ID, allows multiple entries to be configured
if config_entry.version == 1:
@callback
def migrate_unique_id(entity_entry: RegistryEntry) -> dict[str, str]:
"""Migrate the unique ID to a new format."""
return {
"new_unique_id": entity_entry.unique_id.replace(
"satel", config_entry.entry_id
)
}
await async_migrate_entries(hass, config_entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,

View File

@@ -52,11 +52,7 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraAlarmPanel(
controller,
zone_name,
arm_home_mode,
partition_num,
config_entry.entry_id,
controller, zone_name, arm_home_mode, partition_num
)
],
config_subentry_id=subentry.subentry_id,
@@ -73,12 +69,10 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id
) -> None:
def __init__(self, controller, name, arm_home_mode, partition_id) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller

View File

@@ -53,7 +53,6 @@ async def async_setup_entry(
zone_type,
CONF_ZONES,
SIGNAL_ZONES_UPDATED,
config_entry.entry_id,
)
],
config_subentry_id=subentry.subentry_id,
@@ -78,7 +77,6 @@ async def async_setup_entry(
ouput_type,
CONF_OUTPUTS,
SIGNAL_OUTPUTS_UPDATED,
config_entry.entry_id,
)
],
config_subentry_id=subentry.subentry_id,
@@ -98,11 +96,10 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
zone_type,
sensor_type,
react_to_signal,
config_entry_id,
):
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._attr_unique_id = f"satel_{sensor_type}_{device_number}"
self._name = device_name
self._zone_type = zone_type
self._state = 0

View File

@@ -90,8 +90,8 @@ SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a Satel Integra config flow."""
VERSION = 2
MINOR_VERSION = 1
VERSION = 1
MINOR_VERSION = 2
@staticmethod
@callback
@@ -121,8 +121,6 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
valid = await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
)

View File

@@ -7,5 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["satel_integra"],
"requirements": ["satel-integra==0.3.7"]
"requirements": ["satel-integra==0.3.7"],
"single_config_entry": true
}

View File

@@ -4,9 +4,6 @@
"code_input_description": "Code to toggle switchable outputs"
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},

View File

@@ -46,7 +46,6 @@ async def async_setup_entry(
switchable_output_num,
switchable_output_name,
config_entry.options.get(CONF_CODE),
config_entry.entry_id,
),
],
config_subentry_id=subentry.subentry_id,
@@ -58,10 +57,10 @@ class SatelIntegraSwitch(SwitchEntity):
_attr_should_poll = False
def __init__(self, controller, device_number, device_name, code, config_entry_id):
def __init__(self, controller, device_number, device_name, code):
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
self._attr_unique_id = f"satel_switch_{device_number}"
self._name = device_name
self._state = False
self._code = code

View File

@@ -1,85 +0,0 @@
"""BLE provisioning helpers for Shelly integration."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import logging
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
@dataclass
class ProvisioningState:
"""State for tracking zeroconf discovery during BLE provisioning."""
event: asyncio.Event = field(default_factory=asyncio.Event)
host: str | None = None
port: int | None = None
PROVISIONING_FUTURES: HassKey[dict[str, ProvisioningState]] = HassKey(
"shelly_provisioning_futures"
)
@callback
def async_get_provisioning_registry(
hass: HomeAssistant,
) -> dict[str, ProvisioningState]:
"""Get the provisioning registry, creating it if needed.
This is a helper function for internal use.
It ensures the registry exists without requiring async_setup to run first.
"""
return hass.data.setdefault(PROVISIONING_FUTURES, {})
@callback
def async_register_zeroconf_discovery(
hass: HomeAssistant, mac: str, host: str, port: int
) -> None:
"""Register a zeroconf discovery for a device that was provisioned via BLE.
Called by zeroconf discovery when it finds a device that may have been
provisioned via BLE. If BLE provisioning is waiting for this device,
the host and port will be stored (replacing any previous values).
Multiple zeroconf discoveries can happen (Shelly service, HTTP service, etc.)
and the last one wins.
Args:
hass: Home Assistant instance
mac: Device MAC address (will be normalized)
host: Device IP address/hostname from zeroconf
port: Device port from zeroconf
"""
registry = async_get_provisioning_registry(hass)
normalized_mac = format_mac(mac)
state = registry.get(normalized_mac)
if not state:
_LOGGER.debug(
"No BLE provisioning state found for %s (host %s, port %s)",
normalized_mac,
host,
port,
)
return
_LOGGER.debug(
"Registering zeroconf discovery for %s at %s:%s (replacing previous)",
normalized_mac,
host,
port,
)
# Store host and port (replacing any previous values) and signal the event
state.host = host
state.port = port
state.event.set()

View File

@@ -2,13 +2,9 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final
from collections.abc import Mapping
from typing import Any, Final
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -18,18 +14,10 @@ from aioshelly.exceptions import (
InvalidAuthError,
InvalidHostError,
MacAddressMismatchError,
RpcCallError,
)
from aioshelly.rpc_device import RpcDevice
from aioshelly.zeroconf import async_lookup_device_by_name
from bleak.backends.device import BLEDevice
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_HOST,
@@ -41,27 +29,15 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .ble_provisioning import (
ProvisioningState,
async_get_provisioning_registry,
async_register_zeroconf_discovery,
)
from .const import (
CONF_BLE_SCANNER_MODE,
CONF_GEN,
CONF_SLEEP_PERIOD,
CONF_SSID,
DOMAIN,
LOGGER,
PROVISIONING_TIMEOUT,
BLEScannerMode,
)
from .coordinator import ShellyConfigEntry, async_reconnect_soon
@@ -94,10 +70,6 @@ BLE_SCANNER_OPTIONS = [
INTERNAL_WIFI_AP_IP = "192.168.33.1"
# BLE provisioning flow steps that are in the finishing state
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
async def validate_input(
hass: HomeAssistant,
@@ -173,12 +145,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
port: int = DEFAULT_HTTP_PORT
info: dict[str, Any] = {}
device_info: dict[str, Any] = {}
ble_device: BLEDevice | None = None
device_name: str = ""
wifi_networks: list[dict[str, Any]] = []
selected_ssid: str = ""
_provision_task: asyncio.Task | None = None
_provision_result: ConfigFlowResult | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -296,45 +262,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="credentials", data_schema=vol.Schema(schema), errors=errors
)
def _abort_idle_ble_flows(self, mac: str) -> None:
"""Abort idle BLE provisioning flows for this device.
When zeroconf discovers a device, it means the device is already on WiFi.
If there's an idle BLE flow (user hasn't started provisioning yet), abort it.
Active provisioning flows (do_provision/provision_done) should not be aborted
as they're waiting for zeroconf handoff.
"""
for flow in self._async_in_progress(include_uninitialized=True):
if (
flow["flow_id"] != self.flow_id
and flow["context"].get("unique_id") == mac
and flow["context"].get("source") == "bluetooth"
and flow.get("step_id") not in BLUETOOTH_FINISHING_STEPS
):
LOGGER.debug(
"Aborting idle BLE flow %s for %s (device discovered via zeroconf)",
flow["flow_id"],
mac,
)
self.hass.config_entries.flow.async_abort(flow["flow_id"])
async def _async_handle_zeroconf_mac_discovery(
self, mac: str, host: str, port: int
) -> None:
"""Handle MAC address discovery from zeroconf.
Registers discovery info for BLE handoff and aborts idle BLE flows.
"""
# Register this zeroconf discovery with BLE provisioning in case
# this device was just provisioned via BLE
async_register_zeroconf_discovery(self.hass, mac, host, port)
# Check for idle BLE provisioning flows and abort them since
# device is already on WiFi (discovered via zeroconf)
self._abort_idle_ble_flows(mac)
await self._async_discovered_mac(mac, host)
async def _async_discovered_mac(self, mac: str, host: str) -> None:
"""Abort and reconnect soon if the device with the mac address is already configured."""
if (
@@ -354,313 +281,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
else:
self._abort_if_unique_id_configured({CONF_HOST: host})
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle bluetooth discovery."""
# Parse MAC address from the Bluetooth device name
if not (mac := mac_address_from_name(discovery_info.name)):
return self.async_abort(reason="invalid_discovery_info")
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
if not has_rpc_over_ble(discovery_info.manufacturer_data):
LOGGER.debug(
"Device %s does not have RPC-over-BLE enabled, skipping provisioning",
discovery_info.name,
)
return self.async_abort(reason="invalid_discovery_info")
# Check if already configured - abort if device is already set up
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
# Store BLE device and name for WiFi provisioning
self.ble_device = async_ble_device_from_address(
self.hass, discovery_info.address, connectable=True
)
if not self.ble_device:
return self.async_abort(reason="cannot_connect")
self.device_name = discovery_info.name
self.context.update(
{
"title_placeholders": {"name": discovery_info.name},
}
)
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm bluetooth provisioning."""
if user_input is not None:
return await self.async_step_wifi_scan()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={
"name": self.context["title_placeholders"]["name"]
},
)
async def async_step_wifi_scan(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Scan for WiFi networks via BLE."""
if user_input is not None:
self.selected_ssid = user_input[CONF_SSID]
return await self.async_step_wifi_credentials()
# Scan for WiFi networks via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
try:
self.wifi_networks = await async_scan_wifi_networks(self.ble_device)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err)
# "Writing is not permitted" error means device rejects BLE writes
# and BLE provisioning is disabled - user must use Shelly app
if "not permitted" in str(err):
return self.async_abort(reason="ble_not_permitted")
return await self.async_step_wifi_scan_failed()
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi scan")
return self.async_abort(reason="unknown")
# Create list of SSIDs for selection
# If no networks found, still allow custom SSID entry
ssid_options = [network["ssid"] for network in self.wifi_networks]
return self.async_show_form(
step_id="wifi_scan",
data_schema=vol.Schema(
{
vol.Required(CONF_SSID): SelectSelector(
SelectSelectorConfig(
options=ssid_options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
}
),
)
async def async_step_wifi_scan_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle failed WiFi scan - allow retry."""
if user_input is not None:
# User wants to retry - go back to wifi_scan
return await self.async_step_wifi_scan()
return self.async_show_form(step_id="wifi_scan_failed")
@asynccontextmanager
async def _async_provision_context(
self, mac: str
) -> AsyncIterator[ProvisioningState]:
"""Context manager to register and cleanup provisioning state."""
state = ProvisioningState()
provisioning_registry = async_get_provisioning_registry(self.hass)
normalized_mac = format_mac(mac)
provisioning_registry[normalized_mac] = state
try:
yield state
finally:
provisioning_registry.pop(normalized_mac, None)
async def async_step_wifi_credentials(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Get WiFi credentials and provision device."""
if user_input is not None:
self.selected_ssid = user_input.get(CONF_SSID, self.selected_ssid)
password = user_input[CONF_PASSWORD]
return await self.async_step_do_provision({"password": password})
return self.async_show_form(
step_id="wifi_credentials",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={"ssid": self.selected_ssid},
)
async def _async_provision_wifi_and_wait_for_zeroconf(
self, mac: str, password: str, state: ProvisioningState
) -> ConfigFlowResult | None:
"""Provision WiFi credentials via BLE and wait for zeroconf discovery.
Returns the flow result to be stored in self._provision_result, or None if failed.
"""
# Provision WiFi via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
try:
await async_provision_wifi(self.ble_device, self.selected_ssid, password)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to provision WiFi via BLE: %s", err)
# BLE connection/communication failed - allow retry from network selection
return None
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi provisioning")
return self.async_abort(reason="unknown")
LOGGER.debug(
"WiFi provisioning successful for %s, waiting for zeroconf discovery",
mac,
)
# Two-phase device discovery after WiFi provisioning:
#
# Phase 1: Wait for zeroconf discovery callback (via event)
# - Callback only fires on NEW zeroconf advertisements
# - If device appears on network, we get notified immediately
# - This is the fast path for successful provisioning
#
# Phase 2: Active lookup on timeout (poll)
# - Handles case where device was factory reset and has stale zeroconf data
# - Factory reset devices don't send zeroconf goodbye, leaving stale records
# - The timeout ensures device has enough time to connect to WiFi
# - Active poll forces fresh lookup, ignoring stale cached data
#
# Why not just poll? If we polled immediately, we'd get stale data and
# try to connect right away, causing false failures before device is ready.
try:
await asyncio.wait_for(state.event.wait(), timeout=PROVISIONING_TIMEOUT)
except TimeoutError:
LOGGER.debug("Timeout waiting for zeroconf discovery, trying active lookup")
# No new discovery received - device may have stale zeroconf data
# Do active lookup to force fresh resolution
aiozc = await zeroconf.async_get_async_instance(self.hass)
result = await async_lookup_device_by_name(aiozc, self.device_name)
# If we still don't have a host, provisioning failed
if not result:
LOGGER.debug("Active lookup failed - provisioning unsuccessful")
# Store failure info and return None - provision_done will handle redirect
return None
state.host, state.port = result
else:
LOGGER.debug(
"Zeroconf discovery received for device after WiFi provisioning at %s",
state.host,
)
# Device discovered via zeroconf - get device info and set up directly
if TYPE_CHECKING:
assert state.host is not None
assert state.port is not None
self.host = state.host
self.port = state.port
try:
self.info = await self._async_get_info(self.host, self.port)
except DeviceConnectionError as err:
LOGGER.debug("Failed to connect to device after WiFi provisioning: %s", err)
# Device appeared on network but can't connect - allow retry
return None
if get_info_auth(self.info):
# Device requires authentication - show credentials step
return await self.async_step_credentials()
try:
device_info = await validate_input(
self.hass, self.host, self.port, self.info, {}
)
except DeviceConnectionError as err:
LOGGER.debug("Failed to validate device after WiFi provisioning: %s", err)
# Device info validation failed - allow retry
return None
if not device_info[CONF_MODEL]:
return self.async_abort(reason="firmware_not_fully_provisioned")
# User just provisioned this device - create entry directly without confirmation
return self.async_create_entry(
title=device_info["title"],
data={
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
CONF_MODEL: device_info[CONF_MODEL],
CONF_GEN: device_info[CONF_GEN],
},
)
async def _do_provision(self, password: str) -> None:
"""Provision WiFi credentials to device via BLE."""
if TYPE_CHECKING:
assert self.ble_device is not None
mac = self.unique_id
if TYPE_CHECKING:
assert mac is not None
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
)
)
async def async_step_do_provision(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Execute WiFi provisioning via BLE."""
if not self._provision_task:
if TYPE_CHECKING:
assert user_input is not None
password = user_input["password"]
self._provision_task = self.hass.async_create_task(
self._do_provision(password), eager_start=False
)
if not self._provision_task.done():
return self.async_show_progress(
step_id="do_provision",
progress_action="provisioning",
progress_task=self._provision_task,
)
self._provision_task = None
return self.async_show_progress_done(next_step_id="provision_done")
async def async_step_provision_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle failed provisioning - allow retry."""
if user_input is not None:
# User wants to retry - clear state and go back to wifi_scan
self.selected_ssid = ""
self.wifi_networks = []
return await self.async_step_wifi_scan()
return self.async_show_form(
step_id="provision_failed",
description_placeholders={"ssid": self.selected_ssid},
)
async def async_step_provision_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the result of the provision step."""
result = self._provision_result
self._provision_result = None
# If provisioning failed, redirect to provision_failed step
if result is None:
return await self.async_step_provision_failed()
return result
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -668,25 +288,23 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_not_supported")
host = discovery_info.host
port = discovery_info.port or DEFAULT_HTTP_PORT
# First try to get the mac address from the name
# so we can avoid making another connection to the
# device if we already have it configured
if mac := mac_address_from_name(discovery_info.name):
await self._async_handle_zeroconf_mac_discovery(mac, host, port)
await self._async_discovered_mac(mac, host)
try:
# Devices behind range extender doesn't generate zeroconf packets
# so port is always the default one
self.info = await self._async_get_info(host, port)
self.info = await self._async_get_info(host, DEFAULT_HTTP_PORT)
except DeviceConnectionError:
return self.async_abort(reason="cannot_connect")
if not mac:
# We could not get the mac address from the name
# so need to check here since we just got the info
mac = self.info[CONF_MAC]
await self._async_handle_zeroconf_mac_discovery(mac, host, port)
await self._async_discovered_mac(self.info[CONF_MAC], host)
self.host = host
self.context.update(

View File

@@ -36,10 +36,6 @@ DOMAIN: Final = "shelly"
LOGGER: Logger = getLogger(__package__)
# BLE provisioning
PROVISIONING_TIMEOUT: Final = 35 # 35 seconds to wait for device to connect to WiFi
CONF_SSID: Final = "ssid"
CONF_COAP_PORT: Final = "coap_port"
FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})")

View File

@@ -1,11 +1,6 @@
{
"domain": "shelly",
"name": "Shelly",
"bluetooth": [
{
"local_name": "Shelly*"
}
],
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
"config_flow": true,
"dependencies": ["bluetooth", "http", "network"],
@@ -14,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.16.0"],
"requirements": ["aioshelly==13.15.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -2,20 +2,13 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_on_wifi": "Device is already connected to WiFi and was discovered via the network.",
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
"ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision WiFi credentials, then add the device when it appears on your network.",
"cannot_connect": "Failed to connect to the device. Ensure the device is powered on and within range.",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
"invalid_discovery_info": "Invalid Bluetooth discovery information.",
"ipv6_not_supported": "IPv6 is not supported.",
"mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]",
"no_wifi_networks": "No WiFi networks found during scan.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wifi_provisioned": "WiFi credentials for {ssid} have been provisioned to {name}. The device is connecting to WiFi and will complete setup automatically."
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -26,13 +19,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"progress": {
"provisioning": "Provisioning WiFi credentials and waiting for device to connect"
},
"step": {
"bluetooth_confirm": {
"description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to WiFi.\n\nDo you want to provision WiFi credentials to this device?"
},
"confirm_discovery": {
"description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device."
},
@@ -46,9 +33,6 @@
"username": "Username for the device's web panel."
}
},
"provision_failed": {
"description": "The device did not connect to {ssid}. This may be due to an incorrect password or the network being out of range. Would you like to try again?"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -80,27 +64,6 @@
"port": "The TCP port of the Shelly device to connect to (Gen2+)."
},
"description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
},
"wifi_credentials": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "Password for the WiFi network."
},
"description": "Enter the password for {ssid}."
},
"wifi_scan": {
"data": {
"ssid": "WiFi network"
},
"data_description": {
"ssid": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
},
"description": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
},
"wifi_scan_failed": {
"description": "Failed to scan for WiFi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?"
}
}
},

View File

@@ -505,9 +505,6 @@ KEEP_CAPABILITY_QUIRK: dict[
Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: (
lambda status: status[Attribute.LIGHTING].value is not None
),
Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: (
lambda status: status[Attribute.BEEP].value is not None
),
}

View File

@@ -156,13 +156,6 @@
"sanitize": {
"default": "mdi:lotion"
},
"sound_effect": {
"default": "mdi:volume-high",
"state": {
"off": "mdi:volume-off",
"on": "mdi:volume-high"
}
},
"wrinkle_prevent": {
"default": "mdi:tumble-dryer",
"state": {

View File

@@ -653,9 +653,6 @@
"sanitize": {
"name": "Sanitize"
},
"sound_effect": {
"name": "Sound effect"
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
}

View File

@@ -91,15 +91,6 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[
),
}
CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = {
Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: SmartThingsSwitchEntityDescription(
key=Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP,
translation_key="sound_effect",
status_attribute=Attribute.BEEP,
on_key="on",
on_command=Command.ON,
off_command=Command.OFF,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription(
key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK,
translation_key="bubble_soak",

View File

@@ -9,11 +9,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from .coordinator import (
SMHIConfigEntry,
SMHIDataUpdateCoordinator,
SMHIFireDataUpdateCoordinator,
)
from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
@@ -28,9 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool
coordinator = SMHIDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
fire_coordinator = SMHIFireDataUpdateCoordinator(hass, entry)
await fire_coordinator.async_config_entry_first_refresh()
entry.runtime_data = (coordinator, fire_coordinator)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -5,14 +5,7 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from pysmhi import (
SMHIFireForecast,
SmhiFireForecastException,
SMHIFirePointForecast,
SMHIForecast,
SmhiForecastException,
SMHIPointForecast,
)
from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
@@ -22,9 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
type SMHIConfigEntry = ConfigEntry[
tuple[SMHIDataUpdateCoordinator, SMHIFireDataUpdateCoordinator]
]
type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator]
@dataclass
@@ -36,14 +27,6 @@ class SMHIForecastData:
twice_daily: list[SMHIForecast]
@dataclass
class SMHIFireForecastData:
"""Dataclass for SMHI fire data."""
fire_daily: list[SMHIFireForecast]
fire_hourly: list[SMHIFireForecast]
class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]):
"""A SMHI Data Update Coordinator."""
@@ -88,49 +71,3 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]):
def current(self) -> SMHIForecast:
"""Return the current metrics."""
return self.data.daily[0]
class SMHIFireDataUpdateCoordinator(DataUpdateCoordinator[SMHIFireForecastData]):
"""A SMHI Fire Data Update Coordinator."""
config_entry: SMHIConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None:
"""Initialize the SMHI coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self._smhi_fire_api = SMHIFirePointForecast(
config_entry.data[CONF_LOCATION][CONF_LONGITUDE],
config_entry.data[CONF_LOCATION][CONF_LATITUDE],
session=aiohttp_client.async_get_clientsession(hass),
)
async def _async_update_data(self) -> SMHIFireForecastData:
"""Fetch data from SMHI."""
try:
async with asyncio.timeout(TIMEOUT):
_forecast_fire_daily = (
await self._smhi_fire_api.async_get_daily_forecast()
)
_forecast_fire_hourly = (
await self._smhi_fire_api.async_get_hourly_forecast()
)
except SmhiFireForecastException as ex:
raise UpdateFailed(
"Failed to retrieve the forecast from the SMHI API"
) from ex
return SMHIFireForecastData(
fire_daily=_forecast_fire_daily,
fire_hourly=_forecast_fire_hourly,
)
@property
def fire_current(self) -> SMHIFireForecast:
"""Return the current fire metrics."""
return self.data.fire_daily[0]

View File

@@ -6,14 +6,13 @@ from abc import abstractmethod
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import SMHIDataUpdateCoordinator, SMHIFireDataUpdateCoordinator
from .coordinator import SMHIDataUpdateCoordinator
class SmhiWeatherBaseEntity(Entity):
class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]):
"""Representation of a base weather entity."""
_attr_attribution = "Swedish weather institute (SMHI)"
@@ -23,8 +22,10 @@ class SmhiWeatherBaseEntity(Entity):
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{latitude}, {longitude}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
@@ -35,50 +36,12 @@ class SmhiWeatherBaseEntity(Entity):
)
self.update_entity_data()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_entity_data()
super()._handle_coordinator_update()
@abstractmethod
def update_entity_data(self) -> None:
"""Refresh the entity data."""
class SmhiWeatherEntity(
CoordinatorEntity[SMHIDataUpdateCoordinator], SmhiWeatherBaseEntity
):
"""Representation of a weather entity."""
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
SmhiWeatherBaseEntity.__init__(self, latitude, longitude)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_entity_data()
super()._handle_coordinator_update()
class SmhiFireEntity(
CoordinatorEntity[SMHIFireDataUpdateCoordinator], SmhiWeatherBaseEntity
):
"""Representation of a weather entity."""
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIFireDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
SmhiWeatherBaseEntity.__init__(self, latitude, longitude)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_entity_data()
super()._handle_coordinator_update()

View File

@@ -1,42 +1,12 @@
{
"entity": {
"sensor": {
"build_up_index": {
"default": "mdi:grass"
},
"drought_code": {
"default": "mdi:grass"
},
"duff_moisture_code": {
"default": "mdi:grass"
},
"fine_fuel_moisture_code": {
"default": "mdi:grass"
},
"fire_weather_index": {
"default": "mdi:pine-tree-fire"
},
"forestdry": {
"default": "mdi:forest"
},
"frozen_precipitation": {
"default": "mdi:weather-snowy-rainy"
},
"fwi": {
"default": "mdi:pine-tree-fire"
},
"fwiindex": {
"default": "mdi:pine-tree-fire"
},
"grassfire": {
"default": "mdi:fire-circle"
},
"high_cloud": {
"default": "mdi:cloud-arrow-up"
},
"initial_spread_index": {
"default": "mdi:grass"
},
"low_cloud": {
"default": "mdi:cloud-arrow-down"
},
@@ -46,9 +16,6 @@
"precipitation_category": {
"default": "mdi:weather-pouring"
},
"rate_of_spread": {
"default": "mdi:grass"
},
"thunder": {
"default": "mdi:weather-lightning"
},

View File

@@ -10,55 +10,19 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
PERCENTAGE,
UnitOfSpeed,
)
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import (
SMHIConfigEntry,
SMHIDataUpdateCoordinator,
SMHIFireDataUpdateCoordinator,
)
from .entity import SmhiFireEntity, SmhiWeatherEntity
from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator
from .entity import SmhiWeatherBaseEntity
PARALLEL_UPDATES = 0
FWI_INDEX_MAP = {
"1": "very_low",
"2": "low",
"3": "moderate",
"4": "high",
"5": "very_high",
"6": "extreme",
}
GRASSFIRE_MAP = {
"1": "snow_cover",
"2": "season_over",
"3": "low",
"4": "moderate",
"5": "high",
"6": "very_high",
}
FORESTDRY_MAP = {
"1": "very_wet",
"2": "wet",
"3": "moderate_wet",
"4": "dry",
"5": "very_dry",
"6": "extremely_dry",
}
def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None:
def get_percentage_values(entity: SMHISensor, key: str) -> int | None:
"""Return percentage values in correct range."""
value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment]
if value is not None and 0 <= value <= 100:
@@ -68,64 +32,49 @@ def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None:
return None
def get_fire_index_value(entity: SMHIFireSensor, key: str) -> str:
"""Return index value as string."""
value: int | None = entity.coordinator.fire_current.get(key) # type: ignore[assignment]
if value is not None and value > 0:
return str(int(value))
return "0"
@dataclass(frozen=True, kw_only=True)
class SMHIWeatherEntityDescription(SensorEntityDescription):
"""Describes SMHI weather entity."""
class SMHISensorEntityDescription(SensorEntityDescription):
"""Describes SMHI sensor entity."""
value_fn: Callable[[SMHIWeatherSensor], StateType | datetime]
value_fn: Callable[[SMHISensor], StateType | datetime]
@dataclass(frozen=True, kw_only=True)
class SMHIFireEntityDescription(SensorEntityDescription):
"""Describes SMHI fire entity."""
value_fn: Callable[[SMHIFireSensor], StateType | datetime]
WEATHER_SENSOR_DESCRIPTIONS: tuple[SMHIWeatherEntityDescription, ...] = (
SMHIWeatherEntityDescription(
SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = (
SMHISensorEntityDescription(
key="thunder",
translation_key="thunder",
value_fn=lambda entity: get_percentage_values(entity, "thunder"),
native_unit_of_measurement=PERCENTAGE,
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="total_cloud",
translation_key="total_cloud",
value_fn=lambda entity: get_percentage_values(entity, "total_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="low_cloud",
translation_key="low_cloud",
value_fn=lambda entity: get_percentage_values(entity, "low_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="medium_cloud",
translation_key="medium_cloud",
value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="high_cloud",
translation_key="high_cloud",
value_fn=lambda entity: get_percentage_values(entity, "high_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="precipitation_category",
translation_key="precipitation_category",
value_fn=lambda entity: str(
@@ -134,100 +83,13 @@ WEATHER_SENSOR_DESCRIPTIONS: tuple[SMHIWeatherEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["0", "1", "2", "3", "4", "5", "6"],
),
SMHIWeatherEntityDescription(
SMHISensorEntityDescription(
key="frozen_precipitation",
translation_key="frozen_precipitation",
value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"),
native_unit_of_measurement=PERCENTAGE,
),
)
FIRE_SENSOR_DESCRIPTIONS: tuple[SMHIFireEntityDescription, ...] = (
SMHIFireEntityDescription(
key="fwiindex",
translation_key="fwiindex",
value_fn=(
lambda entity: FWI_INDEX_MAP.get(get_fire_index_value(entity, "fwiindex"))
),
device_class=SensorDeviceClass.ENUM,
options=[*FWI_INDEX_MAP.values()],
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="fire_weather_index",
translation_key="fire_weather_index",
value_fn=lambda entity: entity.coordinator.fire_current.get("fwi"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="initial_spread_index",
translation_key="initial_spread_index",
value_fn=lambda entity: entity.coordinator.fire_current.get("isi"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="build_up_index",
translation_key="build_up_index",
value_fn=(
lambda entity: entity.coordinator.fire_current.get(
"bui" # codespell:ignore bui
)
),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="fine_fuel_moisture_code",
translation_key="fine_fuel_moisture_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("ffmc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="duff_moisture_code",
translation_key="duff_moisture_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("dmc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="drought_code",
translation_key="drought_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("dc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="grassfire",
translation_key="grassfire",
value_fn=(
lambda entity: GRASSFIRE_MAP.get(get_fire_index_value(entity, "grassfire"))
),
device_class=SensorDeviceClass.ENUM,
options=[*GRASSFIRE_MAP.values()],
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="rate_of_spread",
translation_key="rate_of_spread",
value_fn=lambda entity: entity.coordinator.fire_current.get("rn"),
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_MINUTE,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="forestdry",
translation_key="forestdry",
value_fn=(
lambda entity: FORESTDRY_MAP.get(get_fire_index_value(entity, "forestdry"))
),
device_class=SensorDeviceClass.ENUM,
options=[*FORESTDRY_MAP.values()],
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
@@ -237,43 +99,30 @@ async def async_setup_entry(
) -> None:
"""Set up SMHI sensor platform."""
coordinator = entry.runtime_data[0]
fire_coordinator = entry.runtime_data[1]
coordinator = entry.runtime_data
location = entry.data
entities: list[SMHIWeatherSensor | SMHIFireSensor] = []
entities.extend(
SMHIWeatherSensor(
async_add_entities(
SMHISensor(
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
coordinator=coordinator,
entity_description=description,
)
for description in WEATHER_SENSOR_DESCRIPTIONS
)
entities.extend(
SMHIFireSensor(
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
coordinator=fire_coordinator,
entity_description=description,
)
for description in FIRE_SENSOR_DESCRIPTIONS
for description in SENSOR_DESCRIPTIONS
)
async_add_entities(entities)
class SMHISensor(SmhiWeatherBaseEntity, SensorEntity):
"""Representation of a SMHI Sensor."""
class SMHIWeatherSensor(SmhiWeatherEntity, SensorEntity):
"""Representation of a SMHI Weather Sensor."""
entity_description: SMHIWeatherEntityDescription
entity_description: SMHISensorEntityDescription
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
entity_description: SMHIWeatherEntityDescription,
entity_description: SMHISensorEntityDescription,
) -> None:
"""Initiate SMHI Sensor."""
self.entity_description = entity_description
@@ -288,30 +137,3 @@ class SMHIWeatherSensor(SmhiWeatherEntity, SensorEntity):
"""Refresh the entity data."""
if self.coordinator.data.daily:
self._attr_native_value = self.entity_description.value_fn(self)
class SMHIFireSensor(SmhiFireEntity, SensorEntity):
"""Representation of a SMHI Weather Sensor."""
entity_description: SMHIFireEntityDescription
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIFireDataUpdateCoordinator,
entity_description: SMHIFireEntityDescription,
) -> None:
"""Initiate SMHI Sensor."""
self.entity_description = entity_description
super().__init__(
latitude,
longitude,
coordinator,
)
self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}"
def update_entity_data(self) -> None:
"""Refresh the entity data."""
if self.coordinator.data.fire_daily:
self._attr_native_value = self.entity_description.value_fn(self)

View File

@@ -26,66 +26,12 @@
},
"entity": {
"sensor": {
"build_up_index": {
"name": "Build up index"
},
"drought_code": {
"name": "Drought code"
},
"duff_moisture_code": {
"name": "Duff moisture code"
},
"fine_fuel_moisture_code": {
"name": "Fine fuel moisture code"
},
"fire_weather_index": {
"name": "Fire weather index"
},
"forestdry": {
"name": "Fuel drying",
"state": {
"dry": "Dry",
"extremely_dry": "Extremely dry",
"moderate_wet": "Moderately wet",
"very_dry": "Very dry",
"very_wet": "Very wet",
"wet": "Wet"
}
},
"frozen_precipitation": {
"name": "Frozen precipitation"
},
"fwi": {
"name": "Fire weather index"
},
"fwiindex": {
"name": "FWI index",
"state": {
"extreme": "Extremely high risk",
"high": "High risk",
"low": "Low risk",
"moderate": "Moderate risk",
"very_high": "Very high risk",
"very_low": "Very low risk"
}
},
"grassfire": {
"name": "Highest grass fire risk",
"state": {
"high": "High",
"low": "Low",
"moderate": "Moderate",
"season_over": "Grass fire season over",
"snow_cover": "Snow cover",
"very_high": "Very high"
}
},
"high_cloud": {
"name": "High cloud coverage"
},
"initial_spread_index": {
"name": "Initial spread index"
},
"low_cloud": {
"name": "Low cloud coverage"
},
@@ -104,9 +50,6 @@
"6": "Freezing drizzle"
}
},
"rate_of_spread": {
"name": "Potential rate of spread"
},
"thunder": {
"name": "Thunder probability"
},

View File

@@ -55,7 +55,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT
from .coordinator import SMHIConfigEntry
from .entity import SmhiWeatherEntity
from .entity import SmhiWeatherBaseEntity
# Used to map condition from API results
CONDITION_CLASSES: Final[dict[str, list[int]]] = {
@@ -89,7 +89,7 @@ async def async_setup_entry(
"""Add a weather entity from map location."""
location = config_entry.data
coordinator = config_entry.runtime_data[0]
coordinator = config_entry.runtime_data
entity = SmhiWeather(
location[CONF_LOCATION][CONF_LATITUDE],
@@ -101,7 +101,7 @@ async def async_setup_entry(
async_add_entities([entity])
class SmhiWeather(SmhiWeatherEntity, SingleCoordinatorWeatherEntity):
class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity):
"""Representation of a weather entity."""
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS

View File

@@ -1,5 +0,0 @@
"""Constants for the Sunricher DALI integration."""
DOMAIN = "sunricher_dali"
MANUFACTURER = "Sunricher"
CONF_SERIAL_NUMBER = "serial_number"

View File

@@ -1,4 +1,4 @@
"""The Sunricher DALI integration."""
"""The DALI Center integration."""
from __future__ import annotations
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Set up Sunricher DALI from a config entry."""
"""Set up DALI Center from a config entry."""
gateway = DaliGateway(
entry.data[CONF_SERIAL_NUMBER],

View File

@@ -1,4 +1,4 @@
"""Config flow for the Sunricher DALI integration."""
"""Config flow for the DALI Center integration."""
from __future__ import annotations
@@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sunricher DALI."""
"""Handle a config flow for DALI Center."""
VERSION = 1

View File

@@ -0,0 +1,5 @@
"""Constants for the DALI Center integration."""
DOMAIN = "sunricher_dali_center"
MANUFACTURER = "Sunricher"
CONF_SERIAL_NUMBER = "serial_number"

View File

@@ -38,7 +38,7 @@ async def async_setup_entry(
entry: DaliCenterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sunricher DALI light entities from config entry."""
"""Set up DALI Center light entities from config entry."""
runtime_data = entry.runtime_data
gateway = runtime_data.gateway
devices = runtime_data.devices
@@ -57,7 +57,7 @@ async def async_setup_entry(
class DaliCenterLight(LightEntity):
"""Representation of a Sunricher DALI Light."""
"""Representation of a DALI Center Light."""
_attr_has_entity_name = True
_attr_name = None

View File

@@ -1,9 +1,9 @@
{
"domain": "sunricher_dali",
"name": "Sunricher DALI",
"domain": "sunricher_dali_center",
"name": "DALI Center",
"codeowners": ["@niracler"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["PySrDaliGateway==0.13.1"]

View File

@@ -5,8 +5,8 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_failed": "Failed to discover Sunricher DALI gateways on the network",
"no_devices_found": "No Sunricher DALI gateways found on the network",
"discovery_failed": "Failed to discover DALI gateways on the network",
"no_devices_found": "No DALI gateways found on the network",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@@ -17,10 +17,12 @@
"data_description": {
"selected_gateway": "Each option shows the gateway name, serial number, and IP address."
},
"description": "Select the gateway to configure."
"description": "Select the gateway to configure.",
"title": "Select DALI gateway"
},
"user": {
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your Sunricher DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press."
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press.",
"title": "Set up DALI Center gateway"
}
}
}

View File

@@ -1,4 +1,4 @@
"""Type definitions for the Sunricher DALI integration."""
"""Type definitions for the DALI Center integration."""
from dataclasses import dataclass
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
@dataclass
class DaliCenterData:
"""Runtime data for the Sunricher DALI integration."""
"""Runtime data for the DALI Center integration."""
gateway: DaliGateway
devices: list[Device]

View File

@@ -66,11 +66,11 @@ def get_process(entity: SystemMonitorSensor) -> bool:
class SysMonitorBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes System Monitor binary sensor entities."""
value_fn: Callable[[SystemMonitorSensor], bool | None]
value_fn: Callable[[SystemMonitorSensor], bool]
add_to_update: Callable[[SystemMonitorSensor], tuple[str, str]]
PROCESS_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
SysMonitorBinarySensorEntityDescription(
key="binary_process",
translation_key="process",
@@ -81,20 +81,6 @@ PROCESS_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
),
)
BINARY_SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
SysMonitorBinarySensorEntityDescription(
key="battery_plugged",
value_fn=(
lambda entity: entity.coordinator.data.battery.power_plugged
if entity.coordinator.data.battery
else None
),
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
add_to_update=lambda entity: ("battery", ""),
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -104,30 +90,18 @@ async def async_setup_entry(
"""Set up System Monitor binary sensors based on a config entry."""
coordinator = entry.runtime_data.coordinator
entities: list[SystemMonitorSensor] = []
entities.extend(
async_add_entities(
SystemMonitorSensor(
coordinator,
sensor_description,
entry.entry_id,
argument,
)
for sensor_description in PROCESS_TYPES
for sensor_description in SENSOR_TYPES
for argument in entry.options.get(BINARY_SENSOR_DOMAIN, {}).get(
CONF_PROCESS, []
)
)
entities.extend(
SystemMonitorSensor(
coordinator,
sensor_description,
entry.entry_id,
"",
)
for sensor_description in BINARY_SENSOR_TYPES
)
async_add_entities(entities)
class SystemMonitorSensor(

View File

@@ -9,7 +9,7 @@ import os
from typing import TYPE_CHECKING, Any, NamedTuple
from psutil import Process
from psutil._common import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
import psutil_home_assistant as ha_psutil
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -22,7 +22,6 @@ from .const import CONF_PROCESS, PROCESS_ERRORS
if TYPE_CHECKING:
from . import SystemMonitorConfigEntry
from .util import read_fan_speed
_LOGGER = logging.getLogger(__name__)
@@ -31,52 +30,44 @@ _LOGGER = logging.getLogger(__name__)
class SensorData:
"""Sensor data."""
addresses: dict[str, list[snicaddr]]
battery: sbattery | None
boot_time: datetime
cpu_percent: float | None
disk_usage: dict[str, sdiskusage]
fan_speed: dict[str, int]
io_counters: dict[str, snetio]
load: tuple[float, float, float]
memory: VirtualMemory
process_fds: dict[str, int]
processes: list[Process]
swap: sswap
memory: VirtualMemory
io_counters: dict[str, snetio]
addresses: dict[str, list[snicaddr]]
load: tuple[float, float, float]
cpu_percent: float | None
boot_time: datetime
processes: list[Process]
temperatures: dict[str, list[shwtemp]]
process_fds: dict[str, int]
def as_dict(self) -> dict[str, Any]:
"""Return as dict."""
addresses = None
if self.addresses:
addresses = {k: str(v) for k, v in self.addresses.items()}
disk_usage = None
if self.disk_usage:
disk_usage = {k: str(v) for k, v in self.disk_usage.items()}
fan_speed = None
if self.fan_speed:
fan_speed = {k: str(v) for k, v in self.fan_speed.items()}
io_counters = None
if self.io_counters:
io_counters = {k: str(v) for k, v in self.io_counters.items()}
addresses = None
if self.addresses:
addresses = {k: str(v) for k, v in self.addresses.items()}
temperatures = None
if self.temperatures:
temperatures = {k: str(v) for k, v in self.temperatures.items()}
return {
"addresses": addresses,
"battery": str(self.battery),
"boot_time": str(self.boot_time),
"cpu_percent": str(self.cpu_percent),
"disk_usage": disk_usage,
"fan_speed": fan_speed,
"io_counters": io_counters,
"load": str(self.load),
"memory": str(self.memory),
"process_fds": self.process_fds,
"processes": str(self.processes),
"swap": str(self.swap),
"memory": str(self.memory),
"io_counters": io_counters,
"addresses": addresses,
"load": str(self.load),
"cpu_percent": str(self.cpu_percent),
"boot_time": str(self.boot_time),
"processes": str(self.processes),
"temperatures": temperatures,
"process_fds": self.process_fds,
}
@@ -133,16 +124,14 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
_disk_defaults[("disks", argument)] = set()
return {
**_disk_defaults,
("addresses", ""): set(),
("battery", ""): set(),
("boot", ""): set(),
("cpu_percent", ""): set(),
("fan_speed", ""): set(),
("io_counters", ""): set(),
("load", ""): set(),
("memory", ""): set(),
("processes", ""): set(),
("swap", ""): set(),
("memory", ""): set(),
("io_counters", ""): set(),
("addresses", ""): set(),
("load", ""): set(),
("cpu_percent", ""): set(),
("boot", ""): set(),
("processes", ""): set(),
("temperatures", ""): set(),
}
@@ -164,19 +153,17 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
self._initial_update = False
return SensorData(
addresses=_data["addresses"],
battery=_data["battery"],
boot_time=_data["boot_time"],
cpu_percent=cpu_percent,
disk_usage=_data["disks"],
fan_speed=_data["fan_speed"],
io_counters=_data["io_counters"],
load=load,
memory=_data["memory"],
process_fds=_data["process_fds"],
processes=_data["processes"],
swap=_data["swap"],
memory=_data["memory"],
io_counters=_data["io_counters"],
addresses=_data["addresses"],
load=load,
cpu_percent=cpu_percent,
boot_time=_data["boot_time"],
processes=_data["processes"],
temperatures=_data["temperatures"],
process_fds=_data["process_fds"],
)
def update_data(self) -> dict[str, Any]:
@@ -268,33 +255,14 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
except AttributeError:
_LOGGER.debug("OS does not provide temperature sensors")
fan_speed: dict[str, int] = {}
if self.update_subscribers[("fan_speed", "")] or self._initial_update:
try:
fan_sensors = self._psutil.sensors_fans()
fan_speed = read_fan_speed(fan_sensors)
_LOGGER.debug("fan_speed: %s", fan_speed)
except AttributeError:
_LOGGER.debug("OS does not provide fan sensors")
battery: sbattery | None = None
if self.update_subscribers[("battery", "")] or self._initial_update:
try:
battery = self._psutil.sensors_battery()
_LOGGER.debug("battery: %s", battery)
except AttributeError:
_LOGGER.debug("OS does not provide battery sensors")
return {
"addresses": addresses,
"battery": battery,
"boot_time": self.boot_time,
"disks": disks,
"fan_speed": fan_speed,
"io_counters": io_counters,
"memory": memory,
"process_fds": process_fds,
"processes": selected_processes,
"swap": swap,
"memory": memory,
"io_counters": io_counters,
"addresses": addresses,
"boot_time": self.boot_time,
"processes": selected_processes,
"temperatures": temps,
"process_fds": process_fds,
}

View File

@@ -1,9 +1,6 @@
{
"entity": {
"sensor": {
"battery_empty": {
"default": "mdi:battery-clock"
},
"disk_free": {
"default": "mdi:harddisk"
},
@@ -13,9 +10,6 @@
"disk_use_percent": {
"default": "mdi:harddisk"
},
"fan_speed": {
"default": "mdi:fan"
},
"ipv4_address": {
"default": "mdi:ip-network"
},

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
import contextlib
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime
from functools import lru_cache
import ipaddress
import logging
@@ -14,8 +14,6 @@ import sys
import time
from typing import Any, Literal
from psutil._common import POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
@@ -25,7 +23,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfDataRate,
UnitOfInformation,
@@ -37,7 +34,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util import slugify
from . import SystemMonitorConfigEntry
from .binary_sensor import BINARY_SENSOR_DOMAIN
@@ -58,23 +55,12 @@ SENSOR_TYPE_MANDATORY_ARG = 4
SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update"
BATTERY_REMAIN_UNKNOWNS = (POWER_TIME_UNKNOWN, POWER_TIME_UNLIMITED)
SENSORS_NO_ARG = (
"battery_empty",
"battery",
"last_boot",
"load_",
"memory_",
"processor_use",
"swap_",
)
SENSORS_NO_ARG = ("load_", "memory_", "processor_use", "swap_", "last_boot")
SENSORS_WITH_ARG = {
"disk_": "disk_arguments",
"fan_speed": "fan_speed_arguments",
"ipv": "network_arguments",
"process_num_fds": "processes",
**dict.fromkeys(NET_IO_TYPES, "network_arguments"),
"process_num_fds": "processes",
}
@@ -147,17 +133,6 @@ def get_process_num_fds(entity: SystemMonitorSensor) -> int | None:
return process_fds.get(entity.argument)
def battery_time_ends(entity: SystemMonitorSensor) -> datetime | None:
"""Return when battery runs out, rounded to minute."""
battery = entity.coordinator.data.battery
if not battery or battery.secsleft in BATTERY_REMAIN_UNKNOWNS:
return None
return (dt_util.utcnow() + timedelta(seconds=battery.secsleft)).replace(
second=0, microsecond=0
)
@dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Describes System Monitor sensor entities."""
@@ -170,28 +145,6 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
"battery": SysMonitorSensorEntityDescription(
key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: entity.coordinator.data.battery.percent
if entity.coordinator.data.battery
else None
),
none_is_unavailable=True,
add_to_update=lambda entity: ("battery", ""),
),
"battery_empty": SysMonitorSensorEntityDescription(
key="battery_empty",
translation_key="battery_empty",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=SensorStateClass.MEASUREMENT,
value_fn=battery_time_ends,
none_is_unavailable=True,
add_to_update=lambda entity: ("battery", ""),
),
"disk_free": SysMonitorSensorEntityDescription(
key="disk_free",
translation_key="disk_free",
@@ -199,13 +152,11 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
native_unit_of_measurement=UnitOfInformation.GIBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: round(
entity.coordinator.data.disk_usage[entity.argument].free / 1024**3, 1
)
if entity.argument in entity.coordinator.data.disk_usage
else None
),
value_fn=lambda entity: round(
entity.coordinator.data.disk_usage[entity.argument].free / 1024**3, 1
)
if entity.argument in entity.coordinator.data.disk_usage
else None,
none_is_unavailable=True,
add_to_update=lambda entity: ("disks", entity.argument),
),
@@ -216,13 +167,11 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
native_unit_of_measurement=UnitOfInformation.GIBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: round(
entity.coordinator.data.disk_usage[entity.argument].used / 1024**3, 1
)
if entity.argument in entity.coordinator.data.disk_usage
else None
),
value_fn=lambda entity: round(
entity.coordinator.data.disk_usage[entity.argument].used / 1024**3, 1
)
if entity.argument in entity.coordinator.data.disk_usage
else None,
none_is_unavailable=True,
add_to_update=lambda entity: ("disks", entity.argument),
),
@@ -232,24 +181,14 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
placeholder="mount_point",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: entity.coordinator.data.disk_usage[entity.argument].percent
if entity.argument in entity.coordinator.data.disk_usage
else None
),
value_fn=lambda entity: entity.coordinator.data.disk_usage[
entity.argument
].percent
if entity.argument in entity.coordinator.data.disk_usage
else None,
none_is_unavailable=True,
add_to_update=lambda entity: ("disks", entity.argument),
),
"fan_speed": SysMonitorSensorEntityDescription(
key="fan_speed",
translation_key="fan_speed",
placeholder="fan_name",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.coordinator.data.fan_speed[entity.argument],
none_is_unavailable=True,
add_to_update=lambda entity: ("fan_speed", ""),
),
"ipv4_address": SysMonitorSensorEntityDescription(
key="ipv4_address",
translation_key="ipv4_address",
@@ -273,6 +212,14 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=lambda entity: entity.coordinator.data.boot_time,
add_to_update=lambda entity: ("boot", ""),
),
"load_15m": SysMonitorSensorEntityDescription(
key="load_15m",
translation_key="load_15m",
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.load[2], 2),
add_to_update=lambda entity: ("load", ""),
),
"load_1m": SysMonitorSensorEntityDescription(
key="load_1m",
translation_key="load_1m",
@@ -289,22 +236,14 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=lambda entity: round(entity.coordinator.data.load[1], 2),
add_to_update=lambda entity: ("load", ""),
),
"load_15m": SysMonitorSensorEntityDescription(
key="load_15m",
translation_key="load_15m",
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: round(entity.coordinator.data.load[2], 2),
add_to_update=lambda entity: ("load", ""),
),
"memory_free": SysMonitorSensorEntityDescription(
key="memory_free",
translation_key="memory_free",
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: round(entity.coordinator.data.memory.available / 1024**2, 1)
value_fn=lambda entity: round(
entity.coordinator.data.memory.available / 1024**2, 1
),
add_to_update=lambda entity: ("memory", ""),
),
@@ -314,15 +253,13 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
native_unit_of_measurement=UnitOfInformation.MEBIBYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: round(
(
entity.coordinator.data.memory.total
- entity.coordinator.data.memory.available
)
/ 1024**2,
1,
value_fn=lambda entity: round(
(
entity.coordinator.data.memory.total
- entity.coordinator.data.memory.available
)
/ 1024**2,
1,
),
add_to_update=lambda entity: ("memory", ""),
),
@@ -374,15 +311,27 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=get_packets,
add_to_update=lambda entity: ("io_counters", ""),
),
"process_num_fds": SysMonitorSensorEntityDescription(
key="process_num_fds",
translation_key="process_num_fds",
placeholder="process",
"throughput_network_in": SysMonitorSensorEntityDescription(
key="throughput_network_in",
translation_key="throughput_network_in",
placeholder="interface",
native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
mandatory_arg=True,
value_fn=get_process_num_fds,
add_to_update=lambda entity: ("processes", ""),
value_fn=get_throughput,
add_to_update=lambda entity: ("io_counters", ""),
),
"throughput_network_out": SysMonitorSensorEntityDescription(
key="throughput_network_out",
translation_key="throughput_network_out",
placeholder="interface",
native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
mandatory_arg=True,
value_fn=get_throughput,
add_to_update=lambda entity: ("io_counters", ""),
),
"processor_use": SysMonitorSensorEntityDescription(
key="processor_use",
@@ -390,12 +339,10 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
icon=get_cpu_icon(),
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: (
round(entity.coordinator.data.cpu_percent)
if entity.coordinator.data.cpu_percent
else None
)
value_fn=lambda entity: (
round(entity.coordinator.data.cpu_percent)
if entity.coordinator.data.cpu_percent
else None
),
add_to_update=lambda entity: ("cpu_percent", ""),
),
@@ -405,8 +352,8 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda entity: read_cpu_temperature(entity.coordinator.data.temperatures)
value_fn=lambda entity: read_cpu_temperature(
entity.coordinator.data.temperatures
),
none_is_unavailable=True,
add_to_update=lambda entity: ("temperatures", ""),
@@ -437,27 +384,15 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=lambda entity: entity.coordinator.data.swap.percent,
add_to_update=lambda entity: ("swap", ""),
),
"throughput_network_in": SysMonitorSensorEntityDescription(
key="throughput_network_in",
translation_key="throughput_network_in",
placeholder="interface",
native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
"process_num_fds": SysMonitorSensorEntityDescription(
key="process_num_fds",
translation_key="process_num_fds",
placeholder="process",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
mandatory_arg=True,
value_fn=get_throughput,
add_to_update=lambda entity: ("io_counters", ""),
),
"throughput_network_out": SysMonitorSensorEntityDescription(
key="throughput_network_out",
translation_key="throughput_network_out",
placeholder="interface",
native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
mandatory_arg=True,
value_fn=get_throughput,
add_to_update=lambda entity: ("io_counters", ""),
value_fn=get_process_num_fds,
add_to_update=lambda entity: ("processes", ""),
),
}
@@ -474,17 +409,14 @@ def check_legacy_resource(resource: str, resources: set[str]) -> bool:
IO_COUNTER = {
"network_in": 1,
"network_out": 0,
"packets_in": 3,
"network_in": 1,
"packets_out": 2,
"throughput_network_in": 1,
"packets_in": 3,
"throughput_network_out": 0,
"throughput_network_in": 1,
}
IF_ADDRS_FAMILY = {
"ipv4_address": socket.AF_INET,
"ipv6_address": socket.AF_INET6,
}
IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6}
async def async_setup_entry(
@@ -505,7 +437,6 @@ async def async_setup_entry(
return {
"disk_arguments": get_all_disk_mounts(hass, psutil_wrapper),
"network_arguments": get_all_network_interfaces(hass, psutil_wrapper),
"fan_speed_arguments": list(sensor_data.fan_speed),
}
cpu_temperature: float | None = None

View File

@@ -16,9 +16,6 @@
}
},
"sensor": {
"battery_empty": {
"name": "Battery empty"
},
"disk_free": {
"name": "Disk free {mount_point}"
},
@@ -28,9 +25,6 @@
"disk_use_percent": {
"name": "Disk usage {mount_point}"
},
"fan_speed": {
"name": "{fan_name} fan speed"
},
"ipv4_address": {
"name": "IPv4 address {ip_address}"
},

View File

@@ -3,7 +3,7 @@
import logging
import os
from psutil._common import sfan, shwtemp
from psutil._common import shwtemp
import psutil_home_assistant as ha_psutil
from homeassistant.core import HomeAssistant
@@ -89,19 +89,3 @@ def read_cpu_temperature(temps: dict[str, list[shwtemp]]) -> float | None:
return round(entry.current, 1)
return None
def read_fan_speed(fans: dict[str, list[sfan]]) -> dict[str, int]:
"""Attempt to read fan speed."""
entry: sfan
_LOGGER.debug("Fan speed: %s", fans)
if not fans:
return {}
sensor_fans: dict[str, int] = {}
for name, entries in fans.items():
for entry in entries:
_label = name if not entry.label else entry.label
sensor_fans[_label] = round(entry.current, 0)
return sensor_fans

View File

@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, find_dpcode
from .models import EnumTypeData
from .util import get_dpcode
@@ -118,8 +118,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}"
# Determine supported modes
if supported_modes := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
if supported_modes := self.find_dpcode(
description.key, dptype=DPType.ENUM, prefer_function=True
):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
@@ -131,11 +131,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine master state
if enum_type := find_dpcode(
self.device,
description.master_state,
dptype=DPType.ENUM,
prefer_function=True,
if enum_type := self.find_dpcode(
description.master_state, dptype=DPType.ENUM, prefer_function=True
):
self._master_state = enum_type

View File

@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import IntegerTypeData
from .util import get_dpcode
TUYA_HVAC_TO_HA = {
@@ -153,13 +153,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._attr_temperature_unit = system_temperature_unit
# Figure out current temperature, use preferred unit or what is available
celsius_type = find_dpcode(
self.device, (DPCode.TEMP_CURRENT, DPCode.UPPER_TEMP), dptype=DPType.INTEGER
celsius_type = self.find_dpcode(
(DPCode.TEMP_CURRENT, DPCode.UPPER_TEMP), dptype=DPType.INTEGER
)
fahrenheit_type = find_dpcode(
self.device,
(DPCode.TEMP_CURRENT_F, DPCode.UPPER_TEMP_F),
dptype=DPType.INTEGER,
fahrenheit_type = self.find_dpcode(
(DPCode.TEMP_CURRENT_F, DPCode.UPPER_TEMP_F), dptype=DPType.INTEGER
)
if fahrenheit_type and (
prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT
@@ -175,11 +173,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._current_temperature = celsius_type
# Figure out setting temperature, use preferred unit or what is available
celsius_type = find_dpcode(
self.device, DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True
celsius_type = self.find_dpcode(
DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True
)
fahrenheit_type = find_dpcode(
self.device, DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True
fahrenheit_type = self.find_dpcode(
DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True
)
if fahrenheit_type and (
prefered_temperature_unit == UnitOfTemperature.FAHRENHEIT
@@ -203,8 +201,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# Determine HVAC modes
self._attr_hvac_modes: list[HVACMode] = []
self._hvac_to_tuya = {}
if enum_type := find_dpcode(
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
if enum_type := self.find_dpcode(
DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
):
self._attr_hvac_modes = [HVACMode.OFF]
unknown_hvac_modes: list[str] = []
@@ -227,11 +225,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
]
# Determine dpcode to use for setting the humidity
if int_type := find_dpcode(
self.device,
DPCode.HUMIDITY_SET,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
DPCode.HUMIDITY_SET, dptype=DPType.INTEGER, prefer_function=True
):
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
self._set_humidity = int_type
@@ -239,14 +234,13 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._attr_max_humidity = int(int_type.max_scaled)
# Determine dpcode to use for getting the current humidity
self._current_humidity = find_dpcode(
self.device, DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER
self._current_humidity = self.find_dpcode(
DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER
)
# Determine fan modes
self._fan_mode_dp_code: str | None = None
if enum_type := find_dpcode(
self.device,
if enum_type := self.find_dpcode(
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
dptype=DPType.ENUM,
prefer_function=True,

View File

@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData, find_dpcode
from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
@@ -204,8 +204,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._attr_supported_features |= (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
elif enum_type := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
elif enum_type := self.find_dpcode(
description.key, dptype=DPType.ENUM, prefer_function=True
):
if description.open_instruction_value in enum_type.range:
self._attr_supported_features |= CoverEntityFeature.OPEN
@@ -217,11 +217,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._current_state = get_dpcode(self.device, description.current_state)
# Determine type to use for setting the position
if int_type := find_dpcode(
self.device,
description.set_position,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
description.set_position, dptype=DPType.INTEGER, prefer_function=True
):
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
self._set_position = int_type
@@ -229,17 +226,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._current_position = int_type
# Determine type for getting the position
if int_type := find_dpcode(
self.device,
description.current_position,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
description.current_position, dptype=DPType.INTEGER, prefer_function=True
):
self._current_position = int_type
# Determine type to use for setting the tilt
if int_type := find_dpcode(
self.device,
if int_type := self.find_dpcode(
(DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
dptype=DPType.INTEGER,
prefer_function=True,
@@ -249,8 +242,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
# Determine type to use for checking motor reverse mode
if (motor_mode := description.motor_reverse_mode) and (
enum_type := find_dpcode(
self.device,
enum_type := self.find_dpcode(
motor_mode,
dptype=DPType.ENUM,
prefer_function=True,
@@ -319,11 +311,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
value: bool | str = True
if find_dpcode(
self.device,
self.entity_description.key,
dptype=DPType.ENUM,
prefer_function=True,
if self.find_dpcode(
self.entity_description.key, dptype=DPType.ENUM, prefer_function=True
):
value = self.entity_description.open_instruction_value
@@ -348,11 +337,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
value: bool | str = False
if find_dpcode(
self.device,
self.entity_description.key,
dptype=DPType.ENUM,
prefer_function=True,
if self.find_dpcode(
self.entity_description.key, dptype=DPType.ENUM, prefer_function=True
):
value = self.entity_description.close_instruction_value

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal, overload
from tuya_sharing import CustomerDevice, Manager
@@ -10,7 +10,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType
from .models import EnumTypeData, IntegerTypeData
class TuyaEntity(Entity):
@@ -43,6 +44,77 @@ class TuyaEntity(Entity):
"""Return if the device is available."""
return self.device.online
@overload
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.ENUM],
) -> EnumTypeData | None: ...
@overload
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.INTEGER],
) -> IntegerTypeData | None: ...
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: DPType,
) -> EnumTypeData | IntegerTypeData | None:
"""Find type information for a matching DP code available for this device."""
if dptype not in (DPType.ENUM, DPType.INTEGER):
raise NotImplementedError("Only ENUM and INTEGER types are supported")
if dpcodes is None:
return None
if isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
elif not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
order = ["status_range", "function"]
if prefer_function:
order = ["function", "status_range"]
for dpcode in dpcodes:
for key in order:
if dpcode not in getattr(self.device, key):
continue
if (
dptype == DPType.ENUM
and getattr(self.device, key)[dpcode].type == DPType.ENUM
):
if not (
enum_type := EnumTypeData.from_json(
dpcode, getattr(self.device, key)[dpcode].values
)
):
continue
return enum_type
if (
dptype == DPType.INTEGER
and getattr(self.device, key)[dpcode].type == DPType.INTEGER
):
if not (
integer_type := IntegerTypeData.from_json(
dpcode, getattr(self.device, key)[dpcode].values
)
):
continue
return integer_type
return None
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
self.async_on_remove(

View File

@@ -16,7 +16,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import find_dpcode
# All descriptions can be found here. Mostly the Enum data types in the
# default status set of each category (that don't have a set instruction)
@@ -126,7 +125,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
if dpcode := find_dpcode(self.device, description.key, dptype=DPType.ENUM):
if dpcode := self.find_dpcode(description.key, dptype=DPType.ENUM):
self._attr_event_types: list[str] = dpcode.range
async def _handle_state_update(

View File

@@ -23,7 +23,7 @@ from homeassistant.util.percentage import (
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData, find_dpcode
from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,)
@@ -106,24 +106,21 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
self._switch = get_dpcode(self.device, _SWITCH_DPCODES)
self._attr_preset_modes = []
if enum_type := find_dpcode(
self.device,
(DPCode.FAN_MODE, DPCode.MODE),
dptype=DPType.ENUM,
prefer_function=True,
if enum_type := self.find_dpcode(
(DPCode.FAN_MODE, DPCode.MODE), dptype=DPType.ENUM, prefer_function=True
):
self._presets = enum_type
self._attr_supported_features |= FanEntityFeature.PRESET_MODE
self._attr_preset_modes = enum_type.range
# Find speed controls, can be either percentage or a set of speeds
if int_type := find_dpcode(
self.device, _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True
if int_type := self.find_dpcode(
_SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True
):
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._speed = int_type
elif enum_type := find_dpcode(
self.device, _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True
elif enum_type := self.find_dpcode(
_SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True
):
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._speeds = enum_type
@@ -132,8 +129,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
self._oscillate = dpcode
self._attr_supported_features |= FanEntityFeature.OSCILLATE
if enum_type := find_dpcode(
self.device, _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True
if enum_type := self.find_dpcode(
_DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True
):
self._direction = enum_type
self._attr_supported_features |= FanEntityFeature.DIRECTION

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError, get_dpcode
@@ -120,27 +120,23 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
)
# Determine humidity parameters
if int_type := find_dpcode(
self.device,
description.humidity,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
description.humidity, dptype=DPType.INTEGER, prefer_function=True
):
self._set_humidity = int_type
self._attr_min_humidity = int(int_type.min_scaled)
self._attr_max_humidity = int(int_type.max_scaled)
# Determine current humidity DPCode
if int_type := find_dpcode(
self.device,
if int_type := self.find_dpcode(
description.current_humidity,
dptype=DPType.INTEGER,
):
self._current_humidity = int_type
# Determine mode support and provided modes
if enum_type := find_dpcode(
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
if enum_type := self.find_dpcode(
DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
):
self._attr_supported_features |= HumidifierEntityFeature.MODES
self._attr_available_modes = enum_type.range

View File

@@ -28,7 +28,7 @@ from homeassistant.util import color as color_util
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import IntegerTypeData
from .util import get_dpcode, get_dptype, remap_value
@@ -466,19 +466,16 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
# Determine DPCodes
self._color_mode_dpcode = get_dpcode(self.device, description.color_mode)
if int_type := find_dpcode(
self.device,
description.brightness,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
description.brightness, dptype=DPType.INTEGER, prefer_function=True
):
self._brightness = int_type
color_modes.add(ColorMode.BRIGHTNESS)
self._brightness_max = find_dpcode(
self.device, description.brightness_max, dptype=DPType.INTEGER
self._brightness_max = self.find_dpcode(
description.brightness_max, dptype=DPType.INTEGER
)
self._brightness_min = find_dpcode(
self.device, description.brightness_min, dptype=DPType.INTEGER
self._brightness_min = self.find_dpcode(
description.brightness_min, dptype=DPType.INTEGER
)
if (dpcode := get_dpcode(self.device, description.color_data)) and (
@@ -507,11 +504,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
# Check if the light has color temperature
if int_type := find_dpcode(
self.device,
description.color_temp,
dptype=DPType.INTEGER,
prefer_function=True,
if int_type := self.find_dpcode(
description.color_temp, dptype=DPType.INTEGER, prefer_function=True
):
self._color_temp = int_type
color_modes.add(ColorMode.COLOR_TEMP)
@@ -520,11 +514,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
elif (
color_supported(color_modes)
and (
color_mode_enum := find_dpcode(
self.device,
description.color_mode,
dptype=DPType.ENUM,
prefer_function=True,
color_mode_enum := self.find_dpcode(
description.color_mode, dptype=DPType.ENUM, prefer_function=True
)
)
and WorkMode.WHITE.value in color_mode_enum.range

View File

@@ -6,86 +6,12 @@ import base64
from dataclasses import dataclass
import json
import struct
from typing import Literal, Self, overload
from typing import Self
from tuya_sharing import CustomerDevice
from .const import DPCode, DPType
from .const import DPCode
from .util import remap_value
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.ENUM],
) -> EnumTypeData | None: ...
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.INTEGER],
) -> IntegerTypeData | None: ...
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: DPType,
) -> EnumTypeData | IntegerTypeData | None:
"""Find type information for a matching DP code available for this device."""
if dptype not in (DPType.ENUM, DPType.INTEGER):
raise NotImplementedError("Only ENUM and INTEGER types are supported")
if dpcodes is None:
return None
if isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
elif not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
lookup_tuple = (
(device.function, device.status_range)
if prefer_function
else (device.status_range, device.function)
)
for dpcode in dpcodes:
for device_specs in lookup_tuple:
if not (
(current_definition := device_specs.get(dpcode))
and current_definition.type == dptype
):
continue
if dptype is DPType.ENUM:
if not (
enum_type := EnumTypeData.from_json(
dpcode, current_definition.values
)
):
continue
return enum_type
if dptype is DPType.INTEGER:
if not (
integer_type := IntegerTypeData.from_json(
dpcode, current_definition.values
)
):
continue
return integer_type
return None
@dataclass
class IntegerTypeData:
"""Integer Type Data."""

View File

@@ -26,7 +26,7 @@ from .const import (
DPType,
)
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError
NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
@@ -486,8 +486,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
if int_type := find_dpcode(
self.device, description.key, dptype=DPType.INTEGER, prefer_function=True
if int_type := self.find_dpcode(
description.key, dptype=DPType.INTEGER, prefer_function=True
):
self._number = int_type
self._attr_native_max_value = self._number.max_scaled

View File

@@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import find_dpcode
# All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select.
@@ -389,8 +388,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_options: list[str] = []
if enum_type := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
if enum_type := self.find_dpcode(
description.key, dptype=DPType.ENUM, prefer_function=True
):
self._attr_options = enum_type.range

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from typing import Any
from tuya_sharing import CustomerDevice, Manager
from tuya_sharing.device import DeviceStatusRange
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS,
@@ -39,15 +40,10 @@ from .const import (
DeviceCategory,
DPCode,
DPType,
UnitOfMeasurement,
)
from .entity import TuyaEntity
from .models import (
ComplexValue,
ElectricityValue,
EnumTypeData,
IntegerTypeData,
find_dpcode,
)
from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData
from .util import get_dptype
_WIND_DIRECTIONS = {
@@ -1674,8 +1670,10 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
entity_description: TuyaSensorEntityDescription
_status_range: DeviceStatusRange | None = None
_type: DPType | None = None
_type_data: IntegerTypeData | EnumTypeData | None = None
_uom: UnitOfMeasurement | None = None
def __init__(
self,
@@ -1690,30 +1688,25 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
f"{super().unique_id}{description.key}{description.subkey or ''}"
)
if int_type := find_dpcode(self.device, description.key, dptype=DPType.INTEGER):
if int_type := self.find_dpcode(description.key, dptype=DPType.INTEGER):
self._type_data = int_type
self._type = DPType.INTEGER
if description.native_unit_of_measurement is None:
self._attr_native_unit_of_measurement = int_type.unit
elif enum_type := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
elif enum_type := self.find_dpcode(
description.key, dptype=DPType.ENUM, prefer_function=True
):
self._type_data = enum_type
self._type = DPType.ENUM
else:
self._type = get_dptype(self.device, DPCode(description.key))
self._validate_device_class_unit()
def _validate_device_class_unit(self) -> None:
"""Validate device class unit compatibility."""
# Logic to ensure the set device class and API received Unit Of Measurement
# match Home Assistants requirements.
if (
self.device_class is not None
and not self.device_class.startswith(DOMAIN)
and self.entity_description.native_unit_of_measurement is None
and description.native_unit_of_measurement is None
# we do not need to check mappings if the API UOM is allowed
and self.native_unit_of_measurement
not in SENSOR_DEVICE_CLASS_UNITS[self.device_class]

View File

@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, find_dpcode
from .models import EnumTypeData
from .util import get_dpcode
TUYA_MODE_RETURN_HOME = "chargego"
@@ -97,8 +97,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
self._return_home_use_switch_charge = True
elif (
enum_type := find_dpcode(
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
enum_type := self.find_dpcode(
DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
)
) and TUYA_MODE_RETURN_HOME in enum_type.range:
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
@@ -111,8 +111,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
VacuumEntityFeature.STOP | VacuumEntityFeature.START
)
if enum_type := find_dpcode(
self.device, DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True
if enum_type := self.find_dpcode(
DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True
):
self._fan_speed = enum_type
self._attr_fan_speed_list = enum_type.range

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Sequence
import dataclasses
import fnmatch
import os
@@ -30,6 +29,15 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice:
def scan_serial_ports() -> Sequence[USBDevice]:
"""Scan serial ports for USB devices."""
return [
usb_device_from_port(port)
for port in comports()
if port.vid is not None or port.pid is not None
]
def usb_device_from_path(device_path: str) -> USBDevice | None:
"""Get USB device info from a device path."""
# Scan all symlinks first
by_id = "/dev/serial/by-id"
@@ -38,30 +46,23 @@ def scan_serial_ports() -> Sequence[USBDevice]:
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
realpath_to_by_id[os.path.realpath(path)] = path
serial_ports = []
for port in comports():
if port.vid is not None or port.pid is not None:
usb_device = usb_device_from_port(port)
device_path = realpath_to_by_id.get(port.device, port.device)
if device_path != port.device:
# Prefer the unique /dev/serial/by-id/ path if it exists
usb_device = dataclasses.replace(usb_device, device=device_path)
serial_ports.append(usb_device)
return serial_ports
def usb_device_from_path(device_path: str) -> USBDevice | None:
"""Get USB device info from a device path."""
# Then compare the actual path to each serial port's
device_path_real = os.path.realpath(device_path)
for device in scan_serial_ports():
if os.path.realpath(device.device) == device_path_real:
return device
normalized_path = realpath_to_by_id.get(device.device, device.device)
if (
normalized_path == device_path
or os.path.realpath(device.device) == device_path_real
):
return USBDevice(
device=normalized_path,
vid=device.vid,
pid=device.pid,
serial_number=device.serial_number,
manufacturer=device.manufacturer,
description=device.description,
)
return None

View File

@@ -12,19 +12,12 @@
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The password for your KLF200 gateway."
},
"description": "Please enter the password for {name} ({host})"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The hostname or IP address of your KLF200 gateway.",
"password": "The password for your KLF200 gateway."
}
}
}

View File

@@ -25,7 +25,6 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
_LOGGER = logging.getLogger(__name__)

View File

@@ -1,68 +0,0 @@
"""Update entity for VeSync.."""
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entity."""
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
@callback
def discover(devices):
"""Add new devices to platform."""
_setup_entities(devices, async_add_entities, coordinator)
config_entry.async_on_unload(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
_setup_entities(
hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
)
@callback
def _setup_entities(
devices: list[VeSyncBaseDevice],
async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: VeSyncDataCoordinator,
) -> None:
"""Check if device is a light and add entity."""
async_add_entities(
VeSyncDeviceUpdate(
device=device,
coordinator=coordinator,
)
for device in devices
)
class VeSyncDeviceUpdate(VeSyncBaseEntity, UpdateEntity):
"""Representation of a VeSync device update entity."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
@property
def installed_version(self) -> str | None:
"""Return installed_version."""
return self.device.current_firm_version
@property
def latest_version(self) -> str | None:
"""Return latest_version."""
return self.device.latest_firm_version

View File

@@ -40,22 +40,6 @@
"default": "mdi:shower-head"
}
},
"fan": {
"ventilation": {
"state_attributes": {
"preset_mode": {
"state": {
"permanent": "mdi:fan",
"sensor_driven": "mdi:refresh-auto",
"sensor_override": "mdi:refresh-auto",
"standard": "mdi:snail",
"standby": "mdi:power-standby",
"ventilation": "mdi:clock-outline"
}
}
}
}
},
"number": {
"heating_curve_shift": {
"default": "mdi:plus-minus-variant"

View File

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

View File

@@ -17,7 +17,6 @@ from homeassistant.components.homeassistant_hardware.helpers import (
async_notify_firmware_info,
async_register_firmware_info_provider,
)
from homeassistant.components.usb import usb_device_from_path
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_TYPE,
@@ -135,21 +134,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
Will automatically load components to support devices found on the network.
"""
# Try to perform an in-place migration if we detect that the device path can be made
# unique
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
usb_device = await hass.async_add_executor_job(usb_device_from_path, device_path)
if usb_device is not None and device_path != usb_device.device:
_LOGGER.info(
"Migrating ZHA device path from %s to %s", device_path, usb_device.device
)
new_data = {**config_entry.data}
new_data[CONF_DEVICE][CONF_DEVICE_PATH] = usb_device.device
hass.config_entries.async_update_entry(config_entry, data=new_data)
device_path = usb_device.device
ha_zha_data: HAZHAData = get_zha_data(hass)
ha_zha_data.config_entry = config_entry
zha_lib_data: ZHAData = create_zha_config(hass, ha_zha_data)
@@ -179,6 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
# Check if firmware update is in progress for this device
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
_raise_if_port_in_use(hass, device_path)
try:

View File

@@ -3,18 +3,18 @@
from __future__ import annotations
from abc import abstractmethod
import asyncio
import collections
from contextlib import suppress
from enum import StrEnum
import json
import os
from typing import Any
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from zha.application.const import RadioType
import zigpy.backups
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_TX_POWER
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetworkSettings
from homeassistant.components import onboarding, usb
@@ -25,7 +25,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
ZigbeeFlowStrategy,
)
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
from homeassistant.components.usb import USBDevice, scan_serial_ports
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_ZEROCONF,
@@ -39,7 +38,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, progress_step
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
@@ -125,10 +124,10 @@ def _format_backup_choice(
return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})"
async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
"""List all serial ports, including the Yellow radio and the multi-PAN addon."""
ports: list[USBDevice] = []
ports.extend(await hass.async_add_executor_job(scan_serial_ports))
ports: list[ListPortInfo] = []
ports.extend(await hass.async_add_executor_job(serial.tools.list_ports.comports))
# Add useful info to the Yellow's serial port selection screen
try:
@@ -138,14 +137,9 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
else:
# PySerial does not properly handle the Yellow's serial port with the CM5
# so we manually include it
port = USBDevice(
device="/dev/ttyAMA1",
vid="ffff", # This is technically not a USB device
pid="ffff",
serial_number=None,
manufacturer="Nabu Casa",
description="Yellow Zigbee module",
)
port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True)
port.description = "Yellow Zigbee module"
port.manufacturer = "Nabu Casa"
ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")]
ports.insert(0, port)
@@ -162,15 +156,13 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
addon_info = None
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
addon_port = USBDevice(
addon_port = ListPortInfo(
device=silabs_multiprotocol_addon.get_zigbee_socket(),
vid="ffff", # This is technically not a USB device
pid="ffff",
serial_number=None,
manufacturer="Nabu Casa",
description="Silicon Labs Multiprotocol add-on",
skip_link_detection=True,
)
addon_port.description = "Multiprotocol add-on"
addon_port.manufacturer = "Nabu Casa"
ports.append(addon_port)
return ports
@@ -180,7 +172,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
"""Mixin for common ZHA flow steps and forms."""
_flow_strategy: ZigbeeFlowStrategy | None = None
_overwrite_ieee_during_restore: bool = False
_hass: HomeAssistant
_title: str
@@ -190,8 +181,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._hass = None # type: ignore[assignment]
self._radio_mgr = ZhaRadioManager()
self._restore_backup_task: asyncio.Task[None] | None = None
self._extra_network_config: dict[str, Any] = {}
@property
def hass(self) -> HomeAssistant:
@@ -229,15 +218,8 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Choose a serial port."""
ports = await list_serial_ports(self.hass)
# The full `/dev/serial/by-id/` path is too verbose to show
resolved_paths = {
p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device)
for p in ports
}
list_of_ports = [
f"{resolved_paths[p.device]} - {p.description}{', s/n: ' + p.serial_number if p.serial_number else ''}"
f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
+ (f" - {p.manufacturer}" if p.manufacturer else "")
for p in ports
]
@@ -464,7 +446,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
return await self.async_step_maybe_reset_old_radio()
@progress_step()
async def async_step_maybe_reset_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -492,69 +473,9 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
try:
await temp_radio_mgr.async_reset_adapter()
except HomeAssistantError:
# Old adapter not found or cannot connect, show prompt to plug back in
return await self.async_step_plug_in_old_radio()
await temp_radio_mgr.async_reset_adapter()
return await self.async_step_restore_backup()
async def async_step_plug_in_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Prompt user to plug in the old radio if connection fails."""
config_entries = self.hass.config_entries.async_entries(
DOMAIN, include_ignore=False
)
# Unless the user removes the config entry whilst we try to reset the old radio
# for a few seconds and then also unplugs it, we will basically never hit this
if not config_entries:
return await self.async_step_restore_backup()
config_entry = config_entries[0]
old_device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
return self.async_show_menu(
step_id="plug_in_old_radio",
menu_options=["retry_old_radio", "skip_reset_old_radio"],
description_placeholders={"device_path": old_device_path},
)
async def async_step_retry_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Retry connecting to the old radio."""
return await self.async_step_maybe_reset_old_radio()
async def async_step_skip_reset_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Skip resetting the old radio and continue with migration."""
return await self.async_step_restore_backup()
async def async_step_pre_plug_in_new_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Strip user_input before showing "plug in new radio" form."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_plug_in_new_radio()
async def async_step_plug_in_new_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Prompt user to plug in the new radio if connection fails."""
if user_input is not None:
# User confirmed, retry now
return await self.async_step_restore_backup()
assert self._radio_mgr.device_path is not None
return self.async_show_form(
step_id="plug_in_new_radio",
description_placeholders={"device_path": self._radio_mgr.device_path},
)
return await self.async_step_maybe_confirm_ezsp_restore()
async def async_step_migration_strategy_advanced(
self, user_input: dict[str, Any] | None = None
@@ -618,13 +539,11 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
# This step exists only for translations, it does nothing new
return await self.async_step_form_new_network(user_input)
@progress_step()
async def async_step_form_new_network(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Form a brand-new network."""
await self._radio_mgr.async_form_network(config=self._extra_network_config)
await self._radio_mgr.async_form_network()
# Load the newly formed network settings to get the network info
await self._radio_mgr.async_load_network_settings()
return await self._async_create_radio_entry()
@@ -705,78 +624,47 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
),
)
async def async_step_restore_backup(
async def async_step_maybe_confirm_ezsp_restore(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Restore network backup to new radio."""
if self._restore_backup_task is None:
self._restore_backup_task = self.hass.async_create_task(
self._radio_mgr.restore_backup(
overwrite_ieee=self._overwrite_ieee_during_restore
),
"Restore backup",
)
"""Confirm restore for EZSP radios that require permanent IEEE writes."""
if user_input is not None:
if user_input[OVERWRITE_COORDINATOR_IEEE]:
# On confirmation, overwrite destructively
try:
await self._radio_mgr.restore_backup(overwrite_ieee=True)
except CannotWriteNetworkSettings as exc:
return self.async_abort(
reason="cannot_restore_backup",
description_placeholders={"error": str(exc)},
)
if not self._restore_backup_task.done():
return self.async_show_progress(
step_id="restore_backup",
progress_action="restore_backup",
progress_task=self._restore_backup_task,
)
return await self._async_create_radio_entry()
# On rejection, explain why we can't restore
return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm")
# On first attempt, just try to restore nondestructively
try:
await self._restore_backup_task
await self._radio_mgr.restore_backup()
except DestructiveWriteNetworkSettings:
# If we cannot restore without overwriting the IEEE, ask for confirmation
return self.async_show_progress_done(
next_step_id="pre_confirm_ezsp_ieee_overwrite"
)
except HomeAssistantError:
# User unplugged the new adapter, allow retry
return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio")
# Restore cannot happen automatically, we need to ask for permission
pass
except CannotWriteNetworkSettings as exc:
return self.async_abort(
reason="cannot_restore_backup",
description_placeholders={"error": str(exc)},
)
finally:
self._restore_backup_task = None
else:
return await self._async_create_radio_entry()
# Otherwise, proceed to entry creation
return self.async_show_progress_done(next_step_id="create_entry")
async def async_step_pre_confirm_ezsp_ieee_overwrite(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Strip user_input before showing confirmation form."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_ezsp_ieee_overwrite()
async def async_step_confirm_ezsp_ieee_overwrite(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show confirmation form for EZSP IEEE address overwrite."""
if user_input is None:
return self.async_show_form(
step_id="confirm_ezsp_ieee_overwrite",
data_schema=vol.Schema(
{vol.Required(OVERWRITE_COORDINATOR_IEEE, default=True): bool}
),
)
if not user_input[OVERWRITE_COORDINATOR_IEEE]:
return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm")
self._overwrite_ieee_during_restore = True
return await self.async_step_restore_backup()
async def async_step_create_entry(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Create the config entry after successful setup/migration."""
# This step only exists so that we can create entries from other steps
return await self._async_create_radio_entry()
# If it fails, show the form
return self.async_show_form(
step_id="maybe_confirm_ezsp_restore",
data_schema=vol.Schema(
{vol.Required(OVERWRITE_COORDINATOR_IEEE, default=True): bool}
),
)
class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
@@ -1009,9 +897,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
device_path = device_settings[CONF_DEVICE_PATH]
self._flow_strategy = discovery_data.get("flow_strategy")
if "tx_power" in discovery_data:
self._extra_network_config[CONF_NWK_TX_POWER] = discovery_data["tx_power"]
await self._set_unique_id_and_update_ignored_flow(
unique_id=f"{name}_{radio_type.name}_{device_path}",
device_path=device_path,
@@ -1133,7 +1018,7 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
# If we are reconfiguring, the old radio will not be available
if self._migration_intent is OptionsMigrationIntent.RECONFIGURE:
return await self.async_step_restore_backup()
return await self.async_step_maybe_confirm_ezsp_restore()
return await super().async_step_maybe_reset_old_radio(user_input)

View File

@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["zha==0.0.78"],
"requirements": ["zha==0.0.77"],
"usb": [
{
"description": "*2652*",

View File

@@ -78,7 +78,6 @@ HARDWARE_DISCOVERY_SCHEMA = vol.Schema(
vol.Required("port"): DEVICE_SCHEMA,
vol.Required("radio_type"): str,
vol.Optional("flow_strategy"): vol.All(str, vol.Coerce(ZigbeeFlowStrategy)),
vol.Optional("tx_power"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)),
}
)
@@ -323,7 +322,7 @@ class ZhaRadioManager:
return backup
async def async_form_network(self, config: dict[str, Any] | None) -> None:
async def async_form_network(self) -> None:
"""Form a brand-new network."""
# When forming a new network, we delete the ZHA database to prevent old devices
@@ -332,7 +331,7 @@ class ZhaRadioManager:
await self.hass.async_add_executor_job(os.remove, self.zigpy_database_path)
async with self.create_zigpy_app() as app:
await app.form_network(config=config)
await app.form_network()
async def async_reset_adapter(self) -> None:
"""Reset the current adapter."""

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