"""Utility methods.""" import asyncio from collections.abc import Iterable from enum import Enum, IntEnum from functools import partial, wraps from inspect import isawaitable, iscoroutinefunction import logging import traceback from . import pub from .address import Address from .commands import commands from .constants import ( HC_LOOKUP, RAMP_RATES, RAMP_RATES_SEC, UC_LOOKUP, FanSpeed, FanSpeedRange, MessageFlagType, ResponseStatus, ThermostatMode, X10Commands, ) from .topics import STATUS_REQUEST _LOGGER = logging.getLogger(__name__) _LOGGER_TOPICS = logging.getLogger("pyinsteon.topics") def housecode_to_byte(housecode: str) -> int: """Return the byte value of an X10 housecode.""" return HC_LOOKUP.get(housecode.lower()) def unitcode_to_byte(unitcode: int) -> int: """Return the byte value of an X10 unitcode.""" return UC_LOOKUP.get(unitcode) def byte_to_housecode(bytecode: int) -> str: """Return an X10 housecode value from a byte value.""" house_code = list(HC_LOOKUP.keys())[list(HC_LOOKUP.values()).index(bytecode)] return house_code.upper() def byte_to_unitcode(bytecode: int) -> int: """Return an X10 unitcode value from a byte value.""" return list(UC_LOOKUP.keys())[list(UC_LOOKUP.values()).index(bytecode)] def byte_to_command(bytecode: int) -> int: """Return an X10 unitcode value from a byte value.""" return X10Commands(bytecode) def parse_x10(raw_x10): """Convert an X10 message to a dictionary.""" housecode, uc_or_cmd = raw_x10_to_bytes(raw_x10) return byte_to_housecode(housecode), uc_or_cmd def byte_to_int(bytecode: bytes) -> int: """Return an int from a byte string.""" return int.from_bytes(bytecode, byteorder="big") def raw_x10_to_bytes(raw_x10: int) -> int: """Return the byte value of a raw X10 command.""" house_code_byte = raw_x10 >> 4 uc_or_cmd_byte = raw_x10 & 0x0F return house_code_byte, uc_or_cmd_byte def bit_is_set(bitmask: int, bit: int) -> bool: """Return True if a specific bit is set in a bitmask. Uses the low bit is 1 and the high bit is 8. """ return bool(bitmask & (1 << bit)) def set_bit(data: int, bit: int, is_on: bool) -> int: """Set the value of a bit in a bitmask on or off. Uses the low bit is 0 and the high bit is 7. """ if isinstance(data, bytes): bitmask = int.from_bytes(data, byteorder="big") else: bitmask = data if is_on: return bitmask | (1 << bit) return bitmask & (0xFF & ~(1 << bit)) def vars_to_bytes(vals: Iterable) -> bytes: """Create a byte string from a set of values.""" msg = bytearray() for val in vals: if val is None: pass elif isinstance(val, int): msg.extend(bytes([val])) else: msg.extend(bytes(val)) return bytes(msg) def vars_to_string(vals: Iterable) -> str: """Create a byte string from a set of values.""" output = [] for fld, val in vals: if val is None: valstr = "" elif isinstance(val, (Enum, IntEnum)): valstr = str(val) elif isinstance(val, int): valstr = f"0x{val:02x}" elif isinstance(val, bytes): valstr = f"0x{val.hex():s}" else: valstr = str(val) output.append(f"{fld}: {valstr}") return ", ".join(output) def vars_to_repr(vals: Iterable) -> str: """Create a byte string from a set of values.""" output = [] for fld, val in vals: if val is None: valstr = "" elif isinstance(val, (Enum, IntEnum)): valstr = repr(val) elif isinstance(val, int): valstr = f"0x{val:02x}" elif isinstance(val, bytes): valstr = f"0x{val.hex():s}" else: valstr = repr(val) output.append(f"{fld}: {valstr}") return ", ".join(output) def test_values_eq(val1, val2) -> bool: """Test if val1 eq val2 for template management.""" if val1 is None or val2 is None: return True if val1 == val2: return True return False def _include_address(prefix, topic, address, message_type): """Test if we should include the address in the topic build.""" if address is None: return False if isinstance(message_type, str): message_type = getattr(MessageFlagType, str(message_type).upper()) if prefix == "send" and message_type == MessageFlagType.DIRECT: return False if prefix == "handler" and message_type == MessageFlagType.DIRECT: return True if commands.get(topic) is None: return False return True def _msg_group(topic, group, message_type): if topic == STATUS_REQUEST and group is not None: return group if commands.use_group(topic) and group is not None: return group if group else 1 if group is None: return None if not commands.use_group(topic): return None if message_type in [MessageFlagType.ALL_LINK_CLEANUP]: return None return 1 def build_topic(topic, prefix=None, address=None, group=None, message_type=None): """Build a full topic from components.""" full_topic = "" if prefix is not None: # Adding the . separator since there must be something after a prefix full_topic = f"{str(prefix).lower()}." if _include_address(prefix, topic, address, message_type): addr = address.id if isinstance(address, Address) else address full_topic = f"{full_topic}{addr}." group = _msg_group(topic, group, message_type) if group is not None: full_topic = f"{full_topic}{group}." full_topic = f"{full_topic}{topic}" if message_type is not None: full_topic = f"{full_topic}.{str(message_type).lower()}" return full_topic def multiple_status(*args): """Return the proper status based on the worst case of all status responses.""" worst_response = 1 for response in args: if response is None: continue if int(response) == 0 or worst_response == 0: worst_response = 0 elif int(response) > worst_response: worst_response = int(response) return ResponseStatus(worst_response) def ramp_rate_to_seconds(ramp_rate: int): """Return the seconds associated with a ramp rate.""" if int(ramp_rate) not in range(0, 32): raise ValueError("Ramp rate must be between 0x00 and 0x1f (31)") return RAMP_RATES[int(ramp_rate)] def _abs_diff(list_value, test_value): """Return the absolute difference between two numbers.""" return abs(list_value - test_value) def seconds_to_ramp_rate(seconds: float): """Return the ramp rate asscociated with a number of seconds.""" if seconds > 480 or seconds < 0.1: raise ValueError( "Ramp rate cannot be less than 0.1 seconds or more than 480 seconds (8 minutes)" ) rr_sec_list = list(map(float, RAMP_RATES_SEC.keys())) abs_diff = partial(_abs_diff, test_value=seconds) rr_sec = min(rr_sec_list, key=abs_diff) return RAMP_RATES_SEC[rr_sec] def log_error(msg, ex, topic=None, kwargs=None): """Print an error message when a topic cannot be distributed.""" _LOGGER.error("An issue occured distributing the following message") _LOGGER.error("MSG: %s", msg) _LOGGER.error("Topic: %s data: %s", topic, kwargs) _LOGGER.error("Error: %s", str(ex)) _LOGGER_TOPICS.debug(traceback.format_exc()) if topic is not None: topic_mgr = pub.getDefaultTopicMgr() topic = topic_mgr.getTopic(topic, okIfNone=True) if topic: for subscriber in topic.getListeners(): _LOGGER.error(" Subscriber: %s", subscriber) def to_celsius(fahrenheit): """Convert fahrenheit to celsius.""" return int(round((fahrenheit - 32) * 5 / 9, 0)) def to_fahrenheit(celsius): """Convert celsius to fahrenheit.""" return int(round(celsius * 9 / 5 + 32, 0)) def calc_thermostat_temp(high_byte, low_byte): """Calculate the temperature.""" return round((low_byte | (high_byte << 8)) * 0.1, 1) def calc_thermostat_mode(thermostat_mode_byte, sys_mode_map=None, sys_low=True): """Calculate the system and fan mode.""" if sys_mode_map is None: sys_mode_map = { int(ThermostatMode.OFF): ThermostatMode.OFF, int(ThermostatMode.AUTO): ThermostatMode.AUTO, int(ThermostatMode.HEAT): ThermostatMode.HEAT, int(ThermostatMode.COOL): ThermostatMode.COOL, } mode1 = thermostat_mode_byte & 0x0F mode2 = thermostat_mode_byte >> 4 system_mode, fan_mode = (mode1, mode2) if sys_low else (mode2, mode1) if fan_mode in (0, 4): fan_mode = ThermostatMode.FAN_AUTO else: fan_mode = ThermostatMode.FAN_ALWAYS_ON sys_mode = sys_mode_map.get(system_mode) if sys_mode is None: sys_mode = ThermostatMode.AUTO return sys_mode, fan_mode def publish_topic(topic, logger=None, **kwargs): """Publish a topic and log errors.""" # Send log message as caller not utils. if logger is None: logger = logging.getLogger(__name__) try: pub_topic = pub.getDefaultTopicMgr().getTopic(topic, okIfNone=True) if pub_topic: for listener in pub_topic.listeners: if listener.isDead(): pub_topic.unsubscribe(listener) pub.sendMessage(topic, **kwargs) except pub.ExcHandlerError as exc: logger.error("pubsub ExcHandlerError") logger.error("Error processing topic: %s", topic) logger.error("Error in topic listner: %s", exc.badExcListenerID) except pub.SenderMissingReqdMsgDataError as exc: logger.error("SenderMissingReqdMsgDataError") logger.error(str(exc)) for listner in pub.getDefaultTopicMgr().getTopic(topic).getListeners(): logger.error("Topic listener: %s", listner) except pub.SenderUnknownMsgDataError as exc: logger.error("SenderUnknownMsgDataError") logger.error(str(exc)) for listner in pub.getDefaultTopicMgr().getTopic(topic).getListeners(): logger.error("Topic listener: %s", listner) async_listeners = {} def get_async_listener(listener): """Get the async version of a listener.""" def setup_async_listener(listener): @wraps(listener) def _wrapper(*args, **kwargs): return asyncio.create_task(listener(*args, **kwargs)) return _wrapper async_listener = async_listeners.get(listener) if async_listener: return async_listener async_listener = setup_async_listener(listener) async_listeners[listener] = async_listener return async_listener def remove_async_listener(listener): """Remove an async listener.""" async_listeners.pop(listener) def subscribe_topic(listener, topic_name, logger=None): """Subscribe a listener to a topic and log errors.""" topic_mgr = pub.getDefaultTopicMgr() topic = topic_mgr.getOrCreateTopic(topic_name) if topic.hasListener(listener): return if logger is None: logger = logging.getLogger(__name__) try: if iscoroutinefunction(listener) or isawaitable(listener): async_listener = get_async_listener(listener) if topic.hasListener(async_listener): return topic.subscribe(async_listener) else: topic.subscribe(listener) except pub.ListenerMismatchError as exc: logger.error("ListenerMismatchError") logger.error("args: %s", exc.args) logger.error("msg: %s", exc.msg) logger.error("module: %s", exc.module) logger.error("idStr: %s", exc.idStr) for topic_listner in pub.getDefaultTopicMgr().getTopic(topic).getListeners(): logger.error("Topic listener: %s", topic_listner) def unsubscribe_topic(listener, topic_name): """Unsubscribe a listener to a topic and log errors.""" listener = async_listeners.get(listener, listener) topic_mgr = pub.getDefaultTopicMgr() topic = topic_mgr.getOrCreateTopic(topic_name) if pub.isSubscribed(listener, topicName=topic.name): pub.unsubscribe(listener, topic_name) def set_fan_speed(on_level): """Map a value to a fan speed.""" if on_level in FanSpeedRange.OFF.value: return FanSpeed.OFF if on_level in FanSpeedRange.LOW.value: return FanSpeed.LOW if on_level in FanSpeedRange.MEDIUM.value: return FanSpeed.MEDIUM return FanSpeed.HIGH