1 """Shelly helpers functions."""
3 from __future__
import annotations
5 from collections.abc
import Iterable
6 from datetime
import datetime, timedelta
7 from ipaddress
import IPv4Address, IPv6Address, ip_address
8 from types
import MappingProxyType
9 from typing
import Any, cast
11 from aiohttp.web
import Request, WebSocketResponse
12 from aioshelly.block_device
import COAP, Block, BlockDevice
13 from aioshelly.const
import (
25 from aioshelly.rpc_device
import RpcDevice, WsServer
34 device_registry
as dr,
35 entity_registry
as er,
45 BASIC_INPUTS_EVENTS_TYPES,
49 DEVICES_WITHOUT_FIRMWARE_CHANGELOG,
51 FIRMWARE_UNSUPPORTED_ISSUE_ID,
55 RPC_INPUTS_EVENTS_TYPES,
56 SHBTN_INPUTS_EVENTS_TYPES,
58 SHIX3_1_INPUTS_EVENTS_TYPES,
60 VIRTUAL_COMPONENTS_MAP,
66 hass: HomeAssistant, domain: str, unique_id: str
68 """Remove a Shelly entity."""
69 entity_reg = er.async_get(hass)
70 entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id)
72 LOGGER.debug(
"Removing entity: %s", entity_id)
73 entity_reg.async_remove(entity_id)
77 """Get number of channels for block type."""
80 if block.type ==
"input":
82 if device.settings[
"device"][
"type"]
in [
89 channels = device.shelly.get(
"num_inputs")
90 elif block.type ==
"emeter":
91 channels = device.shelly.get(
"num_emeters")
92 elif block.type
in [
"relay",
"light"]:
93 channels = device.shelly.get(
"num_outputs")
94 elif block.type
in [
"roller",
"device"]:
103 description: str |
None =
None,
105 """Naming for block based switch and sensors."""
109 return f
"{channel_name} {description.lower()}"
115 """Get name based on device and channel name."""
116 entity_name = device.name
120 or block.type ==
"device"
127 channel_name: str |
None =
None
128 mode = cast(str, block.type) +
"s"
129 if mode
in device.settings:
130 channel_name = device.settings[mode][
int(block.channel)].
get(
"name")
135 if device.settings[
"device"][
"type"] == MODEL_EM3:
140 return f
"{entity_name} channel {chr(int(block.channel)+base)}"
144 settings: dict[str, Any], block: Block, include_detached: bool =
False
146 """Return true if block input button settings is set to a momentary type."""
147 momentary_types = [
"momentary",
"momentary_on_release"]
150 momentary_types.append(
"detached")
153 if settings[
"device"][
"type"]
in SHBTN_MODELS:
156 if settings.get(
"mode") ==
"roller":
157 button_type = settings[
"rollers"][0][
"button_type"]
158 return button_type
in momentary_types
160 button = settings.get(
"relays")
or settings.get(
"lights")
or settings.get(
"inputs")
165 if settings[
"device"][
"type"] == MODEL_1L:
166 channel =
int(block.channel
or 0) + 1
167 button_type = button[0].
get(
"btn" +
str(channel) +
"_type")
170 channel =
min(
int(block.channel
or 0), len(button) - 1)
171 button_type = button[channel].
get(
"btn_type")
173 return button_type
in momentary_types
177 """Return device uptime string, tolerate up to 5 seconds deviation."""
182 or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION
190 device: BlockDevice, block: Block
191 ) -> list[tuple[str, str]]:
192 """Return list of input triggers for block."""
193 if "inputEvent" not in block.sensor_ids
or "inputEventCnt" not in block.sensor_ids:
203 subtype = f
"button{int(block.channel)+1}"
205 if device.settings[
"device"][
"type"]
in SHBTN_MODELS:
206 trigger_types = SHBTN_INPUTS_EVENTS_TYPES
207 elif device.settings[
"device"][
"type"] == MODEL_I3:
208 trigger_types = SHIX3_1_INPUTS_EVENTS_TYPES
210 trigger_types = BASIC_INPUTS_EVENTS_TYPES
212 return [(trigger_type, subtype)
for trigger_type
in trigger_types]
216 """Return list of input triggers for SHBTN models."""
217 return [(trigger_type,
"button")
for trigger_type
in SHBTN_INPUTS_EVENTS_TYPES]
220 @singleton.singleton("shelly_coap")
222 """Get CoAP context to be used in all Shelly Gen1 devices."""
225 adapters = await network.async_get_adapters(hass)
226 LOGGER.debug(
"Network adapters: %s", adapters)
228 ipv4: list[IPv4Address] = []
229 if not network.async_only_default_interface_enabled(adapters):
232 for address
in await network.async_get_enabled_source_ips(hass)
233 if address.version == 4
235 address.is_link_local
236 or address.is_loopback
237 or address.is_multicast
238 or address.is_unspecified
241 LOGGER.debug(
"Network IPv4 addresses: %s", ipv4)
242 if DOMAIN
in hass.data:
243 port = hass.data[DOMAIN].
get(CONF_COAP_PORT, DEFAULT_COAP_PORT)
245 port = DEFAULT_COAP_PORT
246 LOGGER.info(
"Starting CoAP context with UDP port %s", port)
247 await context.initialize(port, ipv4)
250 def shutdown_listener(ev: Event) ->
None:
253 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener)
258 """Handle pushes from Shelly Gen2 devices."""
260 requires_auth =
False
262 name =
"api:shelly:ws"
265 """Initialize the Shelly receiver view."""
268 async
def get(self, request: Request) -> WebSocketResponse:
269 """Start a get request."""
270 return await self.
_ws_server_ws_server.websocket_handler(request)
273 @singleton.singleton("shelly_ws_server")
275 """Get websocket server context to be used in all Shelly Gen2 devices."""
276 ws_server = WsServer()
282 """Return the device sleep period in seconds or 0 for non sleeping devices."""
285 if settings.get(
"sleep_mode",
False):
286 sleep_period = settings[
"sleep_mode"][
"period"]
287 if settings[
"sleep_mode"][
"unit"] ==
"h":
290 return sleep_period * 60
294 """Return the device wakeup period in seconds or 0 for non sleeping devices."""
295 return cast(int, status[
"sys"].
get(
"wakeup_period", 0))
299 """Return true if device has authorization enabled."""
300 return cast(bool, info.get(
"auth")
or info.get(
"auth_en"))
304 """Return the device generation from shelly info."""
305 return int(info.get(CONF_GEN, 1))
309 """Return the device model name."""
311 return cast(str, MODEL_NAMES.get(info[
"model"], info[
"model"]))
313 return cast(str, MODEL_NAMES.get(info[
"type"], info[
"type"]))
317 """Get name based on device and channel name."""
318 key = key.replace(
"emdata",
"em")
319 key = key.replace(
"em1data",
"em1")
320 device_name = device.name
321 entity_name: str |
None =
None
322 if key
in device.config:
323 entity_name = device.config[key].
get(
"name")
325 if entity_name
is None:
326 channel = key.split(
":")[0]
327 channel_id = key.split(
":")[-1]
328 if key.startswith((
"cover:",
"input:",
"light:",
"switch:",
"thermostat:")):
329 return f
"{device_name} {channel.title()} {channel_id}"
330 if key.startswith((
"cct",
"rgb:",
"rgbw:")):
331 return f
"{device_name} {channel.upper()} light {channel_id}"
332 if key.startswith(
"em1"):
333 return f
"{device_name} EM{channel_id}"
334 if key.startswith((
"boolean:",
"enum:",
"number:",
"text:")):
335 return f
"{channel.title()} {channel_id}"
342 device: RpcDevice, key: str, description: str |
None =
None
344 """Naming for RPC based switch and sensors."""
348 return f
"{channel_name} {description.lower()}"
354 """Return the device generation from config entry."""
355 return entry.data.get(CONF_GEN, 1)
359 """Return list of key instances for RPC device from a dict."""
363 if key ==
"switch" and "cover:0" in keys_dict:
366 return [k
for k
in keys_dict
if k.startswith(f
"{key}:")]
370 """Return list of key ids for RPC device from a dict."""
371 return [
int(k.split(
":")[1])
for k
in keys_dict
if k.startswith(f
"{key}:")]
375 config: dict[str, Any], status: dict[str, Any], key: str
377 """Return true if rpc input button settings is set to a momentary type."""
378 return cast(bool, config[key][
"type"] ==
"button")
382 """Return true if block channel appliance type is set to light."""
383 app_type = settings[
"relays"][channel].
get(
"appliance_type")
384 return app_type
is not None and app_type.lower().startswith(
"light")
388 """Return true if rpc channel consumption type is set to light."""
389 con_types = config[
"sys"].
get(
"ui_data", {}).
get(
"consumption_types")
390 if con_types
is None or len(con_types) <= channel:
392 return cast(str, con_types[channel]).lower().startswith(
"light")
396 """Return true if the thermostat uses an internal relay."""
397 return cast(bool, status[
"sys"].
get(
"relay_in_thermostat",
False))
401 """Return list of input triggers for RPC device."""
411 for trigger_type
in RPC_INPUTS_EVENTS_TYPES:
412 subtype = f
"button{id_+1}"
413 triggers.append((trigger_type, subtype))
420 hass: HomeAssistant, shellydevice: BlockDevice | RpcDevice, entry: ConfigEntry
422 """Update the firmware version information in the device registry."""
423 assert entry.unique_id
425 dev_reg = dr.async_get(hass)
426 if device := dev_reg.async_get_device(
427 identifiers={(DOMAIN, entry.entry_id)},
428 connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))},
430 if device.sw_version == shellydevice.firmware_version:
433 LOGGER.debug(
"Updating device registry info for %s", entry.title)
435 dev_reg.async_update_device(device.id, sw_version=shellydevice.firmware_version)
439 """Convert brightness level to percentage."""
440 return int(100 * (brightness + 1) / 255)
444 """Convert percentage to brightness level."""
445 return round(255 * percentage / 100)
449 """Convert a name to a mac address."""
450 mac = name.partition(
".")[0].partition(
"-")[-1]
451 return mac.upper()
if len(mac) == 12
else None
455 """Return release URL or None."""
456 if beta
or model
in DEVICES_WITHOUT_FIRMWARE_CHANGELOG:
459 return GEN1_RELEASE_URL
if gen
in BLOCK_GENERATIONS
else GEN2_RELEASE_URL
464 hass: HomeAssistant, entry: ConfigEntry
466 """Create a repair issue if the device runs an unsupported firmware."""
467 ir.async_create_issue(
470 FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id),
473 severity=ir.IssueSeverity.ERROR,
474 translation_key=
"unsupported_firmware",
475 translation_placeholders={
476 "device_name": entry.title,
477 "ip_address": entry.data[
"host"],
483 config: dict[str, Any], _status: dict[str, Any], key: str
485 """Return true if rpc all WiFi stations are disabled."""
486 if config[key][
"sta"][
"enable"]
is True or config[key][
"sta1"][
"enable"]
is True:
493 """Get port from config entry data."""
494 return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT))
498 """Get the device IP address or hostname."""
500 ip_object = ip_address(host)
505 if isinstance(ip_object, IPv6Address):
513 hass: HomeAssistant, domain: str, mac: str, keys: list[str]
515 """Remove RPC based Shelly entity."""
516 entity_reg = er.async_get(hass)
518 if entity_id := entity_reg.async_get_entity_id(domain, DOMAIN, f
"{mac}-{key}"):
519 LOGGER.debug(
"Removing entity: %s", entity_id)
520 entity_reg.async_remove(entity_id)
524 """Return True if 'thermostat:<IDent>' is present in the status."""
525 return f
"thermostat:{ident}" in status
529 """Return a list of virtual component IDs for a platform."""
530 component = VIRTUAL_COMPONENTS_MAP.get(platform)
537 for comp_type
in component[
"types"]:
540 for k, v
in config.items()
541 if k.startswith(comp_type)
and v[
"meta"][
"ui"][
"view"]
in component[
"modes"]
550 config_entry_id: str,
554 key_suffix: str |
None =
None,
556 """Remove orphaned entities."""
557 orphaned_entities = []
558 entity_reg = er.async_get(hass)
559 device_reg = dr.async_get(hass)
562 devices := device_reg.devices.get_devices_for_config_entry_id(config_entry_id)
566 device_id = devices[0].id
567 entities = er.async_entries_for_device(entity_reg, device_id,
True)
568 for entity
in entities:
569 if not entity.entity_id.startswith(platform):
571 if key_suffix
is not None and key_suffix
not in entity.unique_id:
574 if not (match := COMPONENT_ID_PATTERN.search(entity.unique_id)):
579 orphaned_entities.append(entity.unique_id.split(
"-", 1)[1])
581 if orphaned_entities:
586 """Return the RPC websocket URL."""
588 raw_url =
get_url(hass, prefer_external=
False, allow_cloud=
False)
589 except NoURLAvailableError:
590 LOGGER.debug(
"URL not available, skipping outbound websocket setup")
593 ws_url = url.with_scheme(
"wss" if url.scheme ==
"https" else "ws")
594 return str(ws_url.joinpath(API_WS_URL.removeprefix(
"/")))
None __init__(self, WsServer ws_server)
WebSocketResponse get(self, Request request)
web.Response get(self, web.Request request, str config_key)
bool get_info_auth(dict[str, Any] info)
bool is_rpc_channel_type_light(dict[str, Any] config, int channel)
bool is_rpc_momentary_input(dict[str, Any] config, dict[str, Any] status, str key)
list[tuple[str, str]] get_shbtn_input_triggers()
bool is_rpc_thermostat_internal_actuator(dict[str, Any] status)
int percentage_to_brightness(int percentage)
list[tuple[str, str]] get_rpc_input_triggers(RpcDevice device)
WsServer get_ws_context(HomeAssistant hass)
list[str] get_virtual_component_ids(dict[str, Any] config, str platform)
None async_remove_shelly_rpc_entities(HomeAssistant hass, str domain, str mac, list[str] keys)
datetime get_device_uptime(float uptime, datetime|None last_uptime)
list[tuple[str, str]] get_block_input_triggers(BlockDevice device, Block block)
list[str] get_rpc_key_instances(dict[str, Any] keys_dict, str key)
int get_info_gen(dict[str, Any] info)
bool is_block_momentary_input(dict[str, Any] settings, Block block, bool include_detached=False)
str get_rpc_channel_name(RpcDevice device, str key)
bool is_rpc_wifi_stations_disabled(dict[str, Any] config, dict[str, Any] _status, str key)
bool is_rpc_thermostat_mode(int ident, dict[str, Any] status)
None async_remove_orphaned_entities(HomeAssistant hass, str config_entry_id, str mac, str platform, Iterable[str] keys, str|None key_suffix=None)
str get_block_entity_name(BlockDevice device, Block|None block, str|None description=None)
str get_model_name(dict[str, Any] info)
COAP get_coap_context(HomeAssistant hass)
int get_rpc_device_wakeup_period(dict[str, Any] status)
str|None get_rpc_ws_url(HomeAssistant hass)
None async_create_issue_unsupported_firmware(HomeAssistant hass, ConfigEntry entry)
None update_device_fw_info(HomeAssistant hass, BlockDevice|RpcDevice shellydevice, ConfigEntry entry)
int brightness_to_percentage(int brightness)
list[int] get_rpc_key_ids(dict[str, Any] keys_dict, str key)
str|None mac_address_from_name(str name)
int get_device_entry_gen(ConfigEntry entry)
str|None get_release_url(int gen, str model, bool beta)
int get_http_port(MappingProxyType[str, Any] data)
bool is_block_channel_type_light(dict[str, Any] settings, int channel)
None async_remove_shelly_entity(HomeAssistant hass, str domain, str unique_id)
str get_block_channel_name(BlockDevice device, Block|None block)
int get_block_device_sleep_period(dict[str, Any] settings)
str get_rpc_entity_name(RpcDevice device, str key, str|None description=None)
int get_number_of_channels(BlockDevice device, Block block)
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)