"""Tasmota utility functions.""" from __future__ import annotations from collections.abc import Mapping from functools import reduce import json import logging import operator import re from typing import Any, cast from .const import ( CONF_DEVICENAME, CONF_FRIENDLYNAME, CONF_FULLTOPIC, CONF_HOSTNAME, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, CONF_PREFIX, CONF_STATE, CONF_SWITCHNAME, CONF_TOPIC, PREFIX_CMND, PREFIX_STAT, PREFIX_TELE, RSLT_ACTION, RSLT_POWER, RSLT_STATE, STATE_OFF, STATE_ON, ) from .mqtt import ReceivePayloadType _LOGGER = logging.getLogger(__name__) ConfigType = dict[str, str] def get_by_path(root: dict, items: list[str | int]) -> dict: """Access a nested object in root by item sequence.""" return reduce(operator.getitem, items, root) def set_by_path(root: dict, items: list[str | int], value: Any) -> None: """Set a value in a nested object in root by item sequence.""" get_by_path(root, items[:-1])[items[-1]] = value def del_by_path(root: dict, items: list[str | int]) -> None: """Delete a key-value in a nested object in root by item sequence.""" del get_by_path(root, items[:-1])[items[-1]] def _get_topic(config: ConfigType, prefix: str) -> str: topic = config[CONF_FULLTOPIC] topic = topic.replace("%hostname%", config[CONF_HOSTNAME]) topic = topic.replace("%id%", config[CONF_MAC][-6:]) topic = topic.replace("%prefix%", prefix) topic = topic.replace("%topic%", config[CONF_TOPIC]) return topic def _get_topic_cmnd(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_CMND]) def _get_topic_stat(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_STAT]) def _get_topic_tele(config: ConfigType) -> str: return _get_topic(config, config[CONF_PREFIX][PREFIX_TELE]) def get_topic_command(config: ConfigType) -> str: """Get command topic.""" return _get_topic_cmnd(config) def get_topic_command_state(config: ConfigType) -> str: """Get topic for command power.""" return _get_topic_cmnd(config) + "STATE" def get_topic_command_status(config: ConfigType) -> str: """Get topic for command power.""" return _get_topic_cmnd(config) + "STATUS" def get_topic_stat(config: ConfigType) -> str: """Get stat topic.""" return _get_topic_stat(config) def get_topic_stat_button_trigger(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"BUTTON{idx + 1}" def get_topic_stat_result(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + "RESULT" def get_topic_stat_status(config: ConfigType, idx: int | None = None) -> str: """Get topic for tele state.""" if idx is None: return _get_topic_stat(config) + "STATUS" return _get_topic_stat(config) + f"STATUS{idx}" def get_topic_stat_switch(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"SWITCH{idx + 1}" def get_topic_stat_switch_trigger(config: ConfigType, idx: int) -> str: """Get topic for tele state.""" return _get_topic_stat(config) + f"SWITCH{idx + 1}" def get_topic_tele(config: ConfigType) -> str: """Get tele topic.""" return _get_topic_tele(config) def get_topic_tele_sensor(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_tele(config) + "SENSOR" def get_topic_tele_state(config: ConfigType) -> str: """Get topic for tele state.""" return _get_topic_tele(config) + "STATE" def get_topic_tele_will(config: ConfigType) -> str: """Get topic for tele will.""" return _get_topic_tele(config) + "LWT" def config_get_state_power_on(config: ConfigType) -> str: """Get command/result on.""" return config[CONF_STATE][STATE_ON] def config_get_state_power_off(config: ConfigType) -> str: """Get command/result off.""" return config[CONF_STATE][STATE_OFF] def config_get_state_offline(config: ConfigType) -> str: """Get state offline.""" return config[CONF_OFFLINE] def config_get_state_online(config: ConfigType) -> str: """Get state online.""" return config[CONF_ONLINE] def get_value( _status: str, key: str, idx: int | None = None, idx_optional: bool = False ) -> Any: """Get status from JSON formatted status or result.""" try: status = json.loads(_status) except json.decoder.JSONDecodeError: _LOGGER.debug("Invalid JSON '%s'", _status) return None if idx is None: return status.get(key) if key in status and idx_optional and idx == 0: return status[key] key = f"{key}{idx + 1}" return status[key] if key in status else None def get_value_by_path(status: dict | ReceivePayloadType, path: list[str | int]) -> Any: """Get status from JSON formatted status or result by path.""" try: if not isinstance(status, Mapping): status = json.loads(status) return get_by_path(cast(dict, status), path) except (json.decoder.JSONDecodeError, KeyError): return None def get_state_power(status: str, idx: int) -> Any: """Get state power.""" return get_value(status, RSLT_POWER, idx=idx, idx_optional=True) def get_state_state(status: str) -> Any: """Get state of switch.""" return get_value(status, RSLT_STATE) def get_state_button_trigger(status: str) -> Any: """Get state of button.""" return get_value(status, RSLT_ACTION) def config_get_friendlyname(config: ConfigType, platform: str, idx: int) -> str | None: """Get config friendly name.""" friendly_names = config[CONF_FRIENDLYNAME] if idx >= len(friendly_names) or friendly_names[idx] is None: return f"{platform} {idx + 1}" if idx == 0 and friendly_names[idx] == config[CONF_DEVICENAME]: return None return friendly_names[idx] def config_get_switchfriendlyname(config: ConfigType, platform: str, idx: int) -> str: """Get config friendly name.""" switch_names = config[CONF_SWITCHNAME] if idx >= len(switch_names) or switch_names[idx] is None: return f"{platform} {idx + 1}" return switch_names[idx] def config_get_switchname(config: ConfigType, idx: int) -> str: """Get switch name.""" switch_names = config[CONF_SWITCHNAME] if idx >= len(switch_names) or switch_names[idx] is None: return f"Switch{idx + 1}" return switch_names[idx] TOPIC_MATCHER = re.compile(r"^(?P[A-Z0-9_-]+)\/(?:config|sensors)$") def discovery_topic_get_mac(topic: str, discovery_topic: str) -> str | None: """Get MAC from discovery topic.""" topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) if not (match := TOPIC_MATCHER.match(topic_trimmed)): return None (mac,) = match.groups() return mac def discovery_topic_is_device_config(topic: str) -> bool: """Return True if the discovery topic is device configuration.""" return topic.endswith("config")