From c8e5ec4dd8c9e129efb2049d525b81867af1adf5 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:35:03 +0200 Subject: [PATCH 1/8] Localtuya 5.1.0 --- custom_components/localtuya/climate.py | 4 + custom_components/localtuya/common.py | 81 +- custom_components/localtuya/config_flow.py | 78 +- custom_components/localtuya/const.py | 1 + custom_components/localtuya/cover.py | 4 +- custom_components/localtuya/fan.py | 2 +- custom_components/localtuya/manifest.json | 2 +- custom_components/localtuya/number.py | 7 +- .../localtuya/pytuya/__init__.py | 837 ++++++++++++++---- custom_components/localtuya/select.py | 10 +- custom_components/localtuya/switch.py | 6 +- .../localtuya/translations/en.json | 4 +- .../localtuya/translations/it.json | 4 +- .../localtuya/translations/pt-BR.json | 4 +- 14 files changed, 769 insertions(+), 275 deletions(-) diff --git a/custom_components/localtuya/climate.py b/custom_components/localtuya/climate.py index f6433b25..d8585dda 100644 --- a/custom_components/localtuya/climate.py +++ b/custom_components/localtuya/climate.py @@ -69,6 +69,10 @@ HVAC_MODE_HEAT: "Manual", HVAC_MODE_AUTO: "Program", }, + "m/p": { + HVAC_MODE_HEAT: "m", + HVAC_MODE_AUTO: "p", + }, "True/False": { HVAC_MODE_HEAT: True, }, diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index aa8992fd..cd503c2a 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -1,5 +1,6 @@ """Code shared between all platforms.""" import asyncio +import json.decoder import logging import time from datetime import timedelta @@ -25,18 +26,19 @@ from . import pytuya from .const import ( + ATTR_STATE, ATTR_UPDATED_AT, + CONF_DEFAULT_VALUE, + CONF_ENABLE_DEBUG, CONF_LOCAL_KEY, CONF_MODEL, + CONF_PASSIVE_ENTITY, CONF_PROTOCOL_VERSION, + CONF_RESET_DPIDS, + CONF_RESTORE_ON_RECONNECT, DATA_CLOUD, DOMAIN, TUYA_DEVICES, - CONF_DEFAULT_VALUE, - ATTR_STATE, - CONF_RESTORE_ON_RECONNECT, - CONF_RESET_DPIDS, - CONF_PASSIVE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -175,12 +177,13 @@ def connected(self): def async_connect(self): """Connect to device if not already connected.""" + # self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface) if not self._is_closing and self._connect_task is None and not self._interface: self._connect_task = asyncio.create_task(self._make_connection()) async def _make_connection(self): """Subscribe localtuya entity events.""" - self.debug("Connecting to %s", self._dev_config_entry[CONF_HOST]) + self.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST]) try: self._interface = await pytuya.connect( @@ -188,27 +191,30 @@ async def _make_connection(self): self._dev_config_entry[CONF_DEVICE_ID], self._local_key, float(self._dev_config_entry[CONF_PROTOCOL_VERSION]), + self._dev_config_entry.get(CONF_ENABLE_DEBUG, False), self, ) self._interface.add_dps_to_request(self.dps_to_request) - except Exception: # pylint: disable=broad-except - self.exception(f"Connect to {self._dev_config_entry[CONF_HOST]} failed") + except Exception as ex: # pylint: disable=broad-except + self.warning( + f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex + ) if self._interface is not None: await self._interface.close() self._interface = None if self._interface is not None: try: - self.debug("Retrieving initial state") - status = await self._interface.status() - if status is None: - raise Exception("Failed to retrieve status") + try: + self.debug("Retrieving initial state") + status = await self._interface.status() + if status is None: + raise Exception("Failed to retrieve status") - self._interface.start_heartbeat() - self.status_updated(status) + self._interface.start_heartbeat() + self.status_updated(status) - except Exception as ex: # pylint: disable=broad-except - try: + except Exception as ex: if (self._default_reset_dpids is not None) and ( len(self._default_reset_dpids) > 0 ): @@ -226,26 +232,19 @@ async def _make_connection(self): self._interface.start_heartbeat() self.status_updated(status) + else: + self.error("Initial state update failed, giving up: %r", ex) + if self._interface is not None: + await self._interface.close() + self._interface = None - except UnicodeDecodeError as e: # pylint: disable=broad-except - self.exception( - f"Connect to {self._dev_config_entry[CONF_HOST]} failed: %s", - type(e), - ) - if self._interface is not None: - await self._interface.close() - self._interface = None - - except Exception as e: # pylint: disable=broad-except - self.exception( - f"Connect to {self._dev_config_entry[CONF_HOST]} failed" - ) - if "json.decode" in str(type(e)): - await self.update_local_key() + except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex: + self.warning("Initial state update failed (%s), trying key update", ex) + await self.update_local_key() - if self._interface is not None: - await self._interface.close() - self._interface = None + if self._interface is not None: + await self._interface.close() + self._interface = None if self._interface is not None: # Attempt to restore status for all entities that need to first set @@ -268,14 +267,16 @@ def _new_entity_handler(entity_id): if ( CONF_SCAN_INTERVAL in self._dev_config_entry - and self._dev_config_entry[CONF_SCAN_INTERVAL] > 0 + and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0 ): self._unsub_interval = async_track_time_interval( self._hass, self._async_refresh, - timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]), + timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])), ) + self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}") + self._connect_task = None async def update_local_key(self): @@ -308,7 +309,7 @@ async def close(self): await self._interface.close() if self._disconnect_task is not None: self._disconnect_task() - self.debug( + self.info( "Closed connection with device %s.", self._dev_config_entry[CONF_FRIENDLY_NAME], ) @@ -356,7 +357,11 @@ def disconnected(self): self._unsub_interval() self._unsub_interval = None self._interface = None - self.debug("Disconnected - waiting for discovery broadcast") + + if self._connect_task is not None: + self._connect_task.cancel() + self._connect_task = None + self.warning("Disconnected - waiting for discovery broadcast") class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 1eeb3b5d..d4272d41 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -33,7 +33,9 @@ CONF_ADD_DEVICE, CONF_DPS_STRINGS, CONF_EDIT_DEVICE, + CONF_ENABLE_DEBUG, CONF_LOCAL_KEY, + CONF_MANUAL_DPS, CONF_MODEL, CONF_NO_CLOUD, CONF_PRODUCT_NAME, @@ -45,7 +47,6 @@ DATA_DISCOVERY, DOMAIN, PLATFORMS, - CONF_MANUAL_DPS, ) from .discovery import discover @@ -82,26 +83,17 @@ } ) -CONFIGURE_DEVICE_SCHEMA = vol.Schema( - { - vol.Required(CONF_FRIENDLY_NAME): str, - vol.Required(CONF_LOCAL_KEY): str, - vol.Required(CONF_HOST): str, - vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), - vol.Optional(CONF_SCAN_INTERVAL): int, - vol.Optional(CONF_MANUAL_DPS): str, - vol.Optional(CONF_RESET_DPIDS): str, - } -) DEVICE_SCHEMA = vol.Schema( { + vol.Required(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(CONF_LOCAL_KEY): cv.string, - vol.Required(CONF_FRIENDLY_NAME): cv.string, - vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), + vol.Required(CONF_ENABLE_DEBUG, default=False): bool, vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_RESET_DPIDS): str, @@ -141,13 +133,16 @@ def options_schema(entities): ] return vol.Schema( { - vol.Required(CONF_FRIENDLY_NAME): str, - vol.Required(CONF_HOST): str, - vol.Required(CONF_LOCAL_KEY): str, - vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Required(CONF_FRIENDLY_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_LOCAL_KEY): cv.string, + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), + vol.Required(CONF_ENABLE_DEBUG, default=False): bool, vol.Optional(CONF_SCAN_INTERVAL): int, - vol.Optional(CONF_MANUAL_DPS): str, - vol.Optional(CONF_RESET_DPIDS): str, + vol.Optional(CONF_MANUAL_DPS): cv.string, + vol.Optional(CONF_RESET_DPIDS): cv.string, vol.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), @@ -247,6 +242,7 @@ async def validate_input(hass: core.HomeAssistant, data): data[CONF_DEVICE_ID], data[CONF_LOCAL_KEY], float(data[CONF_PROTOCOL_VERSION]), + data[CONF_ENABLE_DEBUG], ) if CONF_RESET_DPIDS in data: reset_ids_str = data[CONF_RESET_DPIDS].split(",") @@ -260,20 +256,21 @@ async def validate_input(hass: core.HomeAssistant, data): ) try: detected_dps = await interface.detect_available_dps() - except Exception: # pylint: disable=broad-except + except Exception as ex: try: - _LOGGER.debug("Initial state update failed, trying reset command") + _LOGGER.debug( + "Initial state update failed (%s), trying reset command", ex + ) if len(reset_ids) > 0: await interface.reset(reset_ids) detected_dps = await interface.detect_available_dps() - except Exception: # pylint: disable=broad-except - _LOGGER.debug("No DPS able to be detected") + except Exception as ex: + _LOGGER.debug("No DPS able to be detected: %s", ex) detected_dps = {} # if manual DPs are set, merge these. _LOGGER.debug("Detected DPS: %s", detected_dps) if CONF_MANUAL_DPS in data: - manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")] _LOGGER.debug( "Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list @@ -497,8 +494,8 @@ async def async_step_add_device(self, user_input=None): errors["base"] = "address_in_use" else: errors["base"] = "discovery_failed" - except Exception: # pylint: disable= broad-except - _LOGGER.exception("discovery failed") + except Exception as ex: + _LOGGER.exception("discovery failed: %s", ex) errors["base"] = "discovery_failed" devices = { @@ -564,6 +561,11 @@ async def async_step_configure_device(self, user_input=None): CONF_ENTITIES: [], } ) + if len(user_input[CONF_ENTITIES]) == 0: + return self.async_abort( + reason="no_entities", + description_placeholders={}, + ) if user_input[CONF_ENTITIES]: entity_ids = [ int(entity.split(":")[0]) @@ -585,16 +587,28 @@ async def async_step_configure_device(self, user_input=None): errors["base"] = "invalid_auth" except EmptyDpsList: errors["base"] = "empty_dps" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") + except Exception as ex: + _LOGGER.exception("Unexpected exception: %s", ex) errors["base"] = "unknown" defaults = {} if self.editing_device: # If selected device exists as a config entry, load config from it defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy() - schema = schema_defaults(options_schema(self.entities), **defaults) + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list placeholders = {"for_device": f" for device `{dev_id}`"} + if dev_id in cloud_devs: + cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + if defaults[CONF_LOCAL_KEY] != cloud_local_key: + _LOGGER.info( + "New local_key detected: new %s vs old %s", + cloud_local_key, + defaults[CONF_LOCAL_KEY], + ) + defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + note = "\nNOTE: a new local_key has been retrieved using cloud API" + placeholders = {"for_device": f" for device `{dev_id}`.{note}"} + schema = schema_defaults(options_schema(self.entities), **defaults) else: defaults[CONF_PROTOCOL_VERSION] = "3.3" defaults[CONF_HOST] = "" @@ -611,7 +625,7 @@ async def async_step_configure_device(self, user_input=None): if dev_id in cloud_devs: defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME) - schema = schema_defaults(CONFIGURE_DEVICE_SCHEMA, **defaults) + schema = schema_defaults(DEVICE_SCHEMA, **defaults) placeholders = {"for_device": ""} diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 8010d18c..3a6c2529 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -28,6 +28,7 @@ # config flow CONF_LOCAL_KEY = "local_key" +CONF_ENABLE_DEBUG = "enable_debug" CONF_PROTOCOL_VERSION = "protocol_version" CONF_DPS_STRINGS = "dps_strings" CONF_MODEL = "model" diff --git a/custom_components/localtuya/cover.py b/custom_components/localtuya/cover.py index 3b6b86de..b45669ca 100644 --- a/custom_components/localtuya/cover.py +++ b/custom_components/localtuya/cover.py @@ -108,13 +108,13 @@ def is_closing(self): def is_closed(self): """Return if the cover is closed or not.""" if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: - return None + return False if self._current_cover_position == 0: return True if self._current_cover_position == 100: return False - return None + return False async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 584ea84c..32c32899 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -27,12 +27,12 @@ CONF_FAN_DIRECTION, CONF_FAN_DIRECTION_FWD, CONF_FAN_DIRECTION_REV, + CONF_FAN_DPS_TYPE, CONF_FAN_ORDERED_LIST, CONF_FAN_OSCILLATING_CONTROL, CONF_FAN_SPEED_CONTROL, CONF_FAN_SPEED_MAX, CONF_FAN_SPEED_MIN, - CONF_FAN_DPS_TYPE, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index 10b1b5a3..3f1e00bd 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -1,7 +1,7 @@ { "domain": "localtuya", "name": "LocalTuya integration", - "version": "4.1.1", + "version": "5.0.0", "documentation": "https://github.com/rospogrigio/localtuya/", "dependencies": [], "codeowners": [ diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index 23d7ea9a..917d3d00 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -7,14 +7,13 @@ from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from .common import LocalTuyaEntity, async_setup_entry - from .const import ( - CONF_MIN_VALUE, - CONF_MAX_VALUE, CONF_DEFAULT_VALUE, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_PASSIVE_ENTITY, CONF_RESTORE_ON_RECONNECT, CONF_STEPSIZE_VALUE, - CONF_PASSIVE_ENTITY, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index d36cd5e8..746cc547 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -3,11 +3,8 @@ """ Python module to interface with Tuya WiFi smart devices. -Mostly derived from Shenzhen Xenon ESP8266MOD WiFi smart devices -E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U - -Author: clach04 -Maintained by: postlund +Author: clach04, postlund +Maintained by: rospogrigio For more information see https://github.com/clach04/python-tuya @@ -19,7 +16,7 @@ Functions json = status() # returns json payload - set_version(version) # 3.1 [default] or 3.3 + set_version(version) # 3.1 [default], 3.2, 3.3 or 3.4 detect_available_dps() # returns a list of available dps provided by the device update_dps(dps) # sends update dps command add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the @@ -27,18 +24,21 @@ set_dp(on, dp_index) # Set value of any dps index. -Credits - * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes - For protocol reverse engineering - * PyTuya https://github.com/clach04/python-tuya by clach04 - The origin of this python module (now abandoned) - * LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio - Updated pytuya to support devices with Device IDs of 22 characters + Credits + * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes + For protocol reverse engineering + * PyTuya https://github.com/clach04/python-tuya by clach04 + The origin of this python module (now abandoned) + * Tuya Protocol 3.4 Support by uzlonewolf + Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support + * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox + Several CLI tools and code for Tuya devices """ import asyncio import base64 import binascii +import hmac import json import logging import struct @@ -46,73 +46,174 @@ import weakref from abc import ABC, abstractmethod from collections import namedtuple -from hashlib import md5 +from hashlib import md5, sha256 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -version_tuple = (9, 0, 0) +version_tuple = (10, 0, 0) version = version_string = __version__ = "%d.%d.%d" % version_tuple -__author__ = "postlund" +__author__ = "rospogrigio" _LOGGER = logging.getLogger(__name__) -TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") +# Tuya Packet Format +TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length") +MessagePayload = namedtuple("MessagePayload", "cmd payload") +try: + TuyaMessage = namedtuple( + "TuyaMessage", "seqno cmd retcode payload crc crc_good", defaults=(True,) + ) +except Exception: + TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good") + +# TinyTuya Error Response Codes +ERR_JSON = 900 +ERR_CONNECT = 901 +ERR_TIMEOUT = 902 +ERR_RANGE = 903 +ERR_PAYLOAD = 904 +ERR_OFFLINE = 905 +ERR_STATE = 906 +ERR_FUNCTION = 907 +ERR_DEVTYPE = 908 +ERR_CLOUDKEY = 909 +ERR_CLOUDRESP = 910 +ERR_CLOUDTOKEN = 911 +ERR_PARAMS = 912 +ERR_CLOUD = 913 + +error_codes = { + ERR_JSON: "Invalid JSON Response from Device", + ERR_CONNECT: "Network Error: Unable to Connect", + ERR_TIMEOUT: "Timeout Waiting for Device", + ERR_RANGE: "Specified Value Out of Range", + ERR_PAYLOAD: "Unexpected Payload from Device", + ERR_OFFLINE: "Network Error: Device Unreachable", + ERR_STATE: "Device in Unknown State", + ERR_FUNCTION: "Function Not Supported by Device", + ERR_DEVTYPE: "Device22 Detected: Retry Command", + ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", + ERR_CLOUDRESP: "Invalid JSON Response from Cloud", + ERR_CLOUDTOKEN: "Unable to Get Cloud Token", + ERR_PARAMS: "Missing Function Parameters", + ERR_CLOUD: "Error Response from Tuya Cloud", + None: "Unknown Error", +} + + +class DecodeError(Exception): + """Specific Exception caused by decoding error.""" + + pass + + +# Tuya Command Types +# Reference: +# https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h +AP_CONFIG = 0x01 # FRM_TP_CFG_WF # only used for ap 3.0 network config +ACTIVE = 0x02 # FRM_TP_ACTV (discard) # WORK_MODE_CMD +SESS_KEY_NEG_START = 0x03 # FRM_SECURITY_TYPE3 # negotiate session key +SESS_KEY_NEG_RESP = 0x04 # FRM_SECURITY_TYPE4 # negotiate session key response +SESS_KEY_NEG_FINISH = 0x05 # FRM_SECURITY_TYPE5 # finalize session key negotiation +UNBIND = 0x06 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command +CONTROL = 0x07 # FRM_TP_CMD # STATE_UPLOAD_CMD +STATUS = 0x08 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD +HEART_BEAT = 0x09 # FRM_TP_HB +DP_QUERY = 0x0A # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points +QUERY_WIFI = 0x0B # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD +TOKEN_BIND = 0x0C # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) +CONTROL_NEW = 0x0D # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD +ENABLE_WIFI = 0x0E # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD +WIFI_INFO = 0x0F # 15 # FRM_CFG_WIFI_INFO +DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW +SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC +UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS +UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION +AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40 +BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34 +LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM -SET = "set" -STATUS = "status" -HEARTBEAT = "heartbeat" -RESET = "reset" -UPDATEDPS = "updatedps" # Request refresh of DPS PROTOCOL_VERSION_BYTES_31 = b"3.1" PROTOCOL_VERSION_BYTES_33 = b"3.3" +PROTOCOL_VERSION_BYTES_34 = b"3.4" -PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + 12 * b"\x00" - -MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length +PROTOCOL_3x_HEADER = 12 * b"\x00" +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER +PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER +MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode] MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode +MESSAGE_RETCODE_FMT = ">I" # retcode for received messages MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix - +MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix PREFIX_VALUE = 0x000055AA +PREFIX_BIN = b"\x00\x00U\xaa" SUFFIX_VALUE = 0x0000AA55 +SUFFIX_BIN = b"\x00\x00\xaaU" +NO_PROTOCOL_HEADER_CMDS = [ + DP_QUERY, + DP_QUERY_NEW, + UPDATEDPS, + HEART_BEAT, + SESS_KEY_NEG_START, + SESS_KEY_NEG_RESP, + SESS_KEY_NEG_FINISH, +] HEARTBEAT_INTERVAL = 10 # DPS that are known to be safe to use with update_dps (0x12) command UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) +# Tuya Device Dictionary - Command and Payload Overrides # This is intended to match requests.json payload at # https://github.com/codetheweb/tuyapi : -# type_0a devices require the 0a command as the status request -# type_0d devices require the 0d command as the status request, and the list of -# dps used set to null in the request payload (see generate_payload method) - +# 'type_0a' devices require the 0a command for the DP_QUERY request +# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of +# dps used set to Null in the request payload # prefix: # Next byte is command byte ("hexByte") some zero padding, then length # of remaining payload, i.e. command + suffix (unclear if multiple bytes used for # length, zero padding implies could be more than one byte) -PAYLOAD_DICT = { + +# Any command not defined in payload_dict will be sent as-is with a +# payload of {"gwId": "", "devId": "", "uid": "", "t": ""} + +payload_dict = { + # Default Device "type_0a": { - STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}}, - SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, - HEARTBEAT: {"hexByte": 0x09, "command": {}}, - UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, - RESET: { - "hexByte": 0x12, - "command": { - "gwId": "", - "devId": "", - "uid": "", - "t": "", - "dpId": [18, 19, 20], - }, + AP_CONFIG: { # [BETA] Set Control Values on Device + "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, }, + CONTROL: { # Set Control Values on Device + "command": {"devId": "", "uid": "", "t": ""}, + }, + STATUS: { # Get Status from Device + "command": {"gwId": "", "devId": ""}, + }, + HEART_BEAT: {"command": {"gwId": "", "devId": ""}}, + DP_QUERY: { # Get Data Points from Device + "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, + }, + CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, + DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, + UPDATEDPS: {"command": {"dpId": [18, 19, 20]}}, }, + # Special Case Device "0d" - Some of these devices + # Require the 0d command as the DP_QUERY status request and the list of + # dps requested payload "type_0d": { - STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, - SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, - HEARTBEAT: {"hexByte": 0x09, "command": {}}, - UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, + DP_QUERY: { # Get Data Points from Device + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": ""}, + }, + }, + "v3.4": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": ""}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, }, } @@ -132,13 +233,17 @@ class ContextualLogger: def __init__(self): """Initialize a new ContextualLogger.""" self._logger = None + self._enable_debug = False - def set_logger(self, logger, device_id): + def set_logger(self, logger, device_id, enable_debug=False): """Set base logger to use.""" + self._enable_debug = enable_debug self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id}) def debug(self, msg, *args): """Debug level log.""" + if not self._enable_debug: + return return self._logger.log(logging.DEBUG, msg, *args) def info(self, msg, *args): @@ -158,8 +263,9 @@ def exception(self, msg, *args): return self._logger.exception(msg, *args) -def pack_message(msg): +def pack_message(msg, hmac_key=None): """Pack a TuyaMessage into bytes.""" + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT # Create full message excluding CRC and suffix buffer = ( struct.pack( @@ -167,28 +273,106 @@ def pack_message(msg): PREFIX_VALUE, msg.seqno, msg.cmd, - len(msg.payload) + struct.calcsize(MESSAGE_END_FMT), + len(msg.payload) + struct.calcsize(end_fmt), ) + msg.payload ) - + if hmac_key: + crc = hmac.new(hmac_key, buffer, sha256).digest() + else: + crc = binascii.crc32(buffer) & 0xFFFFFFFF # Calculate CRC, add it together with suffix - buffer += struct.pack(MESSAGE_END_FMT, binascii.crc32(buffer), SUFFIX_VALUE) - + buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE) return buffer -def unpack_message(data): +def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=None): """Unpack bytes into a TuyaMessage.""" - header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) - end_len = struct.calcsize(MESSAGE_END_FMT) + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT + # 4-word header plus return code + header_len = struct.calcsize(MESSAGE_HEADER_FMT) + retcode_len = 0 if no_retcode else struct.calcsize(MESSAGE_RETCODE_FMT) + end_len = struct.calcsize(end_fmt) + headret_len = header_len + retcode_len + + if len(data) < headret_len + end_len: + logger.debug( + "unpack_message(): not enough data to unpack header! need %d but only have %d", + headret_len + end_len, + len(data), + ) + raise DecodeError("Not enough data to unpack header") + + if header is None: + header = parse_header(data) - _, seqno, cmd, _, retcode = struct.unpack( - MESSAGE_RECV_HEADER_FMT, data[:header_len] + if len(data) < header_len + header.length: + logger.debug( + "unpack_message(): not enough data to unpack payload! need %d but only have %d", + header_len + header.length, + len(data), + ) + raise DecodeError("Not enough data to unpack payload") + + retcode = ( + 0 + if no_retcode + else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] + ) + # the retcode is technically part of the payload, but strip it as we do not want it here + payload = data[header_len + retcode_len : header_len + header.length] + crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) + + if hmac_key: + have_crc = hmac.new( + hmac_key, data[: (header_len + header.length) - end_len], sha256 + ).digest() + else: + have_crc = ( + binascii.crc32(data[: (header_len + header.length) - end_len]) & 0xFFFFFFFF + ) + + if suffix != SUFFIX_VALUE: + logger.debug("Suffix prefix wrong! %08X != %08X", suffix, SUFFIX_VALUE) + + if crc != have_crc: + if hmac_key: + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) + else: + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_crc ) - payload = data[header_len:-end_len] - crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:]) - return TuyaMessage(seqno, cmd, retcode, payload, crc) + + +def parse_header(data): + """Unpack bytes into a TuyaHeader.""" + header_len = struct.calcsize(MESSAGE_HEADER_FMT) + + if len(data) < header_len: + raise DecodeError("Not enough data to unpack header") + + prefix, seqno, cmd, payload_len = struct.unpack( + MESSAGE_HEADER_FMT, data[:header_len] + ) + + if prefix != PREFIX_VALUE: + # self.debug('Header prefix wrong! %08X != %08X', prefix, PREFIX_VALUE) + raise DecodeError("Header prefix wrong! %08X != %08X" % (prefix, PREFIX_VALUE)) + + # sanity check. currently the max payload length is somewhere around 300 bytes + if payload_len > 1000: + raise DecodeError( + "Header claims the packet size is over 1000 bytes! It is most likely corrupt. Claimed size: %d bytes" + % payload_len + ) + + return TuyaHeader(prefix, seqno, cmd, payload_len) class AESCipher: @@ -199,19 +383,22 @@ def __init__(self, key): self.block_size = 16 self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) - def encrypt(self, raw, use_base64=True): + def encrypt(self, raw, use_base64=True, pad=True): """Encrypt data to be sent to device.""" encryptor = self.cipher.encryptor() - crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize() + if pad: + raw = self._pad(raw) + crypted_text = encryptor.update(raw) + encryptor.finalize() return base64.b64encode(crypted_text) if use_base64 else crypted_text - def decrypt(self, enc, use_base64=True): + def decrypt(self, enc, use_base64=True, decode_text=True): """Decrypt data from device.""" if use_base64: enc = base64.b64decode(enc) decryptor = self.cipher.decryptor() - return self._unpad(decryptor.update(enc) + decryptor.finalize()).decode() + raw = self._unpad(decryptor.update(enc) + decryptor.finalize()) + return raw.decode("utf-8") if decode_text else raw def _pad(self, data): padnum = self.block_size - len(data) % self.block_size @@ -225,18 +412,22 @@ def _unpad(data): class MessageDispatcher(ContextualLogger): """Buffer and dispatcher for Tuya messages.""" - # Heartbeats always respond with sequence number 0, so they can't be waited for like - # other messages. This is a hack to allow waiting for heartbeats. + # Heartbeats on protocols < 3.3 respond with sequence number 0, + # so they can't be waited for like other messages. + # This is a hack to allow waiting for heartbeats. HEARTBEAT_SEQNO = -100 RESET_SEQNO = -101 + SESS_KEY_SEQNO = -102 - def __init__(self, dev_id, listener): + def __init__(self, dev_id, listener, protocol_version, local_key, enable_debug): """Initialize a new MessageBuffer.""" super().__init__() self.buffer = b"" self.listeners = {} self.listener = listener - self.set_logger(_LOGGER, dev_id) + self.version = protocol_version + self.local_key = local_key + self.set_logger(_LOGGER, dev_id, enable_debug) def abort(self): """Abort all waiting clients.""" @@ -248,16 +439,19 @@ def abort(self): if isinstance(sem, asyncio.Semaphore): sem.release() - async def wait_for(self, seqno, timeout=5): + async def wait_for(self, seqno, cmd, timeout=5): """Wait for response to a sequence number to be received and return it.""" if seqno in self.listeners: raise Exception(f"listener exists for {seqno}") - self.debug("Waiting for sequence number %d", seqno) + self.debug("Command %d waiting for seq. number %d", cmd, seqno) self.listeners[seqno] = asyncio.Semaphore(0) try: await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) except asyncio.TimeoutError: + self.warning( + "Command %d timed out waiting for sequence number %d", cmd, seqno + ) del self.listeners[seqno] raise @@ -273,51 +467,44 @@ def add_data(self, data): if len(self.buffer) < header_len: break - # Parse header and check if enough data according to length in header - _, seqno, cmd, length, retcode = struct.unpack_from( - MESSAGE_RECV_HEADER_FMT, self.buffer - ) - if len(self.buffer[header_len - 4 :]) < length: - break - - # length includes payload length, retcode, crc and suffix - if (retcode & 0xFFFFFF00) != 0: - payload_start = header_len - 4 - payload_length = length - struct.calcsize(MESSAGE_END_FMT) - else: - payload_start = header_len - payload_length = length - 4 - struct.calcsize(MESSAGE_END_FMT) - payload = self.buffer[payload_start : payload_start + payload_length] - - crc, _ = struct.unpack_from( - MESSAGE_END_FMT, - self.buffer[payload_start + payload_length : payload_start + length], + header = parse_header(self.buffer) + hmac_key = self.local_key if self.version == 3.4 else None + msg = unpack_message( + self.buffer, header=header, hmac_key=hmac_key, logger=self ) - - self.buffer = self.buffer[header_len - 4 + length :] - self._dispatch(TuyaMessage(seqno, cmd, retcode, payload, crc)) + self.buffer = self.buffer[header_len - 4 + header.length :] + self._dispatch(msg) def _dispatch(self, msg): """Dispatch a message to someone that is listening.""" - self.debug("Dispatching message %s", msg) + self.debug("Dispatching message CMD %r %s", msg.cmd, msg) if msg.seqno in self.listeners: - self.debug("Dispatching sequence number %d", msg.seqno) + # self.debug("Dispatching sequence number %d", msg.seqno) sem = self.listeners[msg.seqno] - self.listeners[msg.seqno] = msg - sem.release() - elif msg.cmd == 0x09: + if isinstance(sem, asyncio.Semaphore): + self.listeners[msg.seqno] = msg + sem.release() + else: + self.debug("Got additional message without request - skipping: %s", sem) + elif msg.cmd == HEART_BEAT: self.debug("Got heartbeat response") if self.HEARTBEAT_SEQNO in self.listeners: sem = self.listeners[self.HEARTBEAT_SEQNO] self.listeners[self.HEARTBEAT_SEQNO] = msg sem.release() - elif msg.cmd == 0x12: + elif msg.cmd == UPDATEDPS: self.debug("Got normal updatedps response") if self.RESET_SEQNO in self.listeners: sem = self.listeners[self.RESET_SEQNO] self.listeners[self.RESET_SEQNO] = msg sem.release() - elif msg.cmd == 0x08: + elif msg.cmd == SESS_KEY_NEG_RESP: + self.debug("Got key negotiation response") + if self.SESS_KEY_SEQNO in self.listeners: + sem = self.listeners[self.SESS_KEY_SEQNO] + self.listeners[self.SESS_KEY_SEQNO] = msg + sem.release() + elif msg.cmd == STATUS: if self.RESET_SEQNO in self.listeners: self.debug("Got reset status update") sem = self.listeners[self.RESET_SEQNO] @@ -327,12 +514,15 @@ def _dispatch(self, msg): self.debug("Got status update") self.listener(msg) else: - self.debug( - "Got message type %d for unknown listener %d: %s", - msg.cmd, - msg.seqno, - msg, - ) + if msg.cmd == CONTROL_NEW: + self.debug("Got ACK message for command %d: will ignore it", msg.cmd) + else: + self.debug( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) class TuyaListener(ABC): @@ -360,7 +550,9 @@ def disconnected(self): class TuyaProtocol(asyncio.Protocol, ContextualLogger): """Implementation of the Tuya protocol.""" - def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): + def __init__( + self, dev_id, local_key, protocol_version, enable_debug, on_connected, listener + ): """ Initialize a new TuyaInterface. @@ -374,23 +566,59 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): """ super().__init__() self.loop = asyncio.get_running_loop() - self.set_logger(_LOGGER, dev_id) + self.set_logger(_LOGGER, dev_id, enable_debug) self.id = dev_id self.local_key = local_key.encode("latin1") - self.version = protocol_version + self.real_local_key = self.local_key self.dev_type = "type_0a" self.dps_to_request = {} + + if protocol_version: + self.set_version(float(protocol_version)) + else: + # make sure we call our set_version() and not a subclass since some of + # them (such as BulbDevice) make connections when called + TuyaProtocol.set_version(self, 3.1) + self.cipher = AESCipher(self.local_key) - self.seqno = 0 + self.seqno = 1 self.transport = None self.listener = weakref.ref(listener) - self.dispatcher = self._setup_dispatcher() + self.dispatcher = self._setup_dispatcher(enable_debug) self.on_connected = on_connected self.heartbeater = None self.dps_cache = {} + self.local_nonce = b"0123456789abcdef" # not-so-random random key + self.remote_nonce = b"" + + def set_version(self, protocol_version): + """Set the device version and eventually start available DPs detection.""" + self.version = protocol_version + self.version_bytes = str(protocol_version).encode("latin1") + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + elif protocol_version == 3.4: + self.dev_type = "v3.4" + + def error_json(self, number=None, payload=None): + """Return error details in JSON.""" + try: + spayload = json.dumps(payload) + # spayload = payload.replace('\"','').replace('\'','') + except Exception: + spayload = '""' + + vals = (error_codes[number], str(number), spayload) + self.debug("ERROR %s - %s - payload: %s", *vals) - def _setup_dispatcher(self): + return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) + + def _setup_dispatcher(self, enable_debug): def _status_update(msg): + if msg.seqno > 0: + self.seqno = msg.seqno + 1 decoded_message = self._decode_payload(msg.payload) if "dps" in decoded_message: self.dps_cache.update(decoded_message["dps"]) @@ -399,7 +627,9 @@ def _status_update(msg): if listener is not None: listener.status_updated(self.dps_cache) - return MessageDispatcher(self.id, _status_update) + return MessageDispatcher( + self.id, _status_update, self.version, self.local_key, enable_debug + ) def connection_made(self, transport): """Did connect to the device.""" @@ -434,11 +664,13 @@ async def heartbeat_loop(): def data_received(self, data): """Received data from device.""" + # self.debug("received data=%r", binascii.hexlify(data)) self.dispatcher.add_data(data) def connection_lost(self, exc): """Disconnected from device.""" self.debug("Connection lost: %s", exc) + self.real_local_key = self.local_key try: listener = self.listener and self.listener() if listener is not None: @@ -449,6 +681,7 @@ def connection_lost(self, exc): async def close(self): """Close connection and abort all outstanding listeners.""" self.debug("Closing connection") + self.real_local_key = self.local_key if self.heartbeater is not None: self.heartbeater.cancel() try: @@ -464,31 +697,86 @@ async def close(self): self.transport = None transport.close() + async def exchange_quick(self, payload, recv_retries): + """Similar to exchange() but never retries sending and does not decode the response.""" + if not self.transport: + self.debug( + "[" + self.id + "] send quick failed, could not get socket: %s", payload + ) + return None + enc_payload = ( + self._encode_message(payload) + if isinstance(payload, MessagePayload) + else payload + ) + # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno) + + try: + self.transport.write(enc_payload) + except Exception: + # self._check_socket_close(True) + self.close() + return None + while recv_retries: + try: + seqno = MessageDispatcher.SESS_KEY_SEQNO + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message + self.seqno = msg.seqno + except Exception: + msg = None + if msg and len(msg.payload) != 0: + return msg + recv_retries -= 1 + if recv_retries == 0: + self.debug( + "received null payload (%r) but out of recv retries, giving up", msg + ) + else: + self.debug( + "received null payload (%r), fetch new one - %s retries remaining", + msg, + recv_retries, + ) + return None + async def exchange(self, command, dps=None): """Send and receive a message, returning response from device.""" + if self.version == 3.4 and self.real_local_key == self.local_key: + self.debug("3.4 device: negotiating a new session key") + await self._negotiate_session_key() + self.debug( "Sending command %s (device type: %s)", command, self.dev_type, ) payload = self._generate_payload(command, dps) + real_cmd = payload.cmd dev_type = self.dev_type + # self.debug("Exchange: payload %r %r", payload.cmd, payload.payload) # Wait for special sequence number if heartbeat or reset - seqno = self.seqno - 1 + seqno = self.seqno - if command == HEARTBEAT: + if payload.cmd == HEART_BEAT: seqno = MessageDispatcher.HEARTBEAT_SEQNO - elif command == RESET: + elif payload.cmd == UPDATEDPS: seqno = MessageDispatcher.RESET_SEQNO - self.transport.write(payload) - msg = await self.dispatcher.wait_for(seqno) + enc_payload = self._encode_message(payload) + self.transport.write(enc_payload) + msg = await self.dispatcher.wait_for(seqno, payload.cmd) if msg is None: self.debug("Wait was aborted for seqno %d", seqno) return None # TODO: Verify stuff, e.g. CRC sequence number? + if real_cmd in [HEART_BEAT, CONTROL, CONTROL_NEW] and len(msg.payload) == 0: + # device may send messages with empty payload in response + # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK + self.debug("ACK received for command %d: ignoring it", real_cmd) + return None payload = self._decode_payload(msg.payload) # Perform a new exchange (once) if we switched device type @@ -504,21 +792,21 @@ async def exchange(self, command, dps=None): async def status(self): """Return device status.""" - status = await self.exchange(STATUS) + status = await self.exchange(DP_QUERY) if status and "dps" in status: self.dps_cache.update(status["dps"]) return self.dps_cache async def heartbeat(self): """Send a heartbeat message.""" - return await self.exchange(HEARTBEAT) + return await self.exchange(HEART_BEAT) async def reset(self, dpIds=None): """Send a reset message (3.3 only).""" if self.version == 3.3: self.dev_type = "type_0a" self.debug("reset switching to dev_type %s", self.dev_type) - return await self.exchange(RESET, dpIds) + return await self.exchange(UPDATEDPS, dpIds) return True @@ -529,7 +817,7 @@ async def update_dps(self, dps=None): Args: dps([int]): list of dps to update, default=detected&whitelisted """ - if self.version == 3.3: + if self.version in [3.2, 3.3]: # 3.2 behaves like 3.3 with type_0d if dps is None: if not self.dps_cache: await self.detect_available_dps() @@ -539,7 +827,8 @@ async def update_dps(self, dps=None): dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) payload = self._generate_payload(UPDATEDPS, dps) - self.transport.write(payload) + enc_payload = self._encode_message(payload) + self.transport.write(enc_payload) return True async def set_dp(self, value, dp_index): @@ -550,11 +839,11 @@ async def set_dp(self, value, dp_index): dp_index(int): dps index to set value: new value for the dps index """ - return await self.exchange(SET, {str(dp_index): value}) + return await self.exchange(CONTROL, {str(dp_index): value}) async def set_dps(self, dps): """Set values for a set of datapoints.""" - return await self.exchange(SET, dps) + return await self.exchange(CONTROL, dps) async def detect_available_dps(self): """Return which datapoints are supported by the device.""" @@ -591,38 +880,193 @@ def add_dps_to_request(self, dp_indicies): self.dps_to_request.update({str(index): None for index in dp_indicies}) def _decode_payload(self, payload): - if not payload: - payload = "{}" - elif payload.startswith(b"{"): - pass - elif payload.startswith(PROTOCOL_VERSION_BYTES_31): - payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header - # remove (what I'm guessing, but not confirmed is) 16-bytes of MD5 - # hexdigest of payload - payload = self.cipher.decrypt(payload[16:]) - elif self.version == 3.3: - if self.dev_type != "type_0a" or payload.startswith( - PROTOCOL_VERSION_BYTES_33 - ): - payload = payload[len(PROTOCOL_33_HEADER) :] - payload = self.cipher.decrypt(payload, False) + cipher = AESCipher(self.local_key) + + if self.version == 3.4: + # 3.4 devices encrypt the version header in addition to the payload + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception: + self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + + if payload.startswith(PROTOCOL_VERSION_BYTES_31): + # Received an encrypted payload + # Remove version header + payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] + # Decrypt payload + # Remove 16-bytes of MD5 hexdigest of payload + payload = cipher.decrypt(payload[16:]) + elif self.version >= 3.2: # 3.2 or 3.3 or 3.4 + # Trim header for non-default device type + if payload.startswith(self.version_bytes): + payload = payload[len(self.version_header) :] + # self.debug("removing 3.x=%r", payload) + elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0: + payload = payload[len(self.version_header) :] + # self.debug("removing type_0d 3.x header=%r", payload) + + if self.version != 3.4: + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False) + except Exception: + self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + # Try to detect if type_0d found + + if not isinstance(payload, str): + try: + payload = payload.decode() + except Exception as ex: + self.debug("payload was not string type and decoding failed") + raise DecodeError("payload was not a string: %s" % ex) + # return self.error_json(ERR_JSON, payload) if "data unvalid" in payload: self.dev_type = "type_0d" self.debug( - "switching to dev_type %s", + "'data unvalid' error detected: switching to dev_type %r", self.dev_type, ) return None - else: - raise Exception(f"Unexpected payload={payload}") + elif not payload.startswith(b"{"): + self.debug("Unexpected payload=%r", payload) + return self.error_json(ERR_PAYLOAD, payload) if not isinstance(payload, str): payload = payload.decode() - self.debug("Decrypted payload: %s", payload) - return json.loads(payload) + self.debug("Deciphered data = %r", payload) + try: + json_payload = json.loads(payload) + except Exception: + raise DecodeError("could not decrypt data: wrong local_key?") + # json_payload = self.error_json(ERR_JSON, payload) + + # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} + if ( + "dps" not in json_payload + and "data" in json_payload + and "dps" in json_payload["data"] + ): + json_payload["dps"] = json_payload["data"]["dps"] + + return json_payload + + async def _negotiate_session_key(self): + self.local_key = self.real_local_key + + rkey = await self.exchange_quick( + MessagePayload(SESS_KEY_NEG_START, self.local_nonce), 2 + ) + if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48: + # error + self.debug("session key negotiation failed on step 1") + return False + + if rkey.cmd != SESS_KEY_NEG_RESP: + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) + return False + + payload = rkey.payload + try: + # self.debug("decrypting %r using %r", payload, self.real_local_key) + cipher = AESCipher(self.real_local_key) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception: + self.debug( + "session key step 2 decrypt failed, payload=%r (len:%d)", + payload, + len(payload), + ) + return False + + self.debug("decrypted session key negotiation step 2: payload=%r", payload) + + if len(payload) < 48: + self.debug("session key negotiation step 2 failed, too short response") + return False + + self.remote_nonce = payload[:16] + hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest() + + if hmac_check != payload[16:48]: + self.debug( + "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r", + binascii.hexlify(hmac_check), + binascii.hexlify(payload[16:48]), + ) + + # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce) + rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest() + await self.exchange_quick(MessagePayload(SESS_KEY_NEG_FINISH, rkey_hmac), None) + + self.local_key = bytes( + [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)] + ) + # self.debug("Session nonce XOR'd: %r" % self.local_key) + + cipher = AESCipher(self.real_local_key) + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, False, pad=False + ) + self.debug("Session key negotiate success! session key: %r", self.local_key) + return True + + # adds protocol header (if needed) and encrypts + def _encode_message(self, msg): + hmac_key = None + payload = msg.payload + self.cipher = AESCipher(self.local_key) + if self.version == 3.4: + hmac_key = self.local_key + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + self.debug("final payload for cmd %r: %r", msg.cmd, payload) + payload = self.cipher.encrypt(payload, False) + elif self.version >= 3.2: + # expect to connect and then disconnect to set new + payload = self.cipher.encrypt(payload, False) + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + elif msg.cmd == CONTROL: + # need to encrypt + payload = self.cipher.encrypt(payload) + preMd5String = ( + b"data=" + + payload + + b"||lpv=" + + PROTOCOL_VERSION_BYTES_31 + + b"||" + + self.local_key + ) + m = md5() + m.update(preMd5String) + hexdigest = m.hexdigest() + # some tuya libraries strip 8: to :24 + payload = ( + PROTOCOL_VERSION_BYTES_31 + + hexdigest[8:][:16].encode("latin1") + + payload + ) - def _generate_payload(self, command, data=None): + self.cipher = None + msg = TuyaMessage(self.seqno, msg.cmd, 0, payload, 0, True) + self.seqno += 1 # increase message sequence number + buffer = pack_message(msg, hmac_key=hmac_key) + # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) + return buffer + + def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None): """ Generate the payload to send. @@ -631,58 +1075,81 @@ def _generate_payload(self, command, data=None): This is one of the entries from payload_dict data(dict, optional): The data to be send. This is what will be passed via the 'dps' entry + gwId(str, optional): Will be used for gwId + devId(str, optional): Will be used for devId + uid(str, optional): Will be used for uid """ - cmd_data = PAYLOAD_DICT[self.dev_type][command] - json_data = cmd_data["command"] - command_hb = cmd_data["hexByte"] + json_data = command_override = None + + if command in payload_dict[self.dev_type]: + if "command" in payload_dict[self.dev_type][command]: + json_data = payload_dict[self.dev_type][command]["command"] + if "command_override" in payload_dict[self.dev_type][command]: + command_override = payload_dict[self.dev_type][command][ + "command_override" + ] + + if self.dev_type != "type_0a": + if ( + json_data is None + and command in payload_dict["type_0a"] + and "command" in payload_dict["type_0a"][command] + ): + json_data = payload_dict["type_0a"][command]["command"] + if ( + command_override is None + and command in payload_dict["type_0a"] + and "command_override" in payload_dict["type_0a"][command] + ): + command_override = payload_dict["type_0a"][command]["command_override"] + + if command_override is None: + command_override = command + if json_data is None: + # I have yet to see a device complain about included but unneeded attribs, but they *will* + # complain about missing attribs, so just include them all unless otherwise specified + json_data = {"gwId": "", "devId": "", "uid": "", "t": ""} if "gwId" in json_data: - json_data["gwId"] = self.id + if gwId is not None: + json_data["gwId"] = gwId + else: + json_data["gwId"] = self.id if "devId" in json_data: - json_data["devId"] = self.id + if devId is not None: + json_data["devId"] = devId + else: + json_data["devId"] = self.id if "uid" in json_data: - json_data["uid"] = self.id # still use id, no separate uid + if uid is not None: + json_data["uid"] = uid + else: + json_data["uid"] = self.id if "t" in json_data: - json_data["t"] = str(int(time.time())) + if json_data["t"] == "int": + json_data["t"] = int(time.time()) + else: + json_data["t"] = str(int(time.time())) if data is not None: if "dpId" in json_data: json_data["dpId"] = data + elif "data" in json_data: + json_data["data"] = {"dps": data} else: json_data["dps"] = data - elif command_hb == 0x0D: + elif self.dev_type == "type_0d" and command == DP_QUERY: json_data["dps"] = self.dps_to_request - payload = json.dumps(json_data).replace(" ", "").encode("utf-8") - self.debug("Send payload: %s", payload) - - if self.version == 3.3: - payload = self.cipher.encrypt(payload, False) - if command_hb not in [0x0A, 0x12]: - # add the 3.3 header - payload = PROTOCOL_33_HEADER + payload - elif command == SET: - payload = self.cipher.encrypt(payload) - to_hash = ( - b"data=" - + payload - + b"||lpv=" - + PROTOCOL_VERSION_BYTES_31 - + b"||" - + self.local_key - ) - hasher = md5() - hasher.update(to_hash) - hexdigest = hasher.hexdigest() - payload = ( - PROTOCOL_VERSION_BYTES_31 - + hexdigest[8:][:16].encode("latin1") - + payload - ) + if json_data == "": + payload = "" + else: + payload = json.dumps(json_data) + # if spaces are not removed device does not respond! + payload = payload.replace(" ", "").encode("utf-8") + self.debug("Sending payload: %s", payload) - msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0) - self.seqno += 1 - return pack_message(msg) + return MessagePayload(command_override, payload) def __repr__(self): """Return internal string representation of object.""" @@ -694,6 +1161,7 @@ async def connect( device_id, local_key, protocol_version, + enable_debug, listener=None, port=6668, timeout=5, @@ -706,6 +1174,7 @@ async def connect( device_id, local_key, protocol_version, + enable_debug, on_connected, listener or EmptyListener(), ), diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py index f643e081..c9b1d1c6 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,19 +4,15 @@ import voluptuous as vol from homeassistant.components.select import DOMAIN, SelectEntity -from homeassistant.const import ( - CONF_DEVICE_CLASS, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from .common import LocalTuyaEntity, async_setup_entry - from .const import ( + CONF_DEFAULT_VALUE, CONF_OPTIONS, CONF_OPTIONS_FRIENDLY, - CONF_DEFAULT_VALUE, - CONF_RESTORE_ON_RECONNECT, CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, ) diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index bc664bf5..3776836e 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -9,14 +9,14 @@ from .const import ( ATTR_CURRENT, ATTR_CURRENT_CONSUMPTION, - ATTR_VOLTAGE, ATTR_STATE, + ATTR_VOLTAGE, CONF_CURRENT, CONF_CURRENT_CONSUMPTION, - CONF_VOLTAGE, CONF_DEFAULT_VALUE, - CONF_RESTORE_ON_RECONNECT, CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, + CONF_VOLTAGE, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 4b3ddb0a..947141cb 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "Device has already been configured.", - "device_success": "Device {dev_name} successfully {action}." + "device_success": "Device {dev_name} successfully {action}.", + "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button." }, "error": { "authentication_failed": "Failed to authenticate.\n{msg}", @@ -95,6 +96,7 @@ "device_id": "Device ID", "local_key": "Local key", "protocol_version": "Protocol Version", + "enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)", "scan_interval": "Scan interval (seconds, only when not updating automatically)", "entities": "Entities (uncheck an entity to remove it)", "manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)", diff --git a/custom_components/localtuya/translations/it.json b/custom_components/localtuya/translations/it.json index faf4afa0..9b05309e 100644 --- a/custom_components/localtuya/translations/it.json +++ b/custom_components/localtuya/translations/it.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "Il dispositivo è già stato configurato.", - "device_success": "Dispositivo {dev_name} {action} con successo." + "device_success": "Dispositivo {dev_name} {action} con successo.", + "no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete." }, "error": { "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", @@ -95,6 +96,7 @@ "device_id": "ID del dispositivo", "local_key": "Chiave locale", "protocol_version": "Versione del protocollo", + "enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)", "scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)", "entities": "Entities (deseleziona un'entity per rimuoverla)" } diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json index a2feed45..ca5629c1 100644 --- a/custom_components/localtuya/translations/pt-BR.json +++ b/custom_components/localtuya/translations/pt-BR.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "O dispositivo já foi configurado.", - "device_success": "Dispositivo {dev_name} {action} com sucesso." + "device_success": "Dispositivo {dev_name} {action} com sucesso.", + "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir." }, "error": { "authentication_failed": "Falha ao autenticar.\n{msg}", @@ -95,6 +96,7 @@ "device_id": "ID do dispositivo", "local_key": "Local key", "protocol_version": "Versão do protocolo", + "enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)", "scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)", "entities": "Entidades (desmarque uma entidade para removê-la)" } From dc33870733c5b37a44290b60b83f321496fdb9bd Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:36:32 +0200 Subject: [PATCH 2/8] Alexa media 4.6.1 --- custom_components/alexa_media/__init__.py | 41 ++- .../alexa_media/alarm_control_panel.py | 32 +- custom_components/alexa_media/alexa_entity.py | 199 ++++++++--- custom_components/alexa_media/alexa_media.py | 2 - .../alexa_media/binary_sensor.py | 28 +- custom_components/alexa_media/config_flow.py | 31 +- custom_components/alexa_media/const.py | 35 +- custom_components/alexa_media/helpers.py | 62 ++-- custom_components/alexa_media/light.py | 140 ++++---- custom_components/alexa_media/manifest.json | 12 +- custom_components/alexa_media/media_player.py | 12 +- custom_components/alexa_media/notify.py | 15 +- custom_components/alexa_media/sensor.py | 316 +++++++++++------- custom_components/alexa_media/services.py | 10 +- custom_components/alexa_media/switch.py | 13 +- .../alexa_media/translations/de.json | 16 +- 16 files changed, 583 insertions(+), 381 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index bd321dda..323af03e 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -11,7 +11,7 @@ from json import JSONDecodeError import logging import time -from typing import Optional, Text +from typing import Optional from alexapy import ( AlexaAPI, @@ -182,6 +182,7 @@ async def async_setup(hass, config, discovery_info=None): # @retry_async(limit=5, delay=5, catch_exceptions=True) async def async_setup_entry(hass, config_entry): + # noqa: MC0001 """Set up Alexa Media Player as config entry.""" async def close_alexa_media(event=None) -> None: @@ -192,6 +193,7 @@ async def close_alexa_media(event=None) -> None: await close_connections(hass, email) async def complete_startup(event=None) -> None: + # pylint: disable=unused-argument """Run final tasks after startup.""" _LOGGER.debug("Completing remaining startup tasks.") await asyncio.sleep(10) @@ -331,9 +333,11 @@ async def login_success(event=None) -> None: async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): + # pylint: disable=too-many-statements,too-many-locals """Set up a alexa api based on host parameter.""" async def async_update_data() -> Optional[AlexaEntityData]: + # noqa pylint: disable=too-many-branches """Fetch data from API endpoint. This is the place to pre-process the data to lookup tables @@ -399,11 +403,17 @@ async def async_update_data() -> Optional[AlexaEntityData]: if temp and temp.enabled: entities_to_monitor.add(temp.alexa_entity_id) + temp = sensor.get("Air_Quality") + if temp and temp.enabled: + entities_to_monitor.add(temp.alexa_entity_id) + for light in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["light"]: if light.enabled: entities_to_monitor.add(light.alexa_entity_id) - for binary_sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["binary_sensor"]: + for binary_sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "binary_sensor" + ]: if binary_sensor.enabled: entities_to_monitor.add(binary_sensor.alexa_entity_id) @@ -445,8 +455,8 @@ async def async_update_data() -> Optional[AlexaEntityData]: # First run is a special case. Get the state of all entities(including disabled) # This ensures all entities have state during startup without needing to request coordinator refresh - for typeOfEntity, entities in alexa_entities.items(): - if typeOfEntity == "guard" or extended_entity_discovery: + for type_of_entity, entities in alexa_entities.items(): + if type_of_entity == "guard" or extended_entity_discovery: for entity in entities: entities_to_monitor.add(entity.get("id")) entity_state = await get_entity_data( @@ -482,7 +492,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: ) return except BaseException as err: - raise UpdateFailed(f"Error communicating with API: {err}") + raise UpdateFailed(f"Error communicating with API: {err}") from err new_alexa_clients = [] # list of newly discovered device names exclude_filter = [] @@ -530,13 +540,13 @@ async def async_update_data() -> Optional[AlexaEntityData]: _LOGGER.debug("Excluding %s for lacking capability", dev_name) continue - if "bluetoothStates" in bluetooth: + if bluetooth is not None and "bluetoothStates" in bluetooth: for b_state in bluetooth["bluetoothStates"]: if serial == b_state["deviceSerialNumber"]: device["bluetooth_state"] = b_state break - if "devicePreferences" in preferences: + if preferences is not None and "devicePreferences" in preferences: for dev in preferences["devicePreferences"]: if dev["deviceSerialNumber"] == serial: device["locale"] = dev["locale"] @@ -549,7 +559,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: ) break - if "doNotDisturbDeviceStatusList" in dnd: + if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: for dev in dnd["doNotDisturbDeviceStatusList"]: if dev["deviceSerialNumber"] == serial: device["dnd"] = dev["enabled"] @@ -626,7 +636,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: for device_entry in dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ): - for (_, identifier) in device_entry.identifiers: + for _, identifier in device_entry.identifiers: if identifier in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "devices" ]["media_player"].keys() or identifier in map( @@ -687,7 +697,9 @@ async def process_notifications(login_obj, raw_notifications=None): notification["date_time"] = ( f"{n_date} {n_time}" if n_date and n_time else None ) - previous_alarm = previous.get(n_dev_id, {}).get("Alarm", {}).get(n_id) + previous_alarm = ( + previous.get(n_dev_id, {}).get("Alarm", {}).get(n_id) + ) if previous_alarm and alarm_just_dismissed( notification, previous_alarm.get("status"), @@ -695,7 +707,10 @@ async def process_notifications(login_obj, raw_notifications=None): ): hass.bus.async_fire( "alexa_media_alarm_dismissal_event", - event_data={"device": {"id": n_dev_id}, "event": notification}, + event_data={ + "device": {"id": n_dev_id}, + "event": notification, + }, ) if n_dev_id not in notifications: @@ -840,6 +855,7 @@ async def ws_connect() -> WebsocketEchoClient: return websocket async def ws_handler(message_obj): + # pylint: disable=too-many-branches """Handle websocket messages. This allows push notifications from Alexa to update last_called @@ -864,7 +880,6 @@ async def ws_handler(message_obj): ] coord = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["coordinator"] if command and json_payload: - _LOGGER.debug( "%s: Received websocket command: %s : %s", hide_email(email), @@ -906,7 +921,7 @@ async def ws_handler(message_obj): f"{DOMAIN}_{hide_email(email)}"[0:32], {"push_activity": json_payload}, ) - except (AlexapyConnectionError): + except AlexapyConnectionError: # Catch case where activities doesn't report valid json pass elif command in ( diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 7841d1e1..a1b0262e 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -8,7 +8,7 @@ """ from asyncio import sleep import logging -from typing import Dict, List, Optional, Text # noqa pylint: disable=unused-import +from typing import List, Optional from alexapy import hide_email, hide_serial from homeassistant.const import ( @@ -132,7 +132,6 @@ class AlexaAlarmControlPanel(AlarmControlPanel, AlexaMedia, CoordinatorEntity): """Implementation of Alexa Media Player alarm control panel.""" def __init__(self, login, coordinator, guard_entity, media_players=None) -> None: - # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" AlexaMedia.__init__(self, None, login) CoordinatorEntity.__init__(self, coordinator) @@ -144,7 +143,7 @@ def __init__(self, login, coordinator, guard_entity, media_players=None) -> None self._guard_entity_id = guard_entity["id"] self._friendly_name = "Alexa Guard " + self._appliance_id[-5:] self._media_players = {} or media_players - self._attrs: Dict[Text, Text] = {} + self._attrs: dict[str, str] = {} _LOGGER.debug( "%s: Guard Discovered %s: %s %s", self.account, @@ -154,8 +153,9 @@ def __init__(self, login, coordinator, guard_entity, media_players=None) -> None ) @_catch_login_errors - async def _async_alarm_set(self, command: Text = "", code=None) -> None: - # pylint: disable=unexpected-keyword-arg + async def _async_alarm_set( + self, command: str = "", code=None # pylint: disable=unused-argument + ) -> None: """Send command.""" try: if not self.enabled: @@ -187,14 +187,16 @@ async def _async_alarm_set(self, command: Text = "", code=None) -> None: ) await self.coordinator.async_request_refresh() - async def async_alarm_disarm(self, code=None) -> None: - # pylint: disable=unexpected-keyword-arg + async def async_alarm_disarm( + self, code=None # pylint:disable=unused-argument + ) -> None: """Send disarm command.""" await self._async_alarm_set(STATE_ALARM_DISARMED) - async def async_alarm_arm_away(self, code=None) -> None: + async def async_alarm_arm_away( + self, code=None # pylint:disable=unused-argument + ) -> None: """Send arm away command.""" - # pylint: disable=unexpected-keyword-arg await self._async_alarm_set(STATE_ALARM_ARMED_AWAY) @property @@ -215,14 +217,14 @@ def state(self): ) if _state == "ARMED_AWAY": return STATE_ALARM_ARMED_AWAY - elif _state == "ARMED_STAY": - return STATE_ALARM_DISARMED - else: + if _state == "ARMED_STAY": return STATE_ALARM_DISARMED + return STATE_ALARM_DISARMED @property def supported_features(self) -> int: """Return the list of supported features.""" + # pylint: disable=import-outside-toplevel try: from homeassistant.components.alarm_control_panel import ( SUPPORT_ALARM_ARM_AWAY, @@ -233,6 +235,12 @@ def supported_features(self) -> int: @property def assumed_state(self) -> bool: + """Return assumed state. + + Returns + bool: Whether the state is assumed + + """ last_refresh_success = ( self.coordinator.data and self._guard_entity_id in self.coordinator.data ) diff --git a/custom_components/alexa_media/alexa_entity.py b/custom_components/alexa_media/alexa_entity.py index d86e835b..00fc70ab 100644 --- a/custom_components/alexa_media/alexa_entity.py +++ b/custom_components/alexa_media/alexa_entity.py @@ -10,7 +10,7 @@ import json import logging import re -from typing import Any, Dict, List, Optional, Text, Tuple, TypedDict, Union +from typing import Any, Optional, TypedDict, Union from alexapy import AlexaAPI, AlexaLogin, hide_serial from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,14 +19,14 @@ def has_capability( - appliance: Dict[Text, Any], interface_name: Text, property_name: Text + appliance: dict[str, Any], interface_name: str, property_name: str ) -> bool: """Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant. Args: - appliance(Dict[Text, Any]): An appliance from a call to AlexaAPI.get_network_details - interface_name(Text): One of the interfaces documented by the Alexa Smart Home Skills API - property_name(Text): The property that matches the interface name. + appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details + interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API + property_name(str): The property that matches the interface name. """ for cap in appliance["capabilities"]: @@ -42,7 +42,7 @@ def has_capability( return False -def is_hue_v1(appliance: Dict[Text, Any]) -> bool: +def is_hue_v1(appliance: dict[str, Any]) -> bool: """Determine if an appliance is managed via the Philips Hue v1 Hub. This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older @@ -51,7 +51,12 @@ def is_hue_v1(appliance: Dict[Text, Any]) -> bool: return appliance.get("manufacturerName") == "Royal Philips Electronics" -def is_local(appliance: Dict[Text, Any]) -> bool: +def is_skill(appliance: dict[str, Any]) -> bool: + namespace = appliance.get("driverIdentity", {}).get("namespace", "") + return namespace and namespace == "SKILL" + + +def is_local(appliance: dict[str, Any]) -> bool: """Test whether locally connected. This is mainly present to prevent loops with the official Alexa integration. @@ -66,8 +71,12 @@ def is_local(appliance: Dict[Text, Any]) -> bool: # This catches the Echo/AVS devices. connectedVia isn't reliable in this case. # Only the first appears to get that set. if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []): - namespace = appliance.get("driverIdentity", {}).get("namespace", "") - return namespace and namespace != "SKILL" + return not is_skill(appliance) + + # Ledvance bulbs connected via bluetooth are hard to detect as locally connected + # There is probably a better way, but this works for now. + if appliance.get("manufacturerName") == "Ledvance": + return not is_skill(appliance) # Zigbee devices are guaranteed to be local and have a particular pattern of id zigbee_pattern = re.compile( @@ -76,21 +85,32 @@ def is_local(appliance: Dict[Text, Any]) -> bool: return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None -def is_alexa_guard(appliance: Dict[Text, Any]) -> bool: +def is_alexa_guard(appliance: dict[str, Any]) -> bool: """Is the given appliance the guard alarm system of an echo.""" return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability( appliance, "Alexa.SecurityPanelController", "armState" ) -def is_temperature_sensor(appliance: Dict[Text, Any]) -> bool: +def is_temperature_sensor(appliance: dict[str, Any]) -> bool: """Is the given appliance the temperature sensor of an Echo.""" return is_local(appliance) and has_capability( appliance, "Alexa.TemperatureSensor", "temperature" ) -def is_light(appliance: Dict[Text, Any]) -> bool: +# Checks if air quality sensor +def is_air_quality_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance the Air Quality Sensor.""" + return ( + appliance["friendlyDescription"] == "Amazon Indoor Air Quality Monitor" + and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", []) + and has_capability(appliance, "Alexa.TemperatureSensor", "temperature") + and has_capability(appliance, "Alexa.RangeController", "rangeValue") + ) + + +def is_light(appliance: dict[str, Any]) -> bool: """Is the given appliance a light controlled locally by an Echo.""" return ( is_local(appliance) @@ -98,7 +118,8 @@ def is_light(appliance: Dict[Text, Any]) -> bool: and has_capability(appliance, "Alexa.PowerController", "powerState") ) -def is_contact_sensor(appliance: Dict[Text, Any]) -> bool: + +def is_contact_sensor(appliance: dict[str, Any]) -> bool: """Is the given appliance a contact sensor controlled locally by an Echo.""" return ( is_local(appliance) @@ -106,7 +127,8 @@ def is_contact_sensor(appliance: Dict[Text, Any]) -> bool: and has_capability(appliance, "Alexa.ContactSensor", "detectionState") ) -def get_friendliest_name(appliance: Dict[Text, Any]) -> Text: + +def get_friendliest_name(appliance: dict[str, Any]) -> str: """Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one.""" aliases = appliance.get("aliases", []) for alias in aliases: @@ -116,7 +138,7 @@ def get_friendliest_name(appliance: Dict[Text, Any]) -> Text: return appliance["friendlyName"] -def get_device_serial(appliance: Dict[Text, Any]) -> Optional[Text]: +def get_device_serial(appliance: dict[str, Any]) -> Optional[str]: """Find the device serial id if it is present.""" alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", []) for alexa_device_id in alexa_device_id_list: @@ -128,9 +150,9 @@ def get_device_serial(appliance: Dict[Text, Any]) -> Optional[Text]: class AlexaEntity(TypedDict): """Class for Alexaentity.""" - id: Text - appliance_id: Text - name: Text + id: str + appliance_id: str + name: str is_hue_v1: bool @@ -145,7 +167,14 @@ class AlexaLightEntity(AlexaEntity): class AlexaTemperatureEntity(AlexaEntity): """Class for AlexaTemperatureEntity.""" - device_serial: Text + device_serial: str + + +class AlexaAirQualityEntity(AlexaEntity): + """Class for AlexaAirQualityEntity.""" + + device_serial: str + class AlexaBinaryEntity(AlexaEntity): """Class for AlexaBinaryEntity.""" @@ -156,19 +185,23 @@ class AlexaBinaryEntity(AlexaEntity): class AlexaEntities(TypedDict): """Class for holding entities.""" - light: List[AlexaLightEntity] - guard: List[AlexaEntity] - temperature: List[AlexaTemperatureEntity] - binary_sensor: List[AlexaBinaryEntity] + light: list[AlexaLightEntity] + guard: list[AlexaEntity] + temperature: list[AlexaTemperatureEntity] + air_quality: list[AlexaAirQualityEntity] + binary_sensor: list[AlexaBinaryEntity] -def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEntities: +def parse_alexa_entities(network_details: Optional[dict[str, Any]]) -> AlexaEntities: + # pylint: disable=too-many-locals """Turn the network details into a list of useful entities with the important details extracted.""" lights = [] guards = [] temperature_sensors = [] + air_quality_sensors = [] contact_sensors = [] location_details = network_details["locationDetails"]["locationDetails"] + # pylint: disable=too-many-nested-blocks for location in location_details.values(): amazon_bridge_details = location["amazonBridgeDetails"]["amazonBridgeDetails"] for bridge in amazon_bridge_details.values(): @@ -188,6 +221,44 @@ def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEnt serial if serial else appliance["entityId"] ) temperature_sensors.append(processed_appliance) + # Code for Amazon Smart Air Quality Monitor + elif is_air_quality_sensor(appliance): + serial = get_device_serial(appliance) + processed_appliance["device_serial"] = ( + serial if serial else appliance["entityId"] + ) + # create array of air quality sensors. We must store the instance id against + # the assetId so we know which sensors are which. + sensors = [] + if ( + appliance["friendlyDescription"] + == "Amazon Indoor Air Quality Monitor" + ): + for cap in appliance["capabilities"]: + instance = cap.get("instance") + if instance: + friendlyName = cap["resources"].get("friendlyNames") + for entry in friendlyName: + assetId = entry["value"].get("assetId") + if assetId and assetId.startswith( + "Alexa.AirQuality" + ): + unit = cap["configuration"]["unitOfMeasure"] + sensor = { + "sensorType": assetId, + "instance": instance, + "unit": unit, + } + sensors.append(sensor) + _LOGGER.debug( + "AIAQM sensor detected %s", sensor + ) + processed_appliance["sensors"] = sensors + + # Add as both temperature and air quality sensor + temperature_sensors.append(processed_appliance) + air_quality_sensors.append(processed_appliance) + elif is_light(appliance): processed_appliance["brightness"] = has_capability( appliance, "Alexa.BrightnessController", "brightness" @@ -209,22 +280,28 @@ def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEnt else: _LOGGER.debug("Found unsupported device %s", appliance) - return {"light": lights, "guard": guards, "temperature": temperature_sensors, "binary_sensor": contact_sensors} + return { + "light": lights, + "guard": guards, + "temperature": temperature_sensors, + "air_quality": air_quality_sensors, + "binary_sensor": contact_sensors, + } class AlexaCapabilityState(TypedDict): """Class for AlexaCapabilityState.""" - name: Text - namespace: Text - value: Union[int, Text, TypedDict] + name: str + namespace: str + value: Union[int, str, TypedDict] -AlexaEntityData = Dict[Text, List[AlexaCapabilityState]] +AlexaEntityData = dict[str, list[AlexaCapabilityState]] async def get_entity_data( - login_obj: AlexaLogin, entity_ids: List[Text] + login_obj: AlexaLogin, entity_ids: list[str] ) -> AlexaEntityData: """Get and process the entity data into a more usable format.""" @@ -244,8 +321,8 @@ async def get_entity_data( def parse_temperature_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str +) -> Optional[str]: """Get the temperature of an entity from the coordinator data.""" value = parse_value_from_coordinator( coordinator, entity_id, "Alexa.TemperatureSensor", "temperature" @@ -253,8 +330,22 @@ def parse_temperature_from_coordinator( return value.get("value") if value and "value" in value else None +def parse_air_quality_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, instance_id: str +) -> Optional[str]: + """Get the air quality of an entity from the coordinator data.""" + value = parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.RangeController", + "rangeValue", + instance=instance_id, + ) + return value + + def parse_brightness_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime ) -> Optional[int]: """Get the brightness in the range 0-100.""" return parse_value_from_coordinator( @@ -263,9 +354,9 @@ def parse_brightness_from_coordinator( def parse_color_temp_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime ) -> Optional[int]: - """Get the color temperature in kelvin""" + """Get the color temperature in kelvin.""" return parse_value_from_coordinator( coordinator, entity_id, @@ -276,9 +367,9 @@ def parse_color_temp_from_coordinator( def parse_color_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime -) -> Optional[Tuple[float, float, float]]: - """Get the color as a tuple of (hue, saturation, brightness)""" + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime +) -> Optional[tuple[float, float, float]]: + """Get the color as a tuple of (hue, saturation, brightness).""" value = parse_value_from_coordinator( coordinator, entity_id, "Alexa.ColorController", "color", since ) @@ -290,8 +381,8 @@ def parse_color_from_coordinator( def parse_power_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime +) -> Optional[str]: """Get the power state of the entity.""" return parse_value_from_coordinator( coordinator, entity_id, "Alexa.PowerController", "powerState", since @@ -299,8 +390,8 @@ def parse_power_from_coordinator( def parse_guard_state_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str +) -> Optional[str]: """Get the guard state from the coordinator data.""" return parse_value_from_coordinator( coordinator, entity_id, "Alexa.SecurityPanelController", "armState" @@ -308,19 +399,21 @@ def parse_guard_state_from_coordinator( def parse_detection_state_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text + coordinator: DataUpdateCoordinator, entity_id: str ) -> Optional[bool]: """Get the detection state from the coordinator data.""" return parse_value_from_coordinator( coordinator, entity_id, "Alexa.ContactSensor", "detectionState" ) + def parse_value_from_coordinator( coordinator: DataUpdateCoordinator, - entity_id: Text, - namespace: Text, - name: Text, + entity_id: str, + namespace: str, + name: str, since: Optional[datetime] = None, + instance: str = None, ) -> Any: """Parse out values from coordinator for Alexa Entities.""" if coordinator.data and entity_id in coordinator.data: @@ -328,22 +421,22 @@ def parse_value_from_coordinator( if ( cap_state.get("namespace") == namespace and cap_state.get("name") == name + and (cap_state.get("instance") == instance or instance is None) ): if is_cap_state_still_acceptable(cap_state, since): return cap_state.get("value") - else: - _LOGGER.debug( - "Coordinator data for %s is too old to be returned.", - hide_serial(entity_id), - ) - return None + _LOGGER.debug( + "Coordinator data for %s is too old to be returned.", + hide_serial(entity_id), + ) + return None else: _LOGGER.debug("Coordinator has no data for %s", hide_serial(entity_id)) return None def is_cap_state_still_acceptable( - cap_state: Dict[Text, Any], since: Optional[datetime] + cap_state: dict[str, Any], since: Optional[datetime] ) -> bool: """Determine if a particular capability state is still usable given its age.""" if since is not None: diff --git a/custom_components/alexa_media/alexa_media.py b/custom_components/alexa_media/alexa_media.py index 16b9c625..7c96ac96 100644 --- a/custom_components/alexa_media/alexa_media.py +++ b/custom_components/alexa_media/alexa_media.py @@ -8,7 +8,6 @@ """ import logging -from typing import Dict, Text # noqa pylint: disable=unused-import from alexapy import AlexaAPI, hide_email @@ -21,7 +20,6 @@ class AlexaMedia: """Implementation of Alexa Media Base object.""" def __init__(self, device, login) -> None: - # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" # Class info diff --git a/custom_components/alexa_media/binary_sensor.py b/custom_components/alexa_media/binary_sensor.py index 45e545b5..b9cca0b3 100644 --- a/custom_components/alexa_media/binary_sensor.py +++ b/custom_components/alexa_media/binary_sensor.py @@ -8,7 +8,6 @@ """ import logging -from typing import List # noqa pylint: disable=unused-import from alexapy import hide_serial from homeassistant.components.binary_sensor import ( @@ -30,9 +29,10 @@ _LOGGER = logging.getLogger(__name__) + async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa sensor platform.""" - devices: List[BinarySensorEntity] = [] + devices: list[BinarySensorEntity] = [] account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] include_filter = config.get(CONF_INCLUDE_DEVICES, []) @@ -40,13 +40,13 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf coordinator = account_dict["coordinator"] binary_entities = account_dict.get("devices", {}).get("binary_sensor", []) if binary_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): - for be in binary_entities: + for binary_entity in binary_entities: _LOGGER.debug( "Creating entity %s for a binary_sensor with name %s", - hide_serial(be["id"]), - be["name"], + hide_serial(binary_entity["id"]), + binary_entity["name"], ) - contact_sensor = AlexaContact(coordinator, be) + contact_sensor = AlexaContact(coordinator, binary_entity) account_dict["entities"]["binary_sensor"].append(contact_sensor) devices.append(contact_sensor) @@ -75,34 +75,46 @@ async def async_unload_entry(hass, entry) -> bool: await binary_sensor.async_remove() return True + class AlexaContact(CoordinatorEntity, BinarySensorEntity): """A contact sensor controlled by an Echo.""" _attr_device_class = BinarySensorDeviceClass.DOOR - def __init__(self, coordinator, details): + def __init__(self, coordinator: CoordinatorEntity, details: dict): + """Initialize alexa contact sensor. + + Args + coordinator (CoordinatorEntity): Coordinator + details (dict): Details dictionary + + """ super().__init__(coordinator) self.alexa_entity_id = details["id"] self._name = details["name"] @property def name(self): + """Return name.""" return self._name @property def unique_id(self): + """Return unique id.""" return self.alexa_entity_id @property def is_on(self): + """Return whether on.""" detection = parse_detection_state_from_coordinator( self.coordinator, self.alexa_entity_id ) - return detection == 'DETECTED' if detection is not None else None + return detection == "DETECTED" if detection is not None else None @property def assumed_state(self) -> bool: + """Return assumed state.""" last_refresh_success = ( self.coordinator.data and self.alexa_entity_id in self.coordinator.data ) diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py index cde4da2f..585776cc 100644 --- a/custom_components/alexa_media/config_flow.py +++ b/custom_components/alexa_media/config_flow.py @@ -12,7 +12,7 @@ from datetime import timedelta from functools import reduce import logging -from typing import Any, Dict, List, Optional, Text +from typing import Any, Optional from aiohttp import ClientConnectionError, ClientSession, InvalidURL, web, web_response from aiohttp.web_exceptions import HTTPBadRequest @@ -31,7 +31,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import UnknownFlow from homeassistant.exceptions import Unauthorized -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util import slugify import httpx @@ -89,7 +88,7 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): def _update_ord_dict(self, old_dict: OrderedDict, new_dict: dict) -> OrderedDict: result: OrderedDict = OrderedDict() - for k, v in old_dict.items(): + for k, v in old_dict.items(): # pylint: disable=invalid-name for key, value in new_dict.items(): if k == key: result.update([(key, value)]) @@ -133,6 +132,7 @@ async def async_step_import(self, import_config): return await self.async_step_user_legacy(import_config) async def async_step_user(self, user_input=None): + # pylint: disable=too-many-branches """Provide a proxy for login.""" self._save_user_input_to_config(user_input=user_input) try: @@ -263,10 +263,10 @@ async def async_step_user(self, user_input=None): try: async with session.get(hass_url) as resp: hass_url_valid = resp.status == 200 - except (ClientConnectionError) as err: + except ClientConnectionError as err: hass_url_valid = False hass_url_error = str(err) - except (InvalidURL) as err: + except InvalidURL as err: hass_url_valid = False hass_url_error = str(err.__cause__) if not hass_url_valid: @@ -305,6 +305,7 @@ async def async_step_user(self, user_input=None): async def async_step_start_proxy(self, user_input=None): """Start proxy for login.""" + # pylint: disable=unused-argument _LOGGER.debug( "Starting proxy for %s - %s", hide_email(self.login.email), @@ -340,10 +341,11 @@ async def async_step_start_proxy(self, user_input=None): proxy_url = self.proxy.access_url().with_query( {"config_flow_id": self.flow_id, "callback_url": str(callback_url)} ) - self.login._session.cookie_jar.clear() + self.login._session.cookie_jar.clear() # pylint: disable=protected-access return self.async_external_step(step_id="check_proxy", url=str(proxy_url)) async def async_step_check_proxy(self, user_input=None): + # pylint: disable=unused-argument """Check status of proxy for login.""" _LOGGER.debug( "Checking proxy response for %s - %s", @@ -354,6 +356,7 @@ async def async_step_check_proxy(self, user_input=None): return self.async_external_step_done(next_step_id="finish_proxy") async def async_step_finish_proxy(self, user_input=None): + # pylint: disable=unused-argument """Finish auth.""" if await self.login.test_loggedin(): await self.login.finalize_login() @@ -462,7 +465,7 @@ async def async_step_user_legacy(self, user_input=None): errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) - except BaseException as ex: # pylyint: disable=broad-except + except BaseException as ex: # pylint: disable=broad-except _LOGGER.warning("Unknown error: %s", ex) if self.config[CONF_DEBUG]: raise @@ -555,7 +558,6 @@ async def async_step_reauth(self, user_input=None): return await self.async_step_user_legacy(self.config) async def _test_login(self): - # pylint: disable=too-many-statements, too-many-return-statements login = self.login email = login.email _LOGGER.debug("Testing login status: %s", login.status) @@ -632,10 +634,10 @@ async def _test_login(self): self.automatic_steps += 1 await sleep(5) if generated_securitycode: - return await self.async_step_twofactor( + return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: generated_securitycode} ) - return await self.async_step_twofactor( + return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: self.securitycode} ) if login.status and (login.status.get("login_failed")): @@ -675,6 +677,7 @@ async def _test_login(self): ) def _save_user_input_to_config(self, user_input=None) -> None: + # pylint: disable=too-many-branches """Process user_input to save to self.config. user_input can be a dictionary of strings or an internally @@ -833,11 +836,11 @@ class AlexaMediaAuthorizationProxyView(HomeAssistantView): """Handle proxy connections.""" url: str = AUTH_PROXY_PATH - extra_urls: List[str] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] + extra_urls: list[str] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] name: str = AUTH_PROXY_NAME requires_auth: bool = False handler: web.RequestHandler = None - known_ips: Dict[str, datetime.datetime] = {} + known_ips: dict[str, datetime.datetime] = {} auth_seconds: int = 300 def __init__(self, handler: web.RequestHandler): @@ -885,7 +888,9 @@ async def wrapped(request, **kwargs): _LOGGER.warning("Detected Connection error: %s", ex) return web_response.Response( headers={"content-type": "text/html"}, - text=f"Connection Error! Please try refreshing. If this persists, please report this error to here:
{ex}
", + text="Connection Error! Please try refreshing. " + + "If this persists, please report this error to " + + f"here:
{ex}
", ) return wrapped diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index 8a8df879..8bb27043 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -8,7 +8,13 @@ """ from datetime import timedelta -__version__ = "4.4.0" +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, +) + +__version__ = "4.6.1" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = f"{PROJECT_URL}issues" NOTIFY_URL = f"{PROJECT_URL}wiki/Configuration%3A-Notification-Component#use-the-notifyalexa_media-service" @@ -30,7 +36,7 @@ "sensor", "alarm_control_panel", "light", - "binary_sensor" + "binary_sensor", ] HTTP_COOKIE_HEADER = "# HTTP Cookie File" @@ -98,19 +104,30 @@ ATTR_MESSAGE = "message" ATTR_EMAIL = "email" ATTR_NUM_ENTRIES = "entries" -STARTUP = """ +STARTUP = f""" ------------------------------------------------------------------- -{} -Version: {} +{DOMAIN} +Version: {__version__} This is a custom component If you have any issues with this you need to open an issue here: -{} +{ISSUE_URL} ------------------------------------------------------------------- -""".format( - DOMAIN, __version__, ISSUE_URL -) +""" AUTH_CALLBACK_PATH = "/auth/alexamedia/callback" AUTH_CALLBACK_NAME = "auth:alexamedia:callback" AUTH_PROXY_PATH = "/auth/alexamedia/proxy" AUTH_PROXY_NAME = "auth:alexamedia:proxy" + +ALEXA_UNIT_CONVERSION = { + "Alexa.Unit.Percent": PERCENTAGE, + "Alexa.Unit.PartsPerMillion": CONCENTRATION_PARTS_PER_MILLION, + "Alexa.Unit.Density.MicroGramsPerCubicMeter": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, +} + +ALEXA_ICON_CONVERSION = { + "Alexa.AirQuality.CarbonMonoxide": "mdi:molecule-co", + "Alexa.AirQuality.Humidity": "mdi:water-percent", + "Alexa.AirQuality.IndoorAirQuality": "mdi:numeric", +} +ALEXA_ICON_DEFAULT = "mdi:molecule" diff --git a/custom_components/alexa_media/helpers.py b/custom_components/alexa_media/helpers.py index 9964178a..cdd9a66c 100644 --- a/custom_components/alexa_media/helpers.py +++ b/custom_components/alexa_media/helpers.py @@ -6,14 +6,16 @@ For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ +import asyncio +import functools import hashlib import logging -from typing import Any, Callable, Dict, List, Optional, Text +from typing import Any, Callable, Optional from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email from alexapy.alexalogin import AlexaLogin from homeassistant.const import CONF_EMAIL, CONF_URL -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConditionErrorMessage from homeassistant.helpers.entity_component import EntityComponent import wrapt @@ -23,11 +25,11 @@ async def add_devices( - account: Text, - devices: List[EntityComponent], + account: str, + devices: list[EntityComponent], add_devices_callback: Callable, - include_filter: Optional[List[Text]] = None, - exclude_filter: Optional[List[Text]] = None, + include_filter: Optional[list[str]] = None, + exclude_filter: Optional[list[str]] = None, ) -> bool: """Add devices using add_devices_callback.""" include_filter = [] or include_filter @@ -49,8 +51,8 @@ async def add_devices( try: add_devices_callback(devices, False) return True - except HomeAssistantError as exception_: - message = exception_.message # type: str + except ConditionErrorMessage as exception_: + message: str = exception_.message if message.startswith("Entity id already exists"): _LOGGER.debug("%s: Device already added: %s", account, message) else: @@ -84,6 +86,7 @@ def retry_async( The delay in seconds between retries. catch_exceptions : bool Whether exceptions should be caught and treated as failures or thrown. + Returns ------- def @@ -92,9 +95,6 @@ def retry_async( """ def wrap(func) -> Callable: - import asyncio - import functools - @functools.wraps(func) async def wrapper(*args, **kwargs) -> Any: _LOGGER.debug( @@ -110,7 +110,7 @@ async def wrapper(*args, **kwargs) -> Any: next_try: int = 0 while not result and retries < limit: if retries != 0: - next_try = delay * 2 ** retries + next_try = delay * 2**retries await asyncio.sleep(next_try) retries += 1 try: @@ -168,7 +168,7 @@ async def _catch_login_errors(func, instance, args, kwargs) -> Any: # _LOGGER.debug("Func %s instance %s %s %s", func, instance, args, kwargs) if instance: if hasattr(instance, "_login"): - login = instance._login + login = instance._login # pylint: disable=protected-access hass = instance.hass else: for arg in all_args: @@ -222,8 +222,8 @@ def report_relogin_required(hass, login, email) -> bool: return False -def _existing_serials(hass, login_obj) -> List: - email: Text = login_obj.email +def _existing_serials(hass, login_obj) -> list: + email: str = login_obj.email existing_serials = ( list( hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ @@ -250,7 +250,7 @@ def _existing_serials(hass, login_obj) -> List: return existing_serials -async def calculate_uuid(hass, email: Text, url: Text) -> dict: +async def calculate_uuid(hass, email: str, url: str) -> dict: """Return uuid and index of email/url. Args @@ -285,33 +285,25 @@ async def calculate_uuid(hass, email: Text, url: Text) -> dict: def alarm_just_dismissed( - alarm: Dict[Text, Any], - previous_status: Optional[Text], - previous_version: Optional[Text], + alarm: dict[str, Any], + previous_status: Optional[str], + previous_version: Optional[str], ) -> bool: """Given the previous state of an alarm, determine if it has just been dismissed.""" - if previous_status not in ("SNOOZED", "ON"): + if ( + previous_status not in ("SNOOZED", "ON") # The alarm had to be in a status that supported being dismissed - return False - - if previous_version is None: + or previous_version is None # The alarm was probably just created - return False - - if not alarm: + or not alarm # The alarm that was probably just deleted. - return False - - if alarm.get("status") not in ("OFF", "ON"): + or alarm.get("status") not in ("OFF", "ON") # A dismissed alarm is guaranteed to be turned off(one-off alarm) or left on(recurring alarm) - return False - - if previous_version == alarm.get("version"): + or previous_version == alarm.get("version") # A dismissal always has a changed version. - return False - - if int(alarm.get("version", "0")) > 1 + int(previous_version): + or int(alarm.get("version", "0")) > 1 + int(previous_version) + ): # This is an absurd thing to check, but it solves many, many edge cases. # Experimentally, when an alarm is dismissed, the version always increases by 1 # When an alarm is edited either via app or voice, its version always increases by 2+ diff --git a/custom_components/alexa_media/light.py b/custom_components/alexa_media/light.py index 369cf071..d996a5a6 100644 --- a/custom_components/alexa_media/light.py +++ b/custom_components/alexa_media/light.py @@ -9,13 +9,7 @@ import datetime import logging from math import sqrt -from typing import ( # noqa pylint: disable=unused-import - Callable, - List, - Optional, - Text, - Tuple, -) +from typing import Optional from alexapy import AlexaAPI, hide_serial from homeassistant.components.light import ( @@ -74,7 +68,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa sensor platform.""" - devices: List[LightEntity] = [] + devices: list[LightEntity] = [] account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] include_filter = config.get(CONF_INCLUDE_DEVICES, []) @@ -85,20 +79,20 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf ) light_entities = account_dict.get("devices", {}).get("light", []) if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): - for le in light_entities: - if not (le["is_hue_v1"] and hue_emulated_enabled): + for light_entity in light_entities: + if not (light_entity["is_hue_v1"] and hue_emulated_enabled): _LOGGER.debug( "Creating entity %s for a light with name %s", - hide_serial(le["id"]), - le["name"], + hide_serial(light_entity["id"]), + light_entity["name"], ) - light = AlexaLight(coordinator, account_dict["login_obj"], le) + light = AlexaLight(coordinator, account_dict["login_obj"], light_entity) account_dict["entities"]["light"].append(light) devices.append(light) else: _LOGGER.debug( "Light '%s' has not been added because it may originate from emulated_hue", - le["name"], + light_entity["name"], ) return await add_devices( @@ -127,23 +121,24 @@ async def async_unload_entry(hass, entry) -> bool: return True -def color_modes(details): +def color_modes(details) -> list: + """Return list of color modes.""" if details["color"] and details["color_temperature"]: return [COLOR_MODE_HS, COLOR_MODE_COLOR_TEMP] - elif details["color"]: + if details["color"]: return [COLOR_MODE_HS] - elif details["color_temperature"]: + if details["color_temperature"]: return [COLOR_MODE_COLOR_TEMP] - elif details["brightness"]: + if details["brightness"]: return [COLOR_MODE_BRIGHTNESS] - else: - return [COLOR_MODE_ONOFF] + return [COLOR_MODE_ONOFF] class AlexaLight(CoordinatorEntity, LightEntity): """A light controlled by an Echo.""" def __init__(self, coordinator, login, details): + """Initialize alexa light entity.""" super().__init__(coordinator) self.alexa_entity_id = details["id"] self._name = details["name"] @@ -163,14 +158,17 @@ def __init__(self, coordinator, login, details): @property def name(self): + """Return name.""" return self._name @property def unique_id(self): + """Return unique id.""" return self.alexa_entity_id @property def supported_features(self): + """Return supported features.""" # The HA documentation marks every single feature that Alexa lights can support as deprecated. # The new alternative is the supported_color_modes and color_mode properties(HA 2021.4) # This SHOULD just need to return 0 according to the light entity docs. @@ -178,95 +176,99 @@ def supported_features(self): # So, continue to provide a backwards compatible method here until HA is fixed and the min HA version is raised. if COLOR_MODE_BRIGHTNESS in self._color_modes: return SUPPORT_BRIGHTNESS - elif ( + if ( COLOR_MODE_HS in self._color_modes and COLOR_MODE_COLOR_TEMP in self._color_modes ): return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP - elif COLOR_MODE_HS in self._color_modes: + if COLOR_MODE_HS in self._color_modes: return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - elif COLOR_MODE_COLOR_TEMP in self._color_modes: + if COLOR_MODE_COLOR_TEMP in self._color_modes: return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - else: - - return 0 + return 0 @property def color_mode(self): + """Return color mode.""" if ( COLOR_MODE_HS in self._color_modes and COLOR_MODE_COLOR_TEMP in self._color_modes ): - hs = self.hs_color - if hs is None or (hs[0] == 0 and hs[1] == 0): + hs_color = self.hs_color + if hs_color is None or (hs_color[0] == 0 and hs_color[1] == 0): # (0,0) is white. When white, color temp is the better plan. return COLOR_MODE_COLOR_TEMP - else: - return COLOR_MODE_HS - else: - return self._color_modes[0] + return COLOR_MODE_HS + return self._color_modes[0] @property def supported_color_modes(self): + """Return supported color modes.""" return self._color_modes @property def is_on(self): + """Return whether on.""" power = parse_power_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if power is None: return self._requested_power if self._requested_power is not None else False - else: - return power == "ON" + return power == "ON" @property def brightness(self): + """Return brightness.""" bright = parse_brightness_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if bright is None: return self._requested_ha_brightness - else: - return alexa_brightness_to_ha(bright) + return alexa_brightness_to_ha(bright) @property def min_mireds(self): + """Return min mireds.""" return 143 @property def max_mireds(self): + """Return max mireds.""" return 454 @property def color_temp(self): + """Return color temperature.""" kelvin = parse_color_temp_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if kelvin is None: return self._requested_mired - else: - return alexa_kelvin_to_mired(kelvin) + return alexa_kelvin_to_mired(kelvin) @property def hs_color(self): + """Return hs color.""" hsb = parse_color_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if hsb is None: return self._requested_hs - else: - adjusted_hs, color_name = hsb_to_alexa_color(hsb) - return adjusted_hs + ( + adjusted_hs, + color_name, # pylint:disable=unused-variable + ) = hsb_to_alexa_color(hsb) + return adjusted_hs @property def assumed_state(self) -> bool: + """Return whether state is assumed.""" last_refresh_success = ( self.coordinator.data and self.alexa_entity_id in self.coordinator.data ) return not last_refresh_success - async def _set_state(self, power_on, brightness=None, mired=None, hs=None): + async def _set_state(self, power_on, brightness=None, mired=None, hs_color=None): # This is "rounding" on mired to the closest value Alexa is willing to acknowledge the existence of. # The alternative implementation would be to use effects instead. # That is far more non-standard, and would lock users out of things like the Flux integration. @@ -278,7 +280,7 @@ async def _set_state(self, power_on, brightness=None, mired=None, hs=None): # This is "rounding" on HS color to closest value Alexa supports. # The alexa color list is short, but covers a pretty broad spectrum. # Like for mired above, this sounds bad but works ok in practice. - adjusted_hs, color_name = hs_to_alexa_color(hs) + adjusted_hs, color_name = hs_to_alexa_color(hs_color) else: # If a color temperature is being set, it is not possible to also adjust the color. adjusted_hs = None @@ -317,35 +319,36 @@ async def _set_state(self, power_on, brightness=None, mired=None, hs=None): self.async_write_ha_state() async def async_turn_on(self, **kwargs): + """Turn on.""" brightness = None mired = None - hs = None + hs_color = None if COLOR_MODE_ONOFF not in self._color_modes and ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] if COLOR_MODE_COLOR_TEMP in self._color_modes and ATTR_COLOR_TEMP in kwargs: mired = kwargs[ATTR_COLOR_TEMP] if COLOR_MODE_HS in self._color_modes and ATTR_HS_COLOR in kwargs: - hs = kwargs[ATTR_HS_COLOR] - await self._set_state(True, brightness, mired, hs) + hs_color = kwargs[ATTR_HS_COLOR] + await self._set_state(True, brightness, mired, hs_color) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument + """Turn off.""" await self._set_state(False) -def mired_to_alexa(mired: Optional[float]) -> Tuple[Optional[float], Optional[Text]]: +def mired_to_alexa(mired: Optional[float]) -> tuple[Optional[float], Optional[str]]: """Convert a given color temperature in mired to the closest available value that Alexa has support for.""" if mired is None: return None, None - elif mired <= 162.5: + if mired <= 162.5: return 143, "cool_white" - elif mired <= 216: + if mired <= 216: return 182, "daylight_white" - elif mired <= 310: + if mired <= 310: return 250, "white" - elif mired <= 412: + if mired <= 412: return 370, "soft_white" - else: - return 454, "warm_white" + return 454, "warm_white" def alexa_kelvin_to_mired(kelvin: float) -> float: @@ -354,11 +357,13 @@ def alexa_kelvin_to_mired(kelvin: float) -> float: return mired_to_alexa(raw_mired)[0] -def ha_brightness_to_alexa(ha: Optional[float]) -> Optional[float]: - return (ha / 255 * 100) if ha is not None else None +def ha_brightness_to_alexa(ha_brightness: Optional[float]) -> Optional[float]: + """Convert HA brightness to alexa brightness.""" + return (ha_brightness / 255 * 100) if ha_brightness is not None else None def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: + """Convert Alexa brightness to HA brightness.""" return (alexa / 100 * 255) if alexa is not None else None @@ -508,8 +513,9 @@ def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: } -def red_mean(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float: +def red_mean(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float: """Get an approximate 'distance' between two colors using red mean. + Wikipedia says this method is "one of the better low-cost approximations". """ r_avg = (color2[0] + color1[0]) / 2 @@ -522,14 +528,14 @@ def red_mean(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> floa return sqrt(r_term + g_term + b_term) -def alexa_color_name_to_rgb(color_name: Text) -> Tuple[int, int, int]: - """Convert an alexa color name into RGB""" +def alexa_color_name_to_rgb(color_name: str) -> tuple[int, int, int]: + """Convert an alexa color name into RGB.""" return color_name_to_rgb(color_name.replace("_", "")) def rgb_to_alexa_color( - rgb: Tuple[int, int, int] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + rgb: tuple[int, int, int] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given RGB value into the closest Alexa color.""" (name, alexa_rgb) = min( ALEXA_COLORS.items(), @@ -540,18 +546,18 @@ def rgb_to_alexa_color( def hs_to_alexa_color( - hs: Optional[Tuple[float, float]] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + hs_color: Optional[tuple[float, float]] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation value into the closest Alexa color.""" - if hs is None: + if hs_color is None: return None, None - hue, saturation = hs + hue, saturation = hs_color return rgb_to_alexa_color(color_hs_to_RGB(hue, saturation)) def hsb_to_alexa_color( - hsb: Optional[Tuple[float, float, float]] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + hsb: Optional[tuple[float, float, float]] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation/brightness value into the closest Alexa color.""" if hsb is None: return None, None diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index 59146a99..a9d6596f 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -1,13 +1,13 @@ { "domain": "alexa_media", "name": "Alexa Media Player", - "version": "4.4.0", + "codeowners": ["@alandtse", "@keatontaylor"], "config_flow": true, - "documentation": "https://github.com/custom-components/alexa_media_player/wiki", - "issue_tracker": "https://github.com/custom-components/alexa_media_player/issues", "dependencies": ["persistent_notification", "http"], - "codeowners": ["@alandtse", "@keatontaylor"], - "requirements": ["alexapy==1.26.4", "packaging>=20.3", "wrapt>=1.12.1"], + "documentation": "https://github.com/custom-components/alexa_media_player/wiki", "iot_class": "cloud_polling", - "loggers": ["alexapy", "authcaptureproxy"] + "issue_tracker": "https://github.com/custom-components/alexa_media_player/issues", + "loggers": ["alexapy", "authcaptureproxy"], + "requirements": ["alexapy==1.26.5", "packaging>=20.3", "wrapt>=1.12.1"], + "version": "4.6.1" } diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 589fb1d7..ebaf40ba 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -9,9 +9,8 @@ import asyncio import logging import re -from typing import List, Optional, Text # noqa pylint: disable=unused-import +from typing import List, Optional -from alexapy import AlexaAPI from homeassistant import util from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, @@ -90,7 +89,6 @@ # @retry_async(limit=5, delay=2, catch_exceptions=True) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): - # pylint: disable=unused-argument """Set up the Alexa media player platform.""" devices = [] # type: List[AlexaClient] account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] @@ -188,7 +186,6 @@ class AlexaClient(MediaPlayerDevice, AlexaMedia): """Representation of a Alexa device.""" def __init__(self, device, login, second_account_index=0): - # pylint: disable=unused-argument """Initialize the Alexa device.""" super().__init__(self, login) @@ -283,6 +280,7 @@ async def async_will_remove_from_hass(self): pass # ignore missing listener async def _handle_event(self, event): + # pylint: disable=too-many-branches,too-many-statements """Handle events. This will update last_called and player_state events. @@ -378,7 +376,6 @@ async def _refresh_if_no_audiopush(already_refreshed=False): and self._last_called_timestamp != event["last_called_change"]["timestamp"] ): - _LOGGER.debug( "%s: %s is last_called: %s", hide_email(self._login.email), @@ -520,6 +517,7 @@ def _set_authentication_details(self, auth): @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) @_catch_login_errors async def refresh(self, device=None, skip_api: bool = False): + # pylint: disable=too-many-branches,too-many-statements """Refresh device data. This is a per device refresh and for many Alexa devices can result in @@ -594,7 +592,7 @@ async def refresh(self, device=None, skip_api: bool = False): if playing_parents: if len(playing_parents) > 1: _LOGGER.warning( - "Found multiple playing parents " "please file an issue" + "Found multiple playing parents please file an issue" ) parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self._login.email @@ -1312,7 +1310,7 @@ async def async_send_dropin_notification(self, message, **kwargs): @_catch_login_errors async def async_play_media(self, media_type, media_id, enqueue=None, **kwargs): - # pylint: disable=unused-argument + # pylint: disable=unused-argument,too-many-branches """Send the play_media command to the media player.""" queue_delay = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][ "options" diff --git a/custom_components/alexa_media/notify.py b/custom_components/alexa_media/notify.py index 17186bdc..26e290ae 100644 --- a/custom_components/alexa_media/notify.py +++ b/custom_components/alexa_media/notify.py @@ -10,6 +10,7 @@ import json import logging +from alexapy.helpers import hide_email, hide_serial from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, @@ -18,18 +19,15 @@ SERVICE_NOTIFY, BaseNotificationService, ) +from homeassistant.const import CONF_EMAIL import voluptuous as vol -from custom_components.alexa_media.const import NOTIFY_URL - -from . import ( - CONF_EMAIL, +from .const import ( CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, DEFAULT_QUEUE_DELAY, DOMAIN, - hide_email, - hide_serial, + NOTIFY_URL, ) from .helpers import retry_async @@ -205,6 +203,7 @@ def devices(self): return devices async def async_send_message(self, message="", **kwargs): + # pylint: disable=too-many-branches """Send a message to a Alexa device.""" _LOGGER.debug("Message: %s, kwargs: %s", message, kwargs) _LOGGER.debug("Target type: %s", type(kwargs.get(ATTR_TARGET))) @@ -268,7 +267,7 @@ async def async_send_message(self, message="", **kwargs): # ) if alexa.device_serial_number in targets and alexa.available: _LOGGER.debug( - ("%s: Announce by %s to " "targets: %s: %s"), + ("%s: Announce by %s to targets: %s: %s"), hide_email(account), alexa, list(map(hide_serial, targets)), @@ -322,7 +321,7 @@ async def async_send_message(self, message="", **kwargs): errormessage = ( f"{account}: Data value `type={data_type}` is not implemented. " f"See {NOTIFY_URL}" - ) + ) _LOGGER.debug(errormessage) raise vol.Invalid(errormessage) await asyncio.gather(*tasks) diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index cdfd195c..4b341772 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -7,16 +7,17 @@ https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import datetime +import json import logging -from typing import Callable, List, Optional, Text # noqa pylint: disable=unused-import - -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_TIMESTAMP, - STATE_UNAVAILABLE, - TEMP_CELSIUS, - __version__ as HA_VERSION, +from typing import Callable, Optional + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, ) +from homeassistant.const import UnitOfTemperature, __version__ as HA_VERSION +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time @@ -34,8 +35,14 @@ hide_email, hide_serial, ) -from .alexa_entity import parse_temperature_from_coordinator +from .alexa_entity import ( + parse_air_quality_from_coordinator, + parse_temperature_from_coordinator, +) from .const import ( + ALEXA_ICON_CONVERSION, + ALEXA_ICON_DEFAULT, + ALEXA_UNIT_CONVERSION, CONF_EXTENDED_ENTITY_DISCOVERY, RECURRING_DAY, RECURRING_PATTERN, @@ -49,9 +56,10 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + # pylint: disable=too-many-locals """Set up the Alexa sensor platform.""" - devices: List[AlexaMediaNotificationSensor] = [] - SENSOR_TYPES = { + devices: list[AlexaMediaNotificationSensor] = [] + SENSOR_TYPES = { # pylint: disable=invalid-name "Alarm": AlarmSensor, "Timer": TimerSensor, "Reminder": ReminderSensor, @@ -73,7 +81,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf raise ConfigEntryNotReady if key not in (account_dict["entities"]["sensor"]): (account_dict["entities"]["sensor"][key]) = {} - for (n_type, class_) in SENSOR_TYPES.items(): + for n_type, class_ in SENSOR_TYPES.items(): n_type_dict = ( account_dict["notifications"][key][n_type] if key in account_dict["notifications"] @@ -81,7 +89,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf else {} ) if ( - n_type in ("Alarm, Timer") + n_type in ("Alarm", "Timer") and "TIMERS_AND_ALARMS" in device["capabilities"] ): alexa_client = class_( @@ -124,9 +132,19 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf account_dict, temperature_entities ) + # AIAQM Sensors + air_quality_sensors = [] + air_quality_entities = account_dict.get("devices", {}).get("air_quality", []) + if air_quality_entities and account_dict["options"].get( + CONF_EXTENDED_ENTITY_DISCOVERY + ): + air_quality_sensors = await create_air_quality_sensors( + account_dict, air_quality_entities + ) + return await add_devices( hide_email(account), - devices + temperature_sensors, + devices + temperature_sensors + air_quality_sensors, add_devices_callback, include_filter, exclude_filter, @@ -153,6 +171,7 @@ async def async_unload_entry(hass, entry) -> bool: async def create_temperature_sensors(account_dict, temperature_entities): + """Create temperature sensors.""" devices = [] coordinator = account_dict["coordinator"] for temp in temperature_entities: @@ -170,14 +189,54 @@ async def create_temperature_sensors(account_dict, temperature_entities): return devices +async def create_air_quality_sensors(account_dict, air_quality_entities): + devices = [] + coordinator = account_dict["coordinator"] + + for temp in air_quality_entities: + _LOGGER.debug( + "Creating entity %s for a air quality sensor with name %s", + temp["id"], + temp["name"], + ) + # Each AIAQM has 5 different sensors. + for subsensor in temp["sensors"]: + sensor_type = subsensor["sensorType"] + instance = subsensor["instance"] + unit = subsensor["unit"] + serial = temp["device_serial"] + device_info = lookup_device_info(account_dict, serial) + sensor = AirQualitySensor( + coordinator, + temp["id"], + temp["name"], + device_info, + sensor_type, + instance, + unit, + ) + _LOGGER.debug("Create air quality sensors %s", sensor) + account_dict["entities"]["sensor"].setdefault(serial, {}) + account_dict["entities"]["sensor"][serial].setdefault(sensor_type, {}) + account_dict["entities"]["sensor"][serial][sensor_type][ + "Air_Quality" + ] = sensor + devices.append(sensor) + return devices + + def lookup_device_info(account_dict, device_serial): """Get the device to use for a given Echo based on a given device serial id. This may return nothing as there is no guarantee that a given temperature sensor is actually attached to an Echo. """ - for key, mp in account_dict["entities"]["media_player"].items(): - if key == device_serial and mp.device_info and "identifiers" in mp.device_info: - for ident in mp.device_info["identifiers"]: + for key, mediaplayer in account_dict["entities"]["media_player"].items(): + if ( + key == device_serial + and mediaplayer.device_info + and "identifiers" in mediaplayer.device_info + ): + for ident in mediaplayer.device_info["identifiers"]: return ident return None @@ -186,44 +245,86 @@ class TemperatureSensor(SensorEntity, CoordinatorEntity): """A temperature sensor reported by an Echo.""" def __init__(self, coordinator, entity_id, name, media_player_device_id): + """Initialize temperature sensor.""" super().__init__(coordinator) self.alexa_entity_id = entity_id - self._name = name - self._media_player_device_id = media_player_device_id - - @property - def name(self): - return self._name + " Temperature" - - @property - def device_info(self): - """Return the device_info of the device.""" - if self._media_player_device_id: - return { - "identifiers": {self._media_player_device_id}, - "via_device": self._media_player_device_id, + self._attr_name = name + " Temperature" + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_value: Optional[ + datetime.datetime + ] = parse_temperature_from_coordinator(coordinator, entity_id) + self._attr_native_unit_of_measurement: Optional[str] = UnitOfTemperature.CELSIUS + # This includes "_temperature" because the Alexa entityId is for a physical device + # A single physical device could have multiple HA entities + self._attr_unique_id = entity_id + "_temperature" + self._attr_device_info = ( + { + "identifiers": {media_player_device_id}, + "via_device": media_player_device_id, } - return None - - @property - def native_unit_of_measurement(self): - return TEMP_CELSIUS + if media_player_device_id + else None + ) - @property - def native_value(self): - return parse_temperature_from_coordinator( + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = parse_temperature_from_coordinator( self.coordinator, self.alexa_entity_id ) + super()._handle_coordinator_update() - @property - def device_class(self): - return "temperature" - @property - def unique_id(self): - # This includes "_temperature" because the Alexa entityId is for a physical device - # A single physical device could have multiple HA entities - return self.alexa_entity_id + "_temperature" +class AirQualitySensor(SensorEntity, CoordinatorEntity): + """A air quality sensor reported by an Amazon indoor air quality monitor.""" + + def __init__( + self, + coordinator, + entity_id, + name, + media_player_device_id, + sensor_name, + instance, + unit, + ): + super().__init__(coordinator) + self.alexa_entity_id = entity_id + self._sensor_name = sensor_name + # tidy up name + self._sensor_name = self._sensor_name.replace("Alexa.AirQuality.", "") + self._sensor_name = "".join( + " " + char if char.isupper() else char.strip() for char in self._sensor_name + ).strip() + self._attr_name = name + " " + self._sensor_name + self._attr_device_class = self._sensor_name + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_value: Optional[ + datetime.datetime + ] = parse_air_quality_from_coordinator(coordinator, entity_id, instance) + self._attr_native_unit_of_measurement: Optional[ + str + ] = ALEXA_UNIT_CONVERSION.get(unit) + self._attr_unique_id = entity_id + " " + self._sensor_name + self._attr_icon = ALEXA_ICON_CONVERSION.get(sensor_name, ALEXA_ICON_DEFAULT) + self._attr_device_info = ( + { + "identifiers": {media_player_device_id}, + "via_device": media_player_device_id, + } + if media_player_device_id + else None + ) + self._instance = instance + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = parse_air_quality_from_coordinator( + self.coordinator, self.alexa_entity_id, self._instance + ) + super()._handle_coordinator_update() class AlexaMediaNotificationSensor(SensorEntity): @@ -240,22 +341,29 @@ def __init__( ): """Initialize the Alexa sensor device.""" # Class info + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_state_class = None + self._attr_native_value: Optional[datetime.datetime] = None + self._attr_name = f"{client.name} {name}" + self._attr_unique_id = f"{client.unique_id}_{name}" + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(ALEXA_DOMAIN, client.unique_id)}, + "via_device": (ALEXA_DOMAIN, client.unique_id), + } + self._attr_assumed_state = client.assumed_state + self._attr_available = client.available self._client = client self._n_dict = n_dict self._sensor_property = sensor_property self._account = account - self._dev_id = client.unique_id - self._name = name - self._unit = None - self._device_class = DEVICE_CLASS_TIMESTAMP - self._icon = icon + self._type = "" if not self._type else self._type self._all = [] self._active = [] - self._next = None + self._next: Optional[dict] = None self._prior_value = None self._timestamp: Optional[datetime.datetime] = None self._tracker: Optional[Callable] = None - self._state: Optional[datetime.datetime] = None self._dismissed: Optional[datetime.datetime] = None self._status: Optional[str] = None self._amz_id: Optional[str] = None @@ -282,12 +390,12 @@ def _process_raw_notifications(self): ) if alarm_just_dismissed(alarm, self._status, self._version): self._dismissed = dt.now().isoformat() - self._state = self._process_state(self._next) + self._attr_native_value = self._process_state(self._next) self._status = self._next.get("status", "OFF") if self._next else "OFF" self._version = self._next.get("version", "0") if self._next else None self._amz_id = self._next.get("id") if self._next else None - if self._state == STATE_UNAVAILABLE or self._next != self._prior_value: + if self._attr_native_value is None or self._next != self._prior_value: # cancel any event triggers if self._tracker: _LOGGER.debug( @@ -295,16 +403,16 @@ def _process_raw_notifications(self): self, ) self._tracker() - if self._state != STATE_UNAVAILABLE and self._status != "SNOOZED": + if self._attr_native_value is not None and self._status != "SNOOZED": _LOGGER.debug( "%s: Scheduling event in %s", self, - dt.as_utc(dt.parse_datetime(self._state)) - dt.utcnow(), + dt.as_utc(self._attr_native_value) - dt.utcnow(), ) self._tracker = async_track_point_in_utc_time( self.hass, self._trigger_event, - dt.as_utc(dt.parse_datetime(self._state)), + dt.as_utc(self._attr_native_value), ) def _trigger_event(self, time_date) -> None: @@ -331,7 +439,9 @@ def _fix_alarm_date_time(self, value): ): return value naive_time = dt.parse_datetime(value[1][self._sensor_property]) - timezone = pytz.timezone(self._client._timezone) + timezone = pytz.timezone( + self._client._timezone # pylint: disable=protected-access + ) if timezone and naive_time: value[1][self._sensor_property] = timezone.localize(naive_time) elif not naive_time: @@ -355,7 +465,7 @@ def _fix_alarm_date_time(self, value): self._client.name, value[1], naive_time, - self._client._timezone, + self._client._timezone, # pylint: disable=protected-access ) return value @@ -374,7 +484,9 @@ def _update_recurring_alarm(self, value): ) alarm_on = next_item["status"] == "ON" r_rule_data = next_item.get("rRuleData") - if r_rule_data: # the new recurrence pattern; https://github.com/custom-components/alexa_media_player/issues/1608 + if ( + r_rule_data + ): # the new recurrence pattern; https://github.com/custom-components/alexa_media_player/issues/1608 next_trigger_times = r_rule_data.get("nextTriggerTimes") weekdays = r_rule_data.get("byWeekDays") if next_trigger_times: @@ -451,32 +563,12 @@ def _handle_event(self, event): == self._client.device_serial_number ): _LOGGER.debug("Updating sensor %s", self) - self.async_schedule_update_ha_state(True) - - @property - def available(self): - """Return the availability of the sensor.""" - return self._client.available - - @property - def assumed_state(self): - """Return whether the state is an assumed_state.""" - return self._client.assumed_state + self.schedule_update_ha_state(True) @property def hidden(self): """Return whether the sensor should be hidden.""" - return self.state == STATE_UNAVAILABLE - - @property - def unique_id(self): - """Return the unique ID.""" - return f"{self._client.unique_id}_{self._name}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._client.name} {self._name}" + return self.state is None @property def should_poll(self): @@ -485,27 +577,8 @@ def should_poll(self): self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._account]["websocket"] ) - @property - def state(self) -> datetime.datetime: - """Return the state of the sensor.""" - return self._state - - def _process_state(self, value): - return ( - dt.as_local(value[self._sensor_property]).isoformat() - if value - else STATE_UNAVAILABLE - ) - - @property - def unit_of_measurement(self): - """Return the unit_of_measurement of the device.""" - return self._unit - - @property - def device_class(self): - """Return the device_class of the device.""" - return self._device_class + def _process_state(self, value) -> Optional[datetime.datetime]: + return dt.as_local(value[self._sensor_property]) if value else None async def async_update(self): """Update state.""" @@ -528,19 +601,6 @@ async def async_update(self): except NoEntitySpecifiedError: pass # we ignore this due to a harmless startup race condition - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(ALEXA_DOMAIN, self._dev_id)}, - "via_device": (ALEXA_DOMAIN, self._dev_id), - } - - @property - def icon(self): - """Return the icon of the sensor.""" - return self._icon - @property def recurrence(self): """Return the recurrence pattern of the sensor.""" @@ -553,8 +613,6 @@ def recurrence(self): @property def extra_state_attributes(self): """Return additional attributes.""" - import json - attr = { "recurrence": self.recurrence, "process_timestamp": dt.as_local(self._timestamp).isoformat(), @@ -599,22 +657,22 @@ def __init__(self, client, n_json, account): else "mdi:timer", ) - def _process_state(self, value): + def _process_state(self, value) -> Optional[datetime.datetime]: return ( dt.as_local( super()._round_time( self._timestamp + datetime.timedelta(milliseconds=value[self._sensor_property]) ) - ).isoformat() + ) if value and self._timestamp - else STATE_UNAVAILABLE + else None ) @property def paused(self) -> Optional[bool]: """Return the paused state of the sensor.""" - return self._next["status"] == "PAUSED" if self._next else None + return self._next.get("status") == "PAUSED" if self._next else None @property def icon(self): @@ -624,7 +682,7 @@ def icon(self): if (version.parse(HA_VERSION) >= version.parse("0.113.0")) else "mdi:timer-off" ) - return self._icon if not self.paused else off_icon + return self._attr_icon if not self.paused else off_icon class ReminderSensor(AlexaMediaNotificationSensor): @@ -638,7 +696,7 @@ def __init__(self, client, n_json, account): client, n_json, "alarmTime", account, f"next {self._type}", "mdi:reminder" ) - def _process_state(self, value): + def _process_state(self, value) -> Optional[datetime.datetime]: return ( dt.as_local( super()._round_time( @@ -646,15 +704,15 @@ def _process_state(self, value): value[self._sensor_property] / 1000, tz=LOCAL_TIMEZONE ) ) - ).isoformat() + ) if value - else STATE_UNAVAILABLE + else None ) @property def reminder(self): """Return the reminder of the sensor.""" - return self._next["reminderLabel"] if self._next else None + return self._next.get("reminderLabel") if self._next else None @property def extra_state_attributes(self): diff --git a/custom_components/alexa_media/services.py b/custom_components/alexa_media/services.py index 418b09ca..3f85e723 100644 --- a/custom_components/alexa_media/services.py +++ b/custom_components/alexa_media/services.py @@ -8,7 +8,7 @@ """ import logging -from typing import Callable, Dict, Text +from typing import Callable from alexapy import AlexaAPI, AlexapyLoginError, hide_email from alexapy.errors import AlexapyConnectionError @@ -49,10 +49,10 @@ class AlexaMediaServices: """Class that holds our services that should be published to hass.""" - def __init__(self, hass, functions: Dict[Text, Callable]): + def __init__(self, hass, functions: dict[str, Callable]): """Initialize with self.hass.""" self.hass = hass - self.functions: Dict[Text, Callable] = functions + self.functions: dict[str, Callable] = functions async def register(self): """Register services to hass.""" @@ -158,8 +158,8 @@ async def force_logout(self, call) -> bool: async def last_call_handler(self, call): """Handle last call service request. - Args: - call.ATTR_EMAIL: List of case-sensitive Alexa email addresses. If None + Args + call: List of case-sensitive Alexa email addresses. If None all accounts are updated. """ diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index 5a2e95a3..ba62d7cd 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -7,7 +7,7 @@ https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import logging -from typing import List, Text # noqa pylint: disable=unused-import +from typing import List from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -36,7 +36,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa switch platform.""" devices = [] # type: List[DNDSwitch] - SWITCH_TYPES = [ + SWITCH_TYPES = [ # pylint: disable=invalid-name ("dnd", DNDSwitch), ("shuffle", ShuffleSwitch), ("repeat", RepeatSwitch), @@ -62,7 +62,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"][ key ] = {} - for (switch_key, class_) in SWITCH_TYPES: + for switch_key, class_ in SWITCH_TYPES: if ( switch_key == "dnd" and not account_dict["devices"]["switch"].get(key, {}).get("dnd") @@ -139,8 +139,8 @@ class AlexaMediaSwitch(SwitchDevice, AlexaMedia): def __init__( self, client, - switch_property: Text, - switch_function: Text, + switch_property: str, + switch_function: str, name="Alexa", ): """Initialize the Alexa Switch device.""" @@ -188,6 +188,7 @@ def _handle_event(self, event): @_catch_login_errors async def _set_switch(self, state, **kwargs): + # pylint: disable=unused-argument try: if not self.enabled: return @@ -290,7 +291,7 @@ def icon(self): """Return the icon of the switch.""" return self._icon() - def _icon(self, on=None, off=None): + def _icon(self, on=None, off=None): # pylint: disable=invalid-name return on if self.is_on else off diff --git a/custom_components/alexa_media/translations/de.json b/custom_components/alexa_media/translations/de.json index bdaf9246..88ada4cc 100644 --- a/custom_components/alexa_media/translations/de.json +++ b/custom_components/alexa_media/translations/de.json @@ -6,12 +6,12 @@ "reauth_successful": "Alexa Media Player erfolgreich authentifiziert" }, "error": { - "2fa_key_invalid": "Invalid Built-In 2FA key", + "2fa_key_invalid": "Ungültiger 2-Faktor Schlüssel", "connection_error": "Verbindungsfehler; Netzwerk prüfen und erneut versuchen", - "identifier_exists": "Diese Email ist bereits registriert", + "identifier_exists": "Diese E-Mail-Adresse ist bereits registriert", "invalid_credentials": "Falsche Zugangsdaten", "invalid_url": "URL ist ungültig: {message}", - "unable_to_connect_hass_url": "Es kann keine Verbindung zur Home Assistant-URL hergestellt werden. Bitte überprüfen Sie die externe URL unter Konfiguration - > Allgemein", + "unable_to_connect_hass_url": "Es kann keine Verbindung zur Home Assistant-URL hergestellt werden. Bitte überprüfen Sie die externe URL unter Konfiguration -> Allgemein", "unknown_error": "Unbekannter Fehler, bitte Log-Info melden" }, "step": { @@ -20,21 +20,21 @@ "proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning." }, "description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your **HA server** can reach {hass_url}. This field is from the External Url under Configuration -> General but you can try your internal url.\n\nIf you are **certain** your client can reach this url, you can bypass this warning.", - "title": "Alexa Media Player - Unable to Connect to HA URL" + "title": "Alexa Media Player - Keine Verbindung zur Home Assistant-URL möglich" }, "totp_register": { "data": { "registered": "OTP from the Built-in 2FA App Key confirmed successfully." }, "description": "**{email} - alexa.{url}** \nHave you successfully confirmed an OTP from the Built-in 2FA App Key with Amazon? \n >OTP Code {message}", - "title": "Alexa Media Player - OTP Confirmation" + "title": "Alexa Media Player - OTP Bestätigung" }, "user": { "data": { - "debug": "Erweitertes debugging", - "email": "Email Adresse", + "debug": "Erweitertes Debugging", + "email": "E-Mail Adresse", "exclude_devices": "Ausgeschlossene Geräte (komma getrennnt)", - "hass_url": "Url to access Home Assistant", + "hass_url": "Home Assistant-URL", "include_devices": "Eingebundene Geräte (komma getrennnt)", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Passwort", From 861ef1a488b74feff9befd1156ac9756b15272f0 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:39:06 +0200 Subject: [PATCH 3/8] Thermal comfort 2.1.1 --- custom_components/thermal_comfort/__init__.py | 26 + .../thermal_comfort/config_flow.py | 12 +- .../thermal_comfort/manifest.json | 8 +- custom_components/thermal_comfort/sensor.py | 667 +++++++++++++----- .../thermal_comfort/translations/ca.json | 121 ++++ .../thermal_comfort/translations/cs.json | 39 + .../thermal_comfort/translations/da.json | 121 ++++ .../thermal_comfort/translations/de.json | 76 ++ .../thermal_comfort/translations/el.json | 121 ++++ .../thermal_comfort/translations/en.json | 76 ++ .../thermal_comfort/translations/es.json | 80 ++- .../thermal_comfort/translations/fr.json | 39 + .../thermal_comfort/translations/hu.json | 39 + .../thermal_comfort/translations/it.json | 121 ++++ .../thermal_comfort/translations/nb.json | 18 + .../thermal_comfort/translations/nl.json | 121 ++++ .../thermal_comfort/translations/pl.json | 121 ++++ .../thermal_comfort/translations/pt-BR.json | 39 + .../thermal_comfort/translations/pt.json | 39 + .../thermal_comfort/translations/ro.json | 121 ++++ .../thermal_comfort/translations/ru.json | 121 ++++ .../thermal_comfort/translations/sk.json | 121 ++++ .../thermal_comfort/translations/sv.json | 39 + .../thermal_comfort/translations/uk.json | 39 + 24 files changed, 2138 insertions(+), 187 deletions(-) create mode 100644 custom_components/thermal_comfort/translations/ca.json create mode 100644 custom_components/thermal_comfort/translations/cs.json create mode 100644 custom_components/thermal_comfort/translations/da.json create mode 100644 custom_components/thermal_comfort/translations/el.json create mode 100644 custom_components/thermal_comfort/translations/fr.json create mode 100644 custom_components/thermal_comfort/translations/hu.json create mode 100644 custom_components/thermal_comfort/translations/it.json create mode 100644 custom_components/thermal_comfort/translations/nb.json create mode 100644 custom_components/thermal_comfort/translations/nl.json create mode 100644 custom_components/thermal_comfort/translations/pl.json create mode 100644 custom_components/thermal_comfort/translations/pt-BR.json create mode 100644 custom_components/thermal_comfort/translations/pt.json create mode 100644 custom_components/thermal_comfort/translations/ro.json create mode 100644 custom_components/thermal_comfort/translations/ru.json create mode 100644 custom_components/thermal_comfort/translations/sk.json create mode 100644 custom_components/thermal_comfort/translations/sv.json create mode 100644 custom_components/thermal_comfort/translations/uk.json diff --git a/custom_components/thermal_comfort/__init__.py b/custom_components/thermal_comfort/__init__.py index 2eec209e..26a95569 100644 --- a/custom_components/thermal_comfort/__init__.py +++ b/custom_components/thermal_comfort/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration @@ -28,6 +29,8 @@ CONF_POLL, CONF_SCAN_INTERVAL, CONF_TEMPERATURE_SENSOR, + LegacySensorType, + SensorType, ) _LOGGER = logging.getLogger(__name__) @@ -79,6 +82,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + + def update_unique_id(entry: RegistryEntry): + """Update unique_id of changed sensor names""" + if LegacySensorType.THERMAL_PERCEPTION in entry.unique_id: + return {"new_unique_id": entry.unique_id.replace(LegacySensorType.THERMAL_PERCEPTION, SensorType.DEW_POINT_PERCEPTION)} + if LegacySensorType.SIMMER_INDEX in entry.unique_id: + return {"new_unique_id": entry.unique_id.replace(LegacySensorType.SIMMER_INDEX, SensorType.SUMMER_SIMMER_INDEX)} + if LegacySensorType.SIMMER_ZONE in entry.unique_id: + return {"new_unique_id": entry.unique_id.replace(LegacySensorType.SIMMER_ZONE, SensorType.SUMMER_SIMMER_PERCEPTION)} + + await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + config_entry.version = 2 + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the thermal_comfort integration.""" if DOMAIN in config: diff --git a/custom_components/thermal_comfort/config_flow.py b/custom_components/thermal_comfort/config_flow.py index 0dc0f0f5..d8456d1b 100644 --- a/custom_components/thermal_comfort/config_flow.py +++ b/custom_components/thermal_comfort/config_flow.py @@ -282,9 +282,7 @@ def filter_useless_units(state: State) -> bool: def filter_thermal_comfort_ids(entity_id: str) -> bool: """Filter out device_ids containing our SensorType.""" - return all( - sensor_type.to_shortform() not in entity_id for sensor_type in SensorType - ) + return all(sensor_type not in entity_id for sensor_type in SensorType) filters_for_additional_sensors: list[callable] = [ filter_useless_device_class, @@ -424,7 +422,7 @@ def build_schema( default=list(SensorType), ): cv.multi_select( { - sensor_type: sensor_type.to_title() + sensor_type: sensor_type.to_name() for sensor_type in SensorType } ), @@ -464,6 +462,8 @@ def check_input(hass: HomeAssistant, user_input: dict) -> dict: class ThermalComfortConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Configuration flow for setting up new thermal_comfort entry.""" + VERSION = 2 + @staticmethod @callback def async_get_options_flow(config_entry): @@ -485,7 +485,9 @@ async def async_step_user(self, user_input=None): if t_sensor is not None and p_sensor is not None: unique_id = f"{t_sensor.unique_id}-{p_sensor.unique_id}" - await self.async_set_unique_id(unique_id) + entry = await self.async_set_unique_id(unique_id) + if entry is not None: + _LOGGER.debug(f"An entry with the unique_id {unique_id} already exists: {entry.data}") self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/custom_components/thermal_comfort/manifest.json b/custom_components/thermal_comfort/manifest.json index 58506699..2b4c7f58 100644 --- a/custom_components/thermal_comfort/manifest.json +++ b/custom_components/thermal_comfort/manifest.json @@ -1,10 +1,10 @@ { "domain": "thermal_comfort", "name": "Thermal Comfort", - "version": "1.5.5", - "documentation": "https://github.com/dolezsa/thermal_comfort/blob/master/README.md", - "issue_tracker": "https://github.com/dolezsa/thermal_comfort/issues", "codeowners": ["@dolezsa"], + "config_flow": true, + "documentation": "https://github.com/dolezsa/thermal_comfort/blob/master/README.md", "iot_class": "calculated", - "config_flow": true + "issue_tracker": "https://github.com/dolezsa/thermal_comfort/issues", + "version": "2.1.1" } diff --git a/custom_components/thermal_comfort/sensor.py b/custom_components/thermal_comfort/sensor.py index 1db821b6..c3bc2cb0 100644 --- a/custom_components/thermal_comfort/sensor.py +++ b/custom_components/thermal_comfort/sensor.py @@ -10,7 +10,7 @@ from homeassistant import util from homeassistant.backports.enum import StrEnum from homeassistant.components.sensor import ( - ENTITY_ID_FORMAT, + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, @@ -29,13 +29,13 @@ CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_state_change_event, @@ -50,8 +50,15 @@ _LOGGER = logging.getLogger(__name__) +ATTR_DEW_POINT = "dew_point" ATTR_HUMIDITY = "humidity" -ATTR_FROST_RISK_LEVEL = "frost_risk_level" +ATTR_HUMIDEX = "humidex" +ATTR_FROST_POINT = "frost_point" +ATTR_RELATIVE_STRAIN_INDEX = "relative_strain_index" +ATTR_SUMMER_SCHARLAU_INDEX = "summer_scharlau_index" +ATTR_WINTER_SCHARLAU_INDEX = "winter_scharlau_index" +ATTR_SUMMER_SIMMER_INDEX = "summer_simmer_index" +ATTR_THOMS_DISCOMFORT_INDEX = "thoms_discomfort_index" CONF_ENABLED_SENSORS = "enabled_sensors" CONF_SENSOR_TYPES = "sensor_types" CONF_CUSTOM_ICONS = "custom_icons" @@ -63,17 +70,15 @@ # Default values POLL_DEFAULT = False SCAN_INTERVAL_DEFAULT = 30 +DISPLAY_PRECISION = 2 -class ThermalComfortDeviceClass(StrEnum): - """State class for thermal comfort sensors.""" - - FROST_RISK = "thermal_comfort__frost_risk" - SIMMER_ZONE = "thermal_comfort__simmer_zone" - THERMAL_PERCEPTION = "thermal_comfort__thermal_perception" +class LegacySensorType(StrEnum): + THERMAL_PERCEPTION = "thermal_perception" + SIMMER_INDEX = "simmer_index" + SIMMER_ZONE = "simmer_zone" -# Deprecate shortform in 2.0 class SensorType(StrEnum): """Sensor type enum.""" @@ -82,20 +87,20 @@ class SensorType(StrEnum): FROST_POINT = "frost_point" FROST_RISK = "frost_risk" HEAT_INDEX = "heat_index" - SIMMER_INDEX = "simmer_index" - SIMMER_ZONE = "simmer_zone" - THERMAL_PERCEPTION = "thermal_perception" - - def to_title(self) -> str: + HUMIDEX = "humidex" + HUMIDEX_PERCEPTION = "humidex_perception" + MOIST_AIR_ENTHALPY = "moist_air_enthalpy" + RELATIVE_STRAIN_PERCEPTION = "relative_strain_perception" + SUMMER_SCHARLAU_PERCEPTION = "summer_scharlau_perception" + WINTER_SCHARLAU_PERCEPTION = "winter_scharlau_perception" + SUMMER_SIMMER_INDEX = "summer_simmer_index" + SUMMER_SIMMER_PERCEPTION = "summer_simmer_perception" + DEW_POINT_PERCEPTION = "dew_point_perception" + THOMS_DISCOMFORT_PERCEPTION = "thoms_discomfort_perception" + + def to_name(self) -> str: """Return the title of the sensor type.""" - return self.value.replace("_", " ").title() - - def to_shortform(self) -> str: - """Return the shortform of the sensor type.""" - if self.value == "thermal_perception": - return "perception" - else: - return self.value.replace("_", "") + return self.value.replace("_", " ").capitalize() @classmethod def from_string(cls, string: str) -> "SensorType": @@ -103,79 +108,228 @@ def from_string(cls, string: str) -> "SensorType": if string in list(cls): return cls(string) else: - _LOGGER.warning( - "Sensor type shortform and legacy YAML will be removed in 2.0. You should update to the new yaml format: https://github.com/dolezsa/thermal_comfort/blob/master/documentation/yaml.md" + raise ValueError( + f"Unknown sensor type: {string}. Please check https://github.com/dolezsa/thermal_comfort/blob/master/documentation/yaml.md#sensor-options for valid options." ) - if string == "absolutehumidity": - return cls.ABSOLUTE_HUMIDITY - elif string == "dewpoint": - return cls.DEW_POINT - elif string == "frostpoint": - return cls.FROST_POINT - elif string == "frostrisk": - return cls.FROST_RISK - elif string == "heatindex": - return cls.HEAT_INDEX - elif string == "simmerindex": - return cls.SIMMER_INDEX - elif string == "simmerzone": - return cls.SIMMER_ZONE - elif string == "perception": - return cls.THERMAL_PERCEPTION - else: - raise ValueError(f"Unknown sensor type: {string}") +class DewPointPerception(StrEnum): + """Thermal Perception.""" + + DRY = "dry" + VERY_COMFORTABLE = "very_comfortable" + COMFORTABLE = "comfortable" + OK_BUT_HUMID = "ok_but_humid" + SOMEWHAT_UNCOMFORTABLE = "somewhat_uncomfortable" + QUITE_UNCOMFORTABLE = "quite_uncomfortable" + EXTREMELY_UNCOMFORTABLE = "extremely_uncomfortable" + SEVERELY_HIGH = "severely_high" + + +class FrostRisk(StrEnum): + """Frost Risk.""" + + NONE = "no_risk" + LOW = "unlikely" + MEDIUM = "probable" + HIGH = "high" + + +class SummerSimmerPerception(StrEnum): + """Simmer Zone.""" + + COOL = "cool" + SLIGHTLY_COOL = "slightly_cool" + COMFORTABLE = "comfortable" + SLIGHTLY_WARM = "slightly_warm" + INCREASING_DISCOMFORT = "increasing_discomfort" + EXTREMELY_WARM = "extremely_warm" + DANGER_OF_HEATSTROKE = "danger_of_heatstroke" + EXTREME_DANGER_OF_HEATSTROKE = "extreme_danger_of_heatstroke" + CIRCULATORY_COLLAPSE_IMMINENT = "circulatory_collapse_imminent" + + +class RelativeStrainPerception(StrEnum): + """Relative Strain Perception.""" + + OUTSIDE_CALCULABLE_RANGE = "outside_calculable_range" + COMFORTABLE = "comfortable" + SLIGHT_DISCOMFORT = "slight_discomfort" + DISCOMFORT = "discomfort" + SIGNIFICANT_DISCOMFORT = "significant_discomfort" + EXTREME_DISCOMFORT = "extreme_discomfort" + + +class ScharlauPerception(StrEnum): + """Scharlau Winter and Summer Index Perception.""" + + OUTSIDE_CALCULABLE_RANGE = "outside_calculable_range" + COMFORTABLE = "comfortable" + SLIGHTLY_UNCOMFORTABLE = "slightly_uncomfortable" + MODERATLY_UNCOMFORTABLE = "moderatly_uncomfortable" + HIGHLY_UNCOMFORTABLE = "highly_uncomfortable" + + +class HumidexPerception(StrEnum): + """Humidex Perception.""" + + COMFORTABLE = "comfortable" + NOTICABLE_DISCOMFORT = "noticable_discomfort" + EVIDENT_DISCOMFORT = "evident_discomfort" + GREAT_DISCOMFORT = "great_discomfort" + DANGEROUS_DISCOMFORT = "dangerous_discomfort" + HEAT_STROKE = "heat_stroke" + + +class ThomsDiscomfortPerception(StrEnum): + """Thoms Discomfort Perception.""" + + NO_DISCOMFORT = "no_discomfort" + LESS_THEN_HALF = "less_then_half" + MORE_THEN_HALF = "more_then_half" + MOST = "most" + EVERYONE = "everyone" + DANGEROUS = "dangerous" + + +TC_ICONS = { + SensorType.DEW_POINT: "tc:dew-point", + SensorType.FROST_POINT: "tc:frost-point", + SensorType.HUMIDEX_PERCEPTION: "tc:thermal-perception", + SensorType.RELATIVE_STRAIN_PERCEPTION: "tc:thermal-perception", + SensorType.SUMMER_SCHARLAU_PERCEPTION: "tc:thermal-perception", + SensorType.WINTER_SCHARLAU_PERCEPTION: "tc:thermal-perception", + SensorType.SUMMER_SIMMER_PERCEPTION: "tc:thermal-perception", + SensorType.DEW_POINT_PERCEPTION: "tc:thermal-perception", + SensorType.THOMS_DISCOMFORT_PERCEPTION: "tc:thermal-perception", +} + SENSOR_TYPES = { SensorType.ABSOLUTE_HUMIDITY: { "key": SensorType.ABSOLUTE_HUMIDITY, - "device_class": SensorDeviceClass.HUMIDITY, + "name": SensorType.ABSOLUTE_HUMIDITY.to_name(), + "suggested_display_precision": DISPLAY_PRECISION, "native_unit_of_measurement": "g/m³", "state_class": SensorStateClass.MEASUREMENT, "icon": "mdi:water", }, SensorType.DEW_POINT: { "key": SensorType.DEW_POINT, + "name": SensorType.DEW_POINT.to_name(), "device_class": SensorDeviceClass.TEMPERATURE, - "native_unit_of_measurement": TEMP_CELSIUS, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": UnitOfTemperature.CELSIUS, "state_class": SensorStateClass.MEASUREMENT, - "icon": "tc:dew-point", + "icon": "mdi:thermometer-water", }, SensorType.FROST_POINT: { "key": SensorType.FROST_POINT, + "name": SensorType.FROST_POINT.to_name(), "device_class": SensorDeviceClass.TEMPERATURE, - "native_unit_of_measurement": TEMP_CELSIUS, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": UnitOfTemperature.CELSIUS, "state_class": SensorStateClass.MEASUREMENT, - "icon": "tc:frost-point", + "icon": "mdi:snowflake-thermometer", }, SensorType.FROST_RISK: { "key": SensorType.FROST_RISK, - "device_class": ThermalComfortDeviceClass.FROST_RISK, + "name": SensorType.FROST_RISK.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, FrostRisk)), + "translation_key": SensorType.FROST_RISK, "icon": "mdi:snowflake-alert", }, SensorType.HEAT_INDEX: { "key": SensorType.HEAT_INDEX, + "name": SensorType.HEAT_INDEX.to_name(), "device_class": SensorDeviceClass.TEMPERATURE, - "native_unit_of_measurement": TEMP_CELSIUS, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": UnitOfTemperature.CELSIUS, "state_class": SensorStateClass.MEASUREMENT, - "icon": "tc:heat-index", + "icon": "mdi:sun-thermometer", }, - SensorType.SIMMER_INDEX: { - "key": SensorType.SIMMER_INDEX, + SensorType.HUMIDEX: { + "key": SensorType.HUMIDEX, + "name": SensorType.HUMIDEX.to_name(), "device_class": SensorDeviceClass.TEMPERATURE, - "native_unit_of_measurement": TEMP_CELSIUS, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "mdi:sun-thermometer", + }, + SensorType.HUMIDEX_PERCEPTION: { + "key": SensorType.HUMIDEX_PERCEPTION, + "name": SensorType.HUMIDEX_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, HumidexPerception)), + "translation_key": SensorType.HUMIDEX_PERCEPTION, + "icon": "mdi:sun-thermometer", + }, + SensorType.MOIST_AIR_ENTHALPY: { + "key": SensorType.MOIST_AIR_ENTHALPY, + "name": SensorType.MOIST_AIR_ENTHALPY.to_name(), + "translation_key": SensorType.MOIST_AIR_ENTHALPY, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": "kJ/kg", "state_class": SensorStateClass.MEASUREMENT, - "icon": "tc:simmer-index", + "icon": "mdi:water-circle", }, - SensorType.SIMMER_ZONE: { - "key": SensorType.SIMMER_ZONE, - "device_class": ThermalComfortDeviceClass.SIMMER_ZONE, - "icon": "tc:simmer-zone", + SensorType.RELATIVE_STRAIN_PERCEPTION: { + "key": SensorType.RELATIVE_STRAIN_PERCEPTION, + "name": SensorType.RELATIVE_STRAIN_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, RelativeStrainPerception)), + "translation_key": SensorType.RELATIVE_STRAIN_PERCEPTION, + "icon": "mdi:sun-thermometer", }, - SensorType.THERMAL_PERCEPTION: { - "key": SensorType.THERMAL_PERCEPTION, - "device_class": ThermalComfortDeviceClass.THERMAL_PERCEPTION, - "icon": "tc:thermal-perception", + SensorType.SUMMER_SCHARLAU_PERCEPTION: { + "key": SensorType.SUMMER_SCHARLAU_PERCEPTION, + "name": SensorType.SUMMER_SCHARLAU_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, ScharlauPerception)), + "translation_key": "scharlau_perception", + "icon": "mdi:sun-thermometer", + }, + SensorType.WINTER_SCHARLAU_PERCEPTION: { + "key": SensorType.WINTER_SCHARLAU_PERCEPTION, + "name": SensorType.WINTER_SCHARLAU_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, ScharlauPerception)), + "translation_key": "scharlau_perception", + "icon": "mdi:snowflake-thermometer", + }, + SensorType.SUMMER_SIMMER_INDEX: { + "key": SensorType.SUMMER_SIMMER_INDEX, + "name": SensorType.SUMMER_SIMMER_INDEX.to_name(), + "device_class": SensorDeviceClass.TEMPERATURE, + "suggested_display_precision": DISPLAY_PRECISION, + "native_unit_of_measurement": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "mdi:sun-thermometer", + }, + SensorType.SUMMER_SIMMER_PERCEPTION: { + "key": SensorType.SUMMER_SIMMER_PERCEPTION, + "name": SensorType.SUMMER_SIMMER_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, SummerSimmerPerception)), + "translation_key": SensorType.SUMMER_SIMMER_PERCEPTION, + "icon": "mdi:sun-thermometer", + }, + SensorType.DEW_POINT_PERCEPTION: { + "key": SensorType.DEW_POINT_PERCEPTION, + "name": SensorType.DEW_POINT_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, DewPointPerception)), + "translation_key": SensorType.DEW_POINT_PERCEPTION, + "icon": "mdi:sun-thermometer", + }, + SensorType.THOMS_DISCOMFORT_PERCEPTION: { + "key": SensorType.THOMS_DISCOMFORT_PERCEPTION, + "name": SensorType.THOMS_DISCOMFORT_PERCEPTION.to_name(), + "device_class": SensorDeviceClass.ENUM, + "options": list(map(str, ThomsDiscomfortPerception)), + "translation_key": SensorType.THOMS_DISCOMFORT_PERCEPTION, + "icon": "mdi:sun-thermometer", }, } @@ -198,7 +352,7 @@ def from_string(cls, string: str) -> "SensorType": vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_UNIQUE_ID): cv.string, } ) @@ -215,42 +369,6 @@ def from_string(cls, string: str) -> "SensorType": ).extend(PLATFORM_OPTIONS_SCHEMA.schema) -class ThermalPerception(StrEnum): - """Thermal Perception.""" - - DRY = "dry" - VERY_COMFORTABLE = "very_comfortable" - COMFORTABLE = "comfortable" - OK_BUT_HUMID = "ok_but_humid" - SOMEWHAT_UNCOMFORTABLE = "somewhat_uncomfortable" - QUITE_UNCOMFORTABLE = "quite_uncomfortable" - EXTREMELY_UNCOMFORTABLE = "extremely_uncomfortable" - SEVERELY_HIGH = "severely_high" - - -class FrostRisk(StrEnum): - """Frost Risk.""" - - NONE = "no_risk" - LOW = "unlikely" - MEDIUM = "probable" - HIGH = "high" - - -class SimmerZone(StrEnum): - """Simmer Zone.""" - - COOL = "cool" - SLIGHTLY_COOL = "slightly_cool" - COMFORTABLE = "comfortable" - SLIGHTLY_WARM = "slightly_warm" - INCREASING_DISCOMFORT = "increasing_discomfort" - EXTREMELY_WARM = "extremely_warm" - DANGER_OF_HEATSTROKE = "danger_of_heatstroke" - EXTREME_DANGER_OF_HEATSTROKE = "extreme_danger_of_heatstroke" - CIRCULATORY_COLLAPSE_IMMINENT = "circulatory_collapse_imminent" - - def compute_once_lock(sensor_type): """Only compute if sensor_type needs update, return just the value otherwise.""" @@ -272,7 +390,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the Thermal Comfort sensors.""" if discovery_info is None: _LOGGER.warning( - "Legacy YAML configuration support will be removed in 2.0. You should update to the new yaml format: https://github.com/dolezsa/thermal_comfort/blob/master/documentation/yaml.md" + "Legacy YAML configuration is unsupported in 2.0. You should update to the new yaml format: https://github.com/dolezsa/thermal_comfort/blob/master/documentation/yaml.md" ) devices = [ dict(device_config, **{CONF_NAME: device_name}) @@ -308,8 +426,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template=device_config.get(CONF_ICON_TEMPLATE), entity_picture_template=device_config.get(CONF_ENTITY_PICTURE_TEMPLATE), sensor_type=SensorType.from_string(sensor_type), - friendly_name=device_config.get(CONF_FRIENDLY_NAME), custom_icons=device_config.get(CONF_CUSTOM_ICONS, False), + is_config_entry=False, ) for sensor_type in device_config.get( CONF_SENSOR_TYPES, DEFAULT_SENSOR_TYPES @@ -387,37 +505,39 @@ def __init__( entity_description: SensorEntityDescription, icon_template: Template = None, entity_picture_template: Template = None, - friendly_name: str = None, custom_icons: bool = False, + is_config_entry: bool = True, ) -> None: """Initialize the sensor.""" self._device = device - # TODO deprecate shortform in 2.0 self._sensor_type = sensor_type self.entity_description = entity_description - if friendly_name is None: - self.entity_description.name = ( - f"{self._device.name} {self._sensor_type.to_title()}" - ) - else: + self.entity_description.has_entity_name = is_config_entry + if not is_config_entry: self.entity_description.name = ( - f"{friendly_name} {self._sensor_type.to_title()}" + f"{self._device.name} {self.entity_description.name}" ) - # TODO deprecate shortform in 2.0 - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, - f"{self._device.name}_{self._sensor_type.to_shortform()}", - hass=self._device.hass, - ) - if not custom_icons: - if "tc" in self.entity_description.icon: - self._attr_icon = None + if sensor_type in [SensorType.DEW_POINT_PERCEPTION, SensorType.SUMMER_SIMMER_INDEX, SensorType.SUMMER_SIMMER_PERCEPTION]: + registry = entity_registry.async_get(self._device.hass) + if sensor_type is SensorType.DEW_POINT_PERCEPTION: + unique_id = id_generator(self._device.unique_id, LegacySensorType.THERMAL_PERCEPTION) + entity_id = registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id) + elif sensor_type is SensorType.SUMMER_SIMMER_INDEX: + unique_id = id_generator(self._device.unique_id, LegacySensorType.SIMMER_INDEX) + entity_id = registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id) + elif sensor_type is SensorType.SUMMER_SIMMER_PERCEPTION: + unique_id = id_generator(self._device.unique_id, LegacySensorType.SIMMER_ZONE) + entity_id = registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id) + if entity_id is not None: + registry.async_update_entity(entity_id, new_unique_id=id_generator(self._device.unique_id, sensor_type)) + if custom_icons: + if self.entity_description.key in TC_ICONS: + self.entity_description.icon = TC_ICONS[self.entity_description.key] self._icon_template = icon_template self._entity_picture_template = entity_picture_template self._attr_native_value = None self._attr_extra_state_attributes = {} - if self._device.unique_id is not None: - self._attr_unique_id = id_generator(self._device.unique_id, sensor_type) + self._attr_unique_id = id_generator(self._device.unique_id, sensor_type) self._attr_should_poll = False @property @@ -448,9 +568,26 @@ async def async_update(self): if value is None: # can happen during startup return - if self._sensor_type == SensorType.FROST_RISK: - self._attr_extra_state_attributes[ATTR_FROST_RISK_LEVEL] = value - self._attr_native_value = list(FrostRisk)[value] + if type(value) == tuple and len(value) == 2: + if self._sensor_type == SensorType.HUMIDEX_PERCEPTION: + self._attr_extra_state_attributes[ATTR_HUMIDEX] = value[1] + elif self._sensor_type == SensorType.DEW_POINT_PERCEPTION: + self._attr_extra_state_attributes[ATTR_DEW_POINT] = value[1] + elif self._sensor_type == SensorType.FROST_RISK: + self._attr_extra_state_attributes[ATTR_FROST_POINT] = value[1] + elif self._sensor_type == SensorType.RELATIVE_STRAIN_PERCEPTION: + self._attr_extra_state_attributes[ATTR_RELATIVE_STRAIN_INDEX] = value[1] + elif self._sensor_type == SensorType.SUMMER_SCHARLAU_PERCEPTION: + self._attr_extra_state_attributes[ATTR_SUMMER_SCHARLAU_INDEX] = value[1] + elif self._sensor_type == SensorType.WINTER_SCHARLAU_PERCEPTION: + self._attr_extra_state_attributes[ATTR_WINTER_SCHARLAU_INDEX] = value[1] + elif self._sensor_type == SensorType.SUMMER_SIMMER_PERCEPTION: + self._attr_extra_state_attributes[ATTR_SUMMER_SIMMER_INDEX] = value[1] + elif self._sensor_type == SensorType.THOMS_DISCOMFORT_PERCEPTION: + self._attr_extra_state_attributes[ATTR_THOMS_DISCOMFORT_INDEX] = value[ + 1 + ] + self._attr_native_value = value[0] else: self._attr_native_value = value @@ -566,14 +703,17 @@ async def temperature_state_listener(self, event): async def _new_temperature_state(self, state): if _is_valid_state(state): - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + hass = self.hass + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, hass.config.units.temperature_unit) temp = util.convert(state.state, float) - self.extra_state_attributes[ATTR_TEMPERATURE] = temp # convert to celsius if necessary - if unit == TEMP_FAHRENHEIT: - temp = TemperatureConverter.convert(temp, TEMP_FAHRENHEIT, TEMP_CELSIUS) - self._temperature = temp - await self.async_update() + temperature = TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS) + if -89.2 <= temperature <= 56.7: + self.extra_state_attributes[ATTR_TEMPERATURE] = temp + self._temperature = temperature + await self.async_update() + else: + _LOGGER.info(f"Temperature has an invalid value: {state}. Can't calculate new states.") async def humidity_state_listener(self, event): """Handle humidity device state changes.""" @@ -581,9 +721,13 @@ async def humidity_state_listener(self, event): async def _new_humidity_state(self, state): if _is_valid_state(state): - self._humidity = float(state.state) - self.extra_state_attributes[ATTR_HUMIDITY] = self._humidity - await self.async_update() + humidity = float(state.state) + if 0 < humidity <= 100: + self._humidity = float(state.state) + self.extra_state_attributes[ATTR_HUMIDITY] = self._humidity + await self.async_update() + else: + _LOGGER.info(f"Relative humidity has an invalid value: {state}. Can't calculate new states.") @compute_once_lock(SensorType.DEW_POINT) async def dew_point(self) -> float: @@ -597,13 +741,13 @@ async def dew_point(self) -> float: VP = pow(10, SUM - 3) * self._humidity Td = math.log(VP / 0.61078) Td = (241.88 * Td) / (17.558 - Td) - return round(Td, 2) + return Td @compute_once_lock(SensorType.HEAT_INDEX) async def heat_index(self) -> float: """Heat Index .""" fahrenheit = TemperatureConverter.convert( - self._temperature, TEMP_CELSIUS, TEMP_FAHRENHEIT + self._temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ) hi = 0.5 * ( fahrenheit + 61.0 + ((fahrenheit - 68.0) * 1.2) + (self._humidity * 0.094) @@ -626,28 +770,57 @@ async def heat_index(self) -> float: elif self._humidity > 85 and fahrenheit >= 80 and fahrenheit <= 87: hi = hi + ((self._humidity - 85) * 0.1) * ((87 - fahrenheit) * 0.2) - return round(TemperatureConverter.convert(hi, TEMP_FAHRENHEIT, TEMP_CELSIUS), 2) + return TemperatureConverter.convert(hi, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) + + @compute_once_lock(SensorType.HUMIDEX) + async def humidex(self) -> int: + """.""" + dewpoint = await self.dew_point() + e = 6.11 * math.exp(5417.7530 * ((1 / 273.16) - (1 / (dewpoint + 273.15)))) + h = (0.5555) * (e - 10.0) + return self._temperature + h + + @compute_once_lock(SensorType.HUMIDEX_PERCEPTION) + async def humidex_perception(self) -> (HumidexPerception, float): + """.""" + humidex = await self.humidex() + if humidex > 54: + perception = HumidexPerception.HEAT_STROKE + elif humidex >= 45: + perception = HumidexPerception.DANGEROUS_DISCOMFORT + elif humidex >= 40: + perception = HumidexPerception.GREAT_DISCOMFORT + elif humidex >= 35: + perception = HumidexPerception.EVIDENT_DISCOMFORT + elif humidex >= 30: + perception = HumidexPerception.NOTICABLE_DISCOMFORT + else: + perception = HumidexPerception.COMFORTABLE - @compute_once_lock(SensorType.THERMAL_PERCEPTION) - async def thermal_perception(self) -> ThermalPerception: + return perception, humidex + + @compute_once_lock(SensorType.DEW_POINT_PERCEPTION) + async def dew_point_perception(self) -> (DewPointPerception, float): """Dew Point .""" dewpoint = await self.dew_point() if dewpoint < 10: - return ThermalPerception.DRY + perception = DewPointPerception.DRY elif dewpoint < 13: - return ThermalPerception.VERY_COMFORTABLE + perception = DewPointPerception.VERY_COMFORTABLE elif dewpoint < 16: - return ThermalPerception.COMFORTABLE + perception = DewPointPerception.COMFORTABLE elif dewpoint < 18: - return ThermalPerception.OK_BUT_HUMID + perception = DewPointPerception.OK_BUT_HUMID elif dewpoint < 21: - return ThermalPerception.SOMEWHAT_UNCOMFORTABLE + perception = DewPointPerception.SOMEWHAT_UNCOMFORTABLE elif dewpoint < 24: - return ThermalPerception.QUITE_UNCOMFORTABLE + perception = DewPointPerception.QUITE_UNCOMFORTABLE elif dewpoint < 26: - return ThermalPerception.EXTREMELY_UNCOMFORTABLE + perception = DewPointPerception.EXTREMELY_UNCOMFORTABLE else: - return ThermalPerception.SEVERELY_HIGH + perception = DewPointPerception.SEVERELY_HIGH + + return perception, dewpoint @compute_once_lock(SensorType.ABSOLUTE_HUMIDITY) async def absolute_humidity(self) -> float: @@ -660,7 +833,7 @@ async def absolute_humidity(self) -> float: abs_humidity *= self._humidity abs_humidity *= 2.1674 abs_humidity /= abs_temperature - return round(abs_humidity, 2) + return abs_humidity @compute_once_lock(SensorType.FROST_POINT) async def frost_point(self) -> float: @@ -668,36 +841,95 @@ async def frost_point(self) -> float: dewpoint = await self.dew_point() T = self._temperature + 273.15 Td = dewpoint + 273.15 - return round( - (Td + (2671.02 / ((2954.61 / T) + 2.193665 * math.log(T) - 13.3448)) - T) - - 273.15, - 2, - ) + return (Td + (2671.02 / ((2954.61 / T) + 2.193665 * math.log(T) - 13.3448)) - T) - 273.15 @compute_once_lock(SensorType.FROST_RISK) - async def frost_risk(self) -> int: + async def frost_risk(self) -> (FrostRisk, float): """Frost Risk Level.""" thresholdAbsHumidity = 2.8 absolutehumidity = await self.absolute_humidity() frostpoint = await self.frost_point() if self._temperature <= 1 and frostpoint <= 0: if absolutehumidity <= thresholdAbsHumidity: - return 1 # Frost unlikely despite the temperature + frost_risk = FrostRisk.LOW # Frost unlikely despite the temperature else: - return 3 # high probability of frost + frost_risk = FrostRisk.HIGH # high probability of frost elif ( self._temperature <= 4 and frostpoint <= 0.5 and absolutehumidity > thresholdAbsHumidity ): - return 2 # Frost probable despite the temperature - return 0 # No risk of frost + frost_risk = FrostRisk.MEDIUM # Frost probable despite the temperature + else: + frost_risk = FrostRisk.NONE # No risk of frost + + return frost_risk, frostpoint + + @compute_once_lock(SensorType.RELATIVE_STRAIN_PERCEPTION) + async def relative_strain_perception(self) -> (RelativeStrainPerception, float): + """Relative strain perception.""" + + vp = 6.112 * pow(10, 7.5 * self._temperature / (237.7 + self._temperature)) + e = self._humidity * vp / 100 + rsi = round((self._temperature - 21) / (58 - e), 2) + + if self._temperature < 26 or self._temperature > 35: + perception = RelativeStrainPerception.OUTSIDE_CALCULABLE_RANGE + elif rsi >= 0.45: + perception = RelativeStrainPerception.EXTREME_DISCOMFORT + elif rsi >= 0.35: + perception = RelativeStrainPerception.SIGNIFICANT_DISCOMFORT + elif rsi >= 0.25: + perception = RelativeStrainPerception.DISCOMFORT + elif rsi >= 0.15: + perception = RelativeStrainPerception.SLIGHT_DISCOMFORT + else: + perception = RelativeStrainPerception.COMFORTABLE + + return perception, rsi + + @compute_once_lock(SensorType.SUMMER_SCHARLAU_PERCEPTION) + async def summer_scharlau_perception(self) -> (ScharlauPerception, float): + """.""" + tc = -17.089 * math.log(self._humidity) + 94.979 + ise = tc - self._temperature + + if self._temperature < 17 or self._temperature > 39 or self._humidity < 30: + perception = ScharlauPerception.OUTSIDE_CALCULABLE_RANGE + elif ise <= -3: + perception = ScharlauPerception.HIGHLY_UNCOMFORTABLE + elif ise <= -1: + perception = ScharlauPerception.MODERATLY_UNCOMFORTABLE + elif ise < 0: + perception = ScharlauPerception.SLIGHTLY_UNCOMFORTABLE + else: + perception = ScharlauPerception.COMFORTABLE + + return perception, round(ise, 2) + + @compute_once_lock(SensorType.WINTER_SCHARLAU_PERCEPTION) + async def winter_scharlau_perception(self) -> (ScharlauPerception, float): + """.""" + tc = (0.0003 * self._humidity) + (0.1497 * self._humidity) - 7.7133 + ish = self._temperature - tc + if self._temperature < -5 or self._temperature > 6 or self._humidity < 40: + perception = ScharlauPerception.OUTSIDE_CALCULABLE_RANGE + elif ish <= -3: + perception = ScharlauPerception.HIGHLY_UNCOMFORTABLE + elif ish <= -1: + perception = ScharlauPerception.MODERATLY_UNCOMFORTABLE + elif ish < 0: + perception = ScharlauPerception.SLIGHTLY_UNCOMFORTABLE + else: + perception = ScharlauPerception.COMFORTABLE + + return perception, round(ish, 2) - @compute_once_lock(SensorType.SIMMER_INDEX) - async def simmer_index(self) -> float: + @compute_once_lock(SensorType.SUMMER_SIMMER_INDEX) + async def summer_simmer_index(self) -> float: """.""" fahrenheit = TemperatureConverter.convert( - self._temperature, TEMP_CELSIUS, TEMP_FAHRENHEIT + self._temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ) si = ( @@ -706,33 +938,116 @@ async def simmer_index(self) -> float: - 56.83 ) - if fahrenheit < 70: + if fahrenheit < 58: # Summer Simmer Index is only valid above 58°F si = fahrenheit - return round(TemperatureConverter.convert(si, TEMP_FAHRENHEIT, TEMP_CELSIUS), 2) + return TemperatureConverter.convert(si, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) - @compute_once_lock(SensorType.SIMMER_ZONE) - async def simmer_zone(self) -> SimmerZone: + @compute_once_lock(SensorType.SUMMER_SIMMER_PERCEPTION) + async def summer_simmer_perception(self) -> (SummerSimmerPerception, float): """.""" - si = await self.simmer_index() + si = await self.summer_simmer_index() if si < 21.1: - return SimmerZone.COOL + summer_simmer_perception = SummerSimmerPerception.COOL elif si < 25.0: - return SimmerZone.SLIGHTLY_COOL + summer_simmer_perception = SummerSimmerPerception.SLIGHTLY_COOL elif si < 28.3: - return SimmerZone.COMFORTABLE + summer_simmer_perception = SummerSimmerPerception.COMFORTABLE elif si < 32.8: - return SimmerZone.SLIGHTLY_WARM + summer_simmer_perception = SummerSimmerPerception.SLIGHTLY_WARM elif si < 37.8: - return SimmerZone.INCREASING_DISCOMFORT + summer_simmer_perception = SummerSimmerPerception.INCREASING_DISCOMFORT elif si < 44.4: - return SimmerZone.EXTREMELY_WARM + summer_simmer_perception = SummerSimmerPerception.EXTREMELY_WARM elif si < 51.7: - return SimmerZone.DANGER_OF_HEATSTROKE + summer_simmer_perception = SummerSimmerPerception.DANGER_OF_HEATSTROKE elif si < 65.6: - return SimmerZone.EXTREME_DANGER_OF_HEATSTROKE + summer_simmer_perception = SummerSimmerPerception.EXTREME_DANGER_OF_HEATSTROKE else: - return SimmerZone.CIRCULATORY_COLLAPSE_IMMINENT + summer_simmer_perception = SummerSimmerPerception.CIRCULATORY_COLLAPSE_IMMINENT + + return summer_simmer_perception, si + + @compute_once_lock(SensorType.MOIST_AIR_ENTHALPY) + async def moist_air_enthalpy(self) -> float: + """Calculate the enthalpy of moist air.""" + patm = 101325 + c_to_k = 273.15 + h_fg = 2501000 + cp_vapour = 1805.0 + + # calculate vapour pressure + ta_k = self._temperature + c_to_k + c1 = -5674.5359 + c2 = 6.3925247 + c3 = -0.9677843 * math.pow(10, -2) + c4 = 0.62215701 * math.pow(10, -6) + c5 = 0.20747825 * math.pow(10, -8) + c6 = -0.9484024 * math.pow(10, -12) + c7 = 4.1635019 + c8 = -5800.2206 + c9 = 1.3914993 + c10 = -0.048640239 + c11 = 0.41764768 * math.pow(10, -4) + c12 = -0.14452093 * math.pow(10, -7) + c13 = 6.5459673 + + if ta_k < c_to_k: + pascals = math.exp( + c1 / ta_k + + c2 + + ta_k * (c3 + ta_k * (c4 + ta_k * (c5 + c6 * ta_k))) + + c7 * math.log(ta_k) + ) + else: + pascals = math.exp( + c8 / ta_k + + c9 + + ta_k * (c10 + ta_k * (c11 + ta_k * c12)) + + c13 * math.log(ta_k) + ) + + # calculate humidity ratio + p_saturation = pascals + p_vap = self._humidity / 100 * p_saturation + hr = 0.62198 * p_vap / (patm - p_vap) + + # calculate enthalpy + cp_air = 1004 + h_dry_air = cp_air * self._temperature + h_sat_vap = h_fg + cp_vapour * self._temperature + h = h_dry_air + hr * h_sat_vap + + return h / 1000 + + @compute_once_lock(SensorType.THOMS_DISCOMFORT_PERCEPTION) + async def thoms_discomfort_perception(self) -> (ThomsDiscomfortPerception, float): + """Calculate Thom's discomfort index and perception.""" + tw = ( + self._temperature + * math.atan(0.151977 * pow(self._humidity + 8.313659, 1 / 2)) + + math.atan(self._temperature + self._humidity) + - math.atan(self._humidity - 1.676331) + + pow(0.00391838 * self._humidity, 3 / 2) + * math.atan(0.023101 * self._humidity) + - 4.686035 + ) + tdi = 0.5 * tw + 0.5 * self._temperature + + if tdi >= 32: + perception = ThomsDiscomfortPerception.DANGEROUS + elif tdi >= 29: + perception = ThomsDiscomfortPerception.EVERYONE + elif tdi >= 27: + perception = ThomsDiscomfortPerception.MOST + elif tdi >= 24: + perception = ThomsDiscomfortPerception.MORE_THEN_HALF + elif tdi >= 21: + perception = ThomsDiscomfortPerception.LESS_THEN_HALF + else: + perception = ThomsDiscomfortPerception.NO_DISCOMFORT + + return perception, round(tdi, 2) async def async_update(self): """Update the state.""" diff --git a/custom_components/thermal_comfort/translations/ca.json b/custom_components/thermal_comfort/translations/ca.json new file mode 100644 index 00000000..23c5b4a5 --- /dev/null +++ b/custom_components/thermal_comfort/translations/ca.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "No s'ha trobat el sensor de temperatura", + "humidity_not_found": "No s'ha trobat el sensor d'humitat" + }, + "step": { + "init": { + "title": "Configuració de Thermal Comfort", + "data": { + "temperature_sensor": "Sensor de temperatura", + "humidity_sensor": "Sensor d'humitat", + "poll": "Activar la consulta recurrent de les dades", + "scan_interval": "Interval de consulta (segons)", + "custom_icons": "Utilitzar el paquet d'icones personalitzat" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Aquesta combinació de sensors de temperatura i humitat ja està configurada.", + "no_sensors": "No s'han trobat sensors de temperatura o humitat. Torna-ho a provar en mode avançat.", + "no_sensors_advanced": "No s'han trobat sensors de temperatura o humitat." + }, + "error": { + "temperature_not_found": "No s'ha trobat el sensor de temperatura", + "humidity_not_found": "No s'ha trobat el sensor d'humitat" + }, + "step": { + "user": { + "title": "Configuració de Thermal Comfort", + "data": { + "name": "Nom", + "temperature_sensor": "Sensor de temperatura", + "humidity_sensor": "Sensor d'humitat", + "poll": "Activar la consulta recurrent de les dades", + "scan_interval": "Interval de consulta (segons)", + "custom_icons": "Utilitzar el paquet d'icones personalitzat", + "enabled_sensors": "Sensors activats" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Sense risc", + "unlikely": "Poc probable", + "probable": "Probable", + "high": "Alta probabilitat" + } + }, + "dew_point_perception": { + "state": { + "dry": "Una mica sec per a alguns", + "very_comfortable": "Molt còmode", + "comfortable": "Còmode", + "ok_but_humid": "Bé per a la majoria, però humit", + "somewhat_uncomfortable": "Una mica incòmode", + "quite_uncomfortable": "Molt humit, bastant incòmode", + "extremely_uncomfortable": "Extremadament incòmode, aclaparador", + "severely_high": "Molt alt, fins i tot mortal per a persones amb malalties relacionades amb l'asma" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Còmode", + "noticable_discomfort": "Una mica incòmode", + "evident_discomfort": "Bastant incòmode", + "great_discomfort": "Molt incòmode, evitar esforços", + "dangerous_discomfort": "Malestar perillós", + "heat_stroke": "Possible cop de calor" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Fora de l'interval calculable", + "comfortable": "Còmode", + "slight_discomfort": "Una mica incòmode", + "discomfort": "Incòmode", + "significant_discomfort": "Bastant incòmode", + "extreme_discomfort": "Molt incòmode" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Fred", + "slightly_cool": "Una mica fred", + "comfortable": "Còmode", + "slightly_warm": "Una mica càlid", + "increasing_discomfort": "Càlid i incòmode", + "extremely_warm": "Extremadament càlid", + "danger_of_heatstroke": "Perill de cop de calor", + "extreme_danger_of_heatstroke": "Perill extrem de cop de calor", + "circulatory_collapse_imminent": "Colapse circulatori imminent" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Fora de l'interval calculable", + "comfortable": "Còmode", + "slightly_uncomfortable": "Una mica incòmode", + "moderatly_uncomfortable": "Bastant incòmode", + "highly_uncomfortable": "Molt incòmode" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "No és incòmode", + "less_then_half": "Menys de la meitat de la població sent malestar", + "more_then_half": "Més de la meitat de la població sent malestar", + "most": "La majoria de les persones senten malestar i deteriorament de les condicions psicofísiques", + "everyone": "Tothom sent un malestar important", + "dangerous": "Perill, malestar molt fort que pot provocar cops de calor" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/cs.json b/custom_components/thermal_comfort/translations/cs.json new file mode 100644 index 00000000..dda960f9 --- /dev/null +++ b/custom_components/thermal_comfort/translations/cs.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Žádné riziko", + "unlikely": "Nepravděpodobné", + "probable": "Pravděpodobné", + "high": "Vysoce pravděpodobné" + } + }, + "dew_point_perception": { + "state": { + "dry": "Pro někoho sucho", + "very_comfortable": "Velmi přijemně", + "comfortable": "Příjemně", + "ok_but_humid": "OK pro většinu, ale vlhko", + "somewhat_uncomfortable": "Poněkud nepříjemně", + "quite_uncomfortable": "Velmi vlhko, docela nepříjemně", + "extremely_uncomfortable": "Extrémně nepříjemně, tísnivě", + "severely_high": "Velmi vysoká vlhkost, dokonce smrtelná pro jedince s nemocemi související s astmatem" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Chladno", + "slightly_cool": "Mírně chladno", + "comfortable": "Příjemně", + "slightly_warm": "Mírně teplo", + "increasing_discomfort": "Narůstající nepohodlí", + "extremely_warm": "Extrémně teplo", + "danger_of_heatstroke": "Nebezpečí úpalu", + "extreme_danger_of_heatstroke": "Extrémní nebezpečí úpalu", + "circulatory_collapse_imminent": "Hrozící kolaps krevního oběhu" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/da.json b/custom_components/thermal_comfort/translations/da.json new file mode 100644 index 00000000..176ac93e --- /dev/null +++ b/custom_components/thermal_comfort/translations/da.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Temperatursensor ikke fundet", + "humidity_not_found": "Fugtighedssensor ikke fundet" + }, + "step": { + "init": { + "title": "Termiske komfortindstillinger", + "data": { + "temperature_sensor": "Temperatursensor", + "humidity_sensor": "Fugtighedssensor", + "poll": "Aktiver polling", + "scan_interval": "Poll interval (sekunder)", + "custom_icons": "Brug tilpasset ikonpakke" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Denne kombination af temperatur og fugtighedssensorer er allerede konfigureret", + "no_sensors": "Ingen temperatur eller fugtighedssensorer fundet. Prøv igen i avanceret tilstand.", + "no_sensors_advanced": "Ingen temperatur eller fugtighedssensorer fundet." + }, + "error": { + "temperature_not_found": "Temperatursensor ikke fundet", + "humidity_not_found": "Fugtighedssensor ikke fundet" + }, + "step": { + "user": { + "title": "Termiske komfortindstillinger", + "data": { + "name": "Name", + "temperature_sensor": "Temperatursensor", + "humidity_sensor": "Fugtighedssensor", + "poll": "Aktiver polling", + "scan_interval": "Poll interval (sekunder)", + "custom_icons": "Brug tilpasset ikonpakke", + "enabled_sensors": "Aktiverede sensorer" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Ingen risiko", + "unlikely": "Usandsynlig", + "probable": "Sandsynlig", + "high": "Høj sandsynlighed" + } + }, + "dew_point_perception": { + "state": { + "dry": "Lidt tørt for nogle", + "very_comfortable": "Meget behagelig", + "comfortable": "Komfortabel", + "ok_but_humid": "OK for de fleste, men fugtigt", + "somewhat_uncomfortable": "Noget ubehageligt", + "quite_uncomfortable": "Meget fugtigt, ret ubehageligt", + "extremely_uncomfortable": "Ekstremt ubehageligt, undertrykkende", + "severely_high": "Alvorligt høj, endda dødelig for astmarelaterede sygdomme" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Komfortabel", + "noticable_discomfort": "Mærkbart ubehag", + "evident_discomfort": "Tydeligt ubehag", + "great_discomfort": "Stort ubehag, undgå anstrengelse", + "dangerous_discomfort": "Farligt ubehag", + "heat_stroke": "Hedeslag muligt" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Uden for det beregnelige område", + "comfortable": "Komfortabel", + "slight_discomfort": "Let ubehag", + "discomfort": "Ubehag", + "significant_discomfort": "Betydeligt ubehag", + "extreme_discomfort": "Ekstremt ubehag" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Køligt", + "slightly_cool": "Lidt køligt", + "comfortable": "Komfortabel", + "slightly_warm": "Lidt varm", + "increasing_discomfort": "Stigende ubehag", + "extremely_warm": "Ekstremt varmt", + "danger_of_heatstroke": "Fare for hedeslag", + "extreme_danger_of_heatstroke": "Ekstrem fare for hedeslag", + "circulatory_collapse_imminent": "Kredsløbskollaps nært forestående" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Uden for det beregnelige område", + "comfortable": "Komfortabel", + "slightly_uncomfortable": "Lidt ubehageligt", + "moderatly_uncomfortable": "Moderat ubehageligt", + "highly_uncomfortable": "Meget ubehageligt" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Intet ubehag", + "less_then_half": "Mindre end halvdelen af befolkningen føler ubehag", + "more_then_half": "Mere end halvdelen af befolkningen føler ubehag", + "most": "De fleste individer føler ubehag og forværring af psykofysiske tilstande", + "everyone": "Alle føler betydeligt ubehag", + "dangerous": "Farligt, meget stærkt ubehag, som kan forårsage hedeslag" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/de.json b/custom_components/thermal_comfort/translations/de.json index f740f990..654653f8 100644 --- a/custom_components/thermal_comfort/translations/de.json +++ b/custom_components/thermal_comfort/translations/de.json @@ -41,5 +41,81 @@ } } } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Kein Risiko", + "unlikely": "Unwahrscheinlich", + "probable": "Wahrscheinlich", + "high": "Sehr wahrscheinlich" + } + }, + "dew_point_perception": { + "state": { + "dry": "Etwas trocken", + "very_comfortable": "Sehr angenehm", + "comfortable": "Angenehm", + "ok_but_humid": "Angenehm aber schwül", + "somewhat_uncomfortable": "Etwas unangenehm", + "quite_uncomfortable": "Unangenehm und sehr schwül", + "extremely_uncomfortable": "Äußerst unangenehm und drückend", + "severely_high":"Extrem hoch, tödlich für asthmabedingte Erkrankungen" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Angenehm", + "noticable_discomfort": "Spürbares Unbehagen", + "evident_discomfort": "Offensichtliches Unbehagen", + "great_discomfort": "Großes Unbehagen, Anstrengung vermeiden", + "dangerous_discomfort": "Gefährliches Unbehagen unangenehm", + "heat_stroke": "Hitzschlag möglich" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Außerhalb des berechenbaren Bereichs", + "comfortable": "Angenehm", + "slight_discomfort": "Etwas unbehaglich", + "discomfort": "Unbehaglich", + "significant_discomfort": "Erhebliches Unbehagen", + "extreme_discomfort": "Extremes Unbehagen" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Kühl", + "slightly_cool": "Etwas kühl", + "comfortable": "Angenehm", + "slightly_warm": "Etwas warm", + "increasing_discomfort": "Zunehmend unbehaglich", + "extremely_warm": "Äußerst warm", + "danger_of_heatstroke": "Hitzschlaggefahr", + "extreme_danger_of_heatstroke": "Extreme Hitzschlaggefahr", + "circulatory_collapse_imminent": "Drohender Kreislaufkollaps" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Außerhalb des berechenbaren Bereichs", + "comfortable": "Angenehm", + "slightly_uncomfortable": "Leicht unangenehm", + "moderatly_uncomfortable": "Unangenehm", + "highly_uncomfortable": "Sehr unangenehm" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Kein Unbehagen", + "less_then_half": "Weniger als die Hälfte der Bevölkerung fühlt sich unwohl", + "more_then_half": "Mehr als die Hälfte der Bevölkerung fühlt sich unwohl", + "most": "Die meisten Menschen fühlen sich unwohl und einen verschlechterten psychophysischen Zustand", + "everyone": "Jeder fühlt sich unwohl", + "dangerous": "Gefährlich, sehr starke Beschwerden die zu Hitzschlägen führen können" + } + } + } } } diff --git a/custom_components/thermal_comfort/translations/el.json b/custom_components/thermal_comfort/translations/el.json new file mode 100644 index 00000000..195e8544 --- /dev/null +++ b/custom_components/thermal_comfort/translations/el.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Δεν βρέθηκε αισθητήρας θερμοκρασίας", + "humidity_not_found": "Δεν βρέθηκε αισθητήρας υγρασίας" + }, + "step": { + "init": { + "title": "Ρυθμίσεις θερμικής άνεσης", + "data": { + "temperature_sensor": "Αισθητήρας θερμοκρασίας", + "humidity_sensor": "Αισθητήρας υγρασίας", + "poll": "Ενεργοποίηση ανανέωσης", + "scan_interval": "Ρυθμός ανανέωσης (δευτερόλεπτα)", + "custom_icons": "Χρήση προσαρμοσμένου πακέτου εικονιδίων" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Αυτός ο συνδιασμός αισθητήρων θερμοκρασίας και υγρασίας είναι ήδη διαμορφωμένος", + "no_sensors": "Δεν βρέθηκε κανένας αισθητήρας θερμοκρασίας ή υγρασίας. Δοκιμάστε πάλι σε προχωρημένη λειτουργία.", + "no_sensors_advanced": "Δεν βρέθηκε κανένας αισθητήρας θερμοκρασίας ή υγρασίας." + }, + "error": { + "temperature_not_found": "Δεν βρέθηκε αισθητήρας θερμοκρασίας", + "humidity_not_found": "Δεν βρέθηκε αισθητήρας υγρασίας" + }, + "step": { + "user": { + "title": "Ρυθμίσεις θερμικής άνεσης", + "data": { + "name": "Όνομα", + "temperature_sensor": "Αισθητήρας θερμοκρασίας", + "humidity_sensor": "Αισθητήρας υγρασίας", + "poll": "Ενεργοποίηση ανανέωσης", + "scan_interval": "Ρυθμός ανανέωσης (δευτερόλεπτα)", + "custom_icons": "Χρησιμοποίηση προσαρμοσμένης ομάδας εικονιδίων", + "enabled_sensors": "Ενεργοποιημένοι αισθητήρες" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Κανένας κίνδυνος", + "unlikely": "Απίθανο", + "probable": "Πιθανό", + "high": "Μεγάλη πιθανότητα" + } + }, + "dew_point_perception": { + "state": { + "dry": "Λίγο ξηρή για κάποιους", + "very_comfortable": "Πολύ άνετη", + "comfortable": "Άνετη", + "ok_but_humid": "OK για κάποιους , αλλά με υγρασία", + "somewhat_uncomfortable": "Κάπως άβολη", + "quite_uncomfortable": "Πολύ υγρό, αρκετά άβολη", + "extremely_uncomfortable": "Εξαιρετικά άβολα, καταπιεστική", + "severely_high": "Σοβαρά υψηλή, ακόμη και θανατηφόρα για ασθένειες που σχετίζονται με το άσθμα" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Άνετη", + "noticable_discomfort": "Αισθητή δυσφορία", + "evident_discomfort": "Εμφανής δυσφορία", + "great_discomfort": "Μεγάλη δυσφορία, αποφύγετε την άσκηση", + "dangerous_discomfort": "Επικίνδυνη δυσφορία", + "heat_stroke": "Πιθανή θερμοπληξία" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Εκτός του υπολογίσιμου εύρους", + "comfortable": "Άνετη", + "slight_discomfort": "Ελαφρά δυσφορία", + "discomfort": "Δυσφορία", + "significant_discomfort": "Σημαντική δυσφορία", + "extreme_discomfort": "Ακραία δυσφορία" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Δροσερή", + "slightly_cool": "Ελαφρώς δροσερή", + "comfortable": "Άνετη", + "slightly_warm": "Ελαφρώς ζεστή", + "increasing_discomfort": "Αυξανόμενη δυσφορία", + "extremely_warm": "Εξαιρετικά ζεστή", + "danger_of_heatstroke": "Κίνδυνος θερμοπληξίας", + "extreme_danger_of_heatstroke": "Ακραίος κίνδυνος θερμοπληξίας", + "circulatory_collapse_imminent": "Επικείμενη κυκλοφορική κατάρρευση" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Εκτός του υπολογίσιμου εύρους", + "comfortable": "Άνετη", + "slightly_uncomfortable": "Ελαφρώς άβολη", + "moderatly_uncomfortable": "Μέτρια άβολη", + "highly_uncomfortable": "Ιδιαίτερα άβολη" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Καμία ενόχληση", + "less_then_half": "Λιγότερο από το ήμισυ του πληθυσμού αισθάνεται δυσφορία", + "more_then_half": "Περισσότερο από το ήμισυ του πληθυσμού αισθάνεται δυσφορία", + "most": "Τα περισσότερα άτομα αισθάνονται δυσφορία και επιδείνωση των ψυχοφυσικών συνθηκών", + "everyone": "Όλοι αισθάνονται σημαντική δυσφορία", + "dangerous": "Επικίνδυνη, πολύ έντονη δυσφορία που μπορεί να προκαλέσει θερμοπληξία" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/en.json b/custom_components/thermal_comfort/translations/en.json index 7d116b6f..36efda6a 100644 --- a/custom_components/thermal_comfort/translations/en.json +++ b/custom_components/thermal_comfort/translations/en.json @@ -41,5 +41,81 @@ } } } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "No risk", + "unlikely": "Unlikely", + "probable": "Probable", + "high": "High probability" + } + }, + "dew_point_perception": { + "state": { + "dry": "A bit dry for some", + "very_comfortable": "Very comfortable", + "comfortable": "Comfortable", + "ok_but_humid": "OK for most, but humid", + "somewhat_uncomfortable": "Somewhat uncomfortable", + "quite_uncomfortable": "Very humid, quite uncomfortable", + "extremely_uncomfortable": "Extremely uncomfortable, oppressive", + "severely_high": "Severely high, even deadly for asthma related illnesses" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Comfortable", + "noticable_discomfort": "Noticeable discomfort", + "evident_discomfort": "Evident discomfort", + "great_discomfort": "Great discomfort, avoid exertion", + "dangerous_discomfort": "Dangerous discomfort", + "heat_stroke": "Heat stroke possible" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Outside of the calculable range", + "comfortable": "Comfortable", + "slight_discomfort": "Slight discomfort", + "discomfort": "Discomfort", + "significant_discomfort": "Significant discomfort", + "extreme_discomfort": "Extreme discomfort" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Cool", + "slightly_cool": "Slightly cool", + "comfortable": "Comfortable", + "slightly_warm": "Slightly warm", + "increasing_discomfort": "Increasing discomfort", + "extremely_warm": "Extremely warm", + "danger_of_heatstroke": "Danger of heatstroke", + "extreme_danger_of_heatstroke": "Extreme danger of heatstroke", + "circulatory_collapse_imminent": "Circulatory collapse imminent" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Outside of the calculable range", + "comfortable": "Comfortable", + "slightly_uncomfortable": "Slightly uncomfortable", + "moderatly_uncomfortable": "Moderatly uncomfortable", + "highly_uncomfortable": "Highly uncomfortable" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "No discomfort", + "less_then_half": "Less than half of the population feels discomfort", + "more_then_half": "More than half of the population feels discomfort", + "most": "Most individuals feel discomfort and deterioration of psychophysical conditions", + "everyone": "Everyone feels significant discomfort", + "dangerous": "Dangerous, very strong discomfort which may cause heat strokes" + } + } + } } } diff --git a/custom_components/thermal_comfort/translations/es.json b/custom_components/thermal_comfort/translations/es.json index 0d32b622..183b7e16 100644 --- a/custom_components/thermal_comfort/translations/es.json +++ b/custom_components/thermal_comfort/translations/es.json @@ -10,7 +10,7 @@ "data": { "temperature_sensor": "Sensor de temperatura", "humidity_sensor": "Sensor de humedad", - "poll": "Activar consulta de datos", + "poll": "Activar consulta recurrente de datos", "scan_interval": "Intervalo de consulta (segundos)", "custom_icons": "Usar paquete de iconos personalizado" } @@ -34,12 +34,88 @@ "name": "Nombre", "temperature_sensor": "Sensor de temperatura", "humidity_sensor": "Sensor de humedad", - "poll": "Activar consulta de datos", + "poll": "Activar consulta recurrente de datos", "scan_interval": "Intervalo de consulta (segundos)", "custom_icons": "Usar paquete de iconos personalizado", "enabled_sensors": "Sensores activados" } } } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Sin riesgo", + "unlikely": "Poco probable", + "probable": "Probable", + "high": "Alta probabilidad" + } + }, + "dew_point_perception": { + "state": { + "dry": "Un poco seco para algunos", + "very_comfortable": "Muy cómodo", + "comfortable": "Cómodo", + "ok_but_humid": "Bien para la mayoria, pero algo húmedo", + "somewhat_uncomfortable": "Algo incómodo", + "quite_uncomfortable": "Muy húmedo, bastante incómodo", + "extremely_uncomfortable": "Extremadamente incómodo, agobiante", + "severely_high": "Muy alto, incluso mortal para enfermedades relacionadas con el asma" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Cómodo", + "noticable_discomfort": "Un poco incómodo", + "evident_discomfort": "Bastante incómodo", + "great_discomfort": "Muy incómodo, evitar esfuerzos", + "dangerous_discomfort": "Incomodidad peligrosa", + "heat_stroke": "Posible golpe de calor" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Fuera del rango calculable", + "comfortable": "Cómodo", + "slight_discomfort": "Un poco incómodo", + "discomfort": "Incómodo", + "significant_discomfort": "Bastante incómodo", + "extreme_discomfort": "Muy incómodo" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Fresco", + "slightly_cool": "Ligeramente fresco", + "comfortable": "Cómodo", + "slightly_warm": "Ligeramente caluroso", + "increasing_discomfort": "Caluroso e incómodo", + "extremely_warm": "Extremadamente caluroso", + "danger_of_heatstroke": "Riesgo de golpe de calor", + "extreme_danger_of_heatstroke": "Riesgo extremo de golpe de calor", + "circulatory_collapse_imminent": "Colapso circulatorio inminente" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Fuera del rango calculable", + "comfortable": "Cómodo", + "slightly_uncomfortable": "Un poco incómodo", + "moderatly_uncomfortable": "Bastante incómodo", + "highly_uncomfortable": "Muy incómodo" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Sin molestias", + "less_then_half": "Menos de la mitad de la población siente malestar", + "more_then_half": "Más de la mitad de la población siente malestar", + "most": "La mayoría de personas sienten malestar y deterioro de las condiciones psicofísicas", + "everyone": "Todos sienten malestar significativo", + "dangerous": "Peligro, malestar muy fuerte que puede provocar golpes de calor" + } + } + } } } diff --git a/custom_components/thermal_comfort/translations/fr.json b/custom_components/thermal_comfort/translations/fr.json new file mode 100644 index 00000000..6dd02792 --- /dev/null +++ b/custom_components/thermal_comfort/translations/fr.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Aucun risque", + "unlikely": "Peu probable", + "probable": "Probable", + "high": "Haute probabilité" + } + }, + "dew_point_perception": { + "state": { + "dry": "Un peu sec pour certains", + "very_comfortable": "Très confortable", + "comfortable": "Confortable", + "ok_but_humid": "OK pour la plupart, mais humide", + "somewhat_uncomfortable": "Un peu inconfortable", + "quite_uncomfortable": "Très humide, assez inconfortable", + "extremely_uncomfortable": "Extrêmement inconfortable, oppressant", + "severely_high": "Gravement élevé, voire mortel pour les maladies liées à l'asthme" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Froid", + "slightly_cool": "Légèrement froid", + "comfortable": "Confortable", + "slightly_warm": "Légèrement chaud", + "increasing_discomfort": "Inconfortable", + "extremely_warm": "Extrêmement chaud", + "danger_of_heatstroke": "Danger de coup de chaleur", + "extreme_danger_of_heatstroke": "Danger extrême de coup de chaleur", + "circulatory_collapse_imminent": "Arrêt cardiaque imminent" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/hu.json b/custom_components/thermal_comfort/translations/hu.json new file mode 100644 index 00000000..bb387234 --- /dev/null +++ b/custom_components/thermal_comfort/translations/hu.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Nincs kockázat", + "unlikely": "Nem valószínű", + "probable": "Valószínű", + "high": "Nagy valószínűség" + } + }, + "dew_point_perception": { + "state": { + "dry": "Egyeseknek kissé száraz", + "very_comfortable": "Nagyon kellemes", + "comfortable": "Kellemes", + "ok_but_humid": "A többségnek megfelelő, de párás", + "somewhat_uncomfortable": "Kicsit kellemetlen", + "quite_uncomfortable": "Nagyon nedves, eléggé kellemetlen", + "extremely_uncomfortable": "Rendkívül kellemetlen, nyomasztó", + "severely_high": "Különösen magas, az asztmás betegségek számára életveszélyes" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Hideg", + "slightly_cool": "Enyhén hűvös", + "comfortable": "Kellemes", + "slightly_warm": "Enyhén meleg", + "increasing_discomfort": "Fokozódó diszkomfort", + "extremely_warm": "Rendkívül meleg", + "danger_of_heatstroke": "Napszúrásveszély", + "extreme_danger_of_heatstroke": "Rendkívüli napszúrásveszély", + "circulatory_collapse_imminent": "Keringési összeomlás veszélye" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/it.json b/custom_components/thermal_comfort/translations/it.json new file mode 100644 index 00000000..aa2b1238 --- /dev/null +++ b/custom_components/thermal_comfort/translations/it.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Sensore di temperatura non trovato", + "humidity_not_found": "Sensore di umidità non trovato" + }, + "step": { + "init": { + "title": "Impostazioni di comfort termico", + "data": { + "temperature_sensor": "Sensore di temperatura", + "humidity_sensor": "Sensore di umidità", + "poll": "Abilita polling", + "scan_interval": "Intervallo di polling (secondi)", + "custom_icons": "Usa il pacchetto di icone personalizzate" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Questa combinazione di sensori di temperatura e umidità è già configurata", + "no_sensors": "Nessun sensore di temperatura o umidità trovato. Riprova in modalità avanzata.", + "no_sensors_advanced": "Nessun sensore di temperatura o umidità trovato." + }, + "error": { + "temperature_not_found": "Sensore di temperatura non trovato", + "humidity_not_found": "Sensore di umidità non trovato" + }, + "step": { + "user": { + "title": "Impostazioni di comfort termico", + "data": { + "name": "Nome", + "temperature_sensor": "Sensore di temperatura", + "humidity_sensor": "Sensore di umidità", + "poll": "Abilita polling", + "scan_interval": "Intervallo di polling (secondi)", + "custom_icons": "Usa pacchetto icone personalizzato", + "enabled_sensors": "Sensori abilitati" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Nessun rischio", + "unlikely": "Improbabile", + "probable": "Probabile", + "high": "Alta probabilità" + } + }, + "dew_point_perception": { + "state": { + "dry": "Secco per qualcuno", + "very_comfortable": "Molto confortevole", + "comfortable": "Confortevole", + "ok_but_humid": "Confortevole ma umido", + "somewhat_uncomfortable": "Leggero disagio", + "quite_uncomfortable": "Significativo disagio, molto umido", + "extremely_uncomfortable": "Forte disagio, opprimente", + "severely_high": "Estremo disagio, rischioso per malattie correlate all'asma" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Confortevole", + "noticable_discomfort": "Disagio", + "evident_discomfort": "Disagio significativo", + "great_discomfort": "Disagio enorme, evitare affaticamenti", + "dangerous_discomfort": "Disagio pericoloso", + "heat_stroke": "Probabile colpo di calore" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Fuori scala", + "comfortable": "Confortevole", + "slight_discomfort": "Disagio leggero", + "discomfort": "Disagio", + "significant_discomfort": "Disagio significativo", + "extreme_discomfort": "Disagio estremo" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Freddo", + "slightly_cool": "Leggermente freddo", + "comfortable": "Confortevole", + "slightly_warm": "Leggermente caldo", + "increasing_discomfort": "Aumento del disagio", + "extremely_warm": "Estremamente caldo", + "danger_of_heatstroke": "Pericolo colpo di calore", + "extreme_danger_of_heatstroke": "Probabile colpo di calore", + "circulatory_collapse_imminent": "Imminente colasso circolatorio" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Fuori scala", + "comfortable": "Confortevole", + "slightly_uncomfortable": "Leggero disagio", + "moderatly_uncomfortable": "Moderato disagio", + "highly_uncomfortable": "Forte disagio" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Confortevole", + "less_then_half": "Meno della metà della popolazione avverte disagio", + "more_then_half": "Più della metà della popolazione avverte disagio", + "most": "La maggior parte delle persone avverte disagio e deterioramento delle condizioni psicofisiche", + "everyone": "Forte disagio per chiunque", + "dangerous": "Stato di emergenza medica, disagio molto forte che può causare colpi di calore" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/nb.json b/custom_components/thermal_comfort/translations/nb.json new file mode 100644 index 00000000..9500ea00 --- /dev/null +++ b/custom_components/thermal_comfort/translations/nb.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "dew_point_perception": { + "state": { + "dry": "A bit dry for some", + "very_comfortable": "Very comfortable", + "comfortable": "Comfortable", + "ok_but_humid": "OK for most, but humid", + "somewhat_uncomfortable": "Somewhat uncomfortable", + "quite_uncomfortable": "Very humid, quite uncomfortable", + "extremely_uncomfortable": "Extremely uncomfortable, oppressive", + "severely_high": "Severely high, even deadly for asthma related illnesses" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/nl.json b/custom_components/thermal_comfort/translations/nl.json new file mode 100644 index 00000000..f51f3f9b --- /dev/null +++ b/custom_components/thermal_comfort/translations/nl.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Temperatuursensor niet gevonden", + "humidity_not_found": "Vochtigheidsensor niet gevonden" + }, + "step": { + "init": { + "title": "Thermal comfort instellingen", + "data": { + "temperature_sensor": "Temperatuursensor", + "humidity_sensor": "vochtigheidsensor", + "poll": "Zet polling aan", + "scan_interval": "Poll interval (seconden)", + "custom_icons": "Gebruik custom icons pack" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Deze combinatie van temperatuur en vochtigheid sensors is reeds geconfigureerd", + "no_sensors": "Geen temperatuur of vochtigheid sensors gevonden. Probeer nog eens in geadvanceerd modus.", + "no_sensors_advanced": "Geen temperatuur of vochtigheid sensoren gevonden." + }, + "error": { + "temperature_not_found": "Temperatuursensor niet gevonden", + "humidity_not_found": "Vochtigheidsensor niet gevonden" + }, + "step": { + "user": { + "title": "Thermal comfort instellingen", + "data": { + "name": "Naam", + "temperature_sensor": "Temperatuursensor", + "humidity_sensor": "Vochtigheidsensor", + "poll": "Zet Polling aan", + "scan_interval": "Poll interval (seconden)", + "custom_icons": "Gebruik custom icons pack", + "enabled_sensors": "Ingeschakelde sensors" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Geen risico", + "unlikely": "Onwaarschijnlijk", + "probable": "Waarschijnlijk", + "high": "Hoogstwaarschijnlijk" + } + }, + "dew_point_perception": { + "state": { + "dry": "Beetje droog voor sommigen", + "very_comfortable": "Zeer gemakkelijk", + "comfortable": "Gemakkelijk", + "ok_but_humid": "OK voor meesten, wel vochtig", + "somewhat_uncomfortable": "Ietswat ongemakkelijk", + "quite_uncomfortable": "Zeer vochtig, best ongemakkelijk", + "extremely_uncomfortable": "Extreem ongemakkelijk, drukkend", + "severely_high": "Zeer hoog, zelfs gevaarlijk bij astma gerelateerde ziekten" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Gemakkelijk", + "noticable_discomfort": "Merkbaar ongemak", + "evident_discomfort": "Duidelijk ongemak", + "great_discomfort": "Groot ongemak, vermijd inspanning", + "dangerous_discomfort": "Gevaarlijk ongemak", + "heat_stroke": "Hitteberoerte mogelijk" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Buiten het berekenbare bereik", + "comfortable": "Gemakkelijk", + "slight_discomfort": "Licht ongemakkelijk", + "discomfort": "Ongemakkelijk", + "significant_discomfort": "Aanzienlijk ongemakkelijk", + "extreme_discomfort": "Extreem ongemakkelijk" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Koel", + "slightly_cool": "Enigszins koel", + "comfortable": "Gemakkelijk", + "slightly_warm": "Enigszins warm", + "increasing_discomfort": "Toenemend ongemakkelijk", + "extremely_warm": "Extreem warm", + "danger_of_heatstroke": "Gevaar voor een zonnesteek", + "extreme_danger_of_heatstroke": "Extreem gevaar voor een zonnesteek", + "circulatory_collapse_imminent": "Instorting van de bloedsomloop dreigt" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Buiten het berekenbare bereik", + "comfortable": "Gemakkelijk", + "slightly_uncomfortable": "Licht ongemakkelijk", + "moderatly_uncomfortable": "Matig ongemakkelijk", + "highly_uncomfortable": "Zeer ongemakkelijk" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Niet ongemakkelijk", + "less_then_half": "Minder, dan de helft van de populatie voelt ongemakkelijk", + "more_then_half": "Meer, dan de helft van de populatie voelt ongemakkelijk", + "most": "De meeste mensen voelen ongemak en verslechtering van psychofysische omstandigheden", + "everyone": "Iedereen ervaart behoorlijk ongemak", + "dangerous": "Gevaarlijk, zeer sterk ongemak dat hitteberoerte kan veroorzaken" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/pl.json b/custom_components/thermal_comfort/translations/pl.json new file mode 100644 index 00000000..70499910 --- /dev/null +++ b/custom_components/thermal_comfort/translations/pl.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Nie znaleziono czujnika temperatury", + "humidity_not_found": "Nie znaleziono czujnika wilgotności" + }, + "step": { + "init": { + "title": "Ustawienia komfortu cieplnego", + "data": { + "temperature_sensor": "Czujnik temperatury", + "humidity_sensor": "Czujnik wilgotności", + "poll": "Włącz odpytywanie", + "scan_interval": "Interwał odpytywania (sekundy)", + "custom_icons": "Użyj niestandardowego pakietu ikon" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Ta kombinacja czujników temperatury i wilgotności jest już skonfigurowana", + "no_sensors": "Nie znaleziono czujników temperatury ani wilgotności. Spróbuj ponownie w trybie zaawansowanym.", + "no_sensors_advanced": "Nie znaleziono czujników temperatury ani wilgotności." + }, + "error": { + "temperature_not_found": "Nie znaleziono czujnika temperatury", + "humidity_not_found": "Nie znaleziono czujnika wilgotności" + }, + "step": { + "user": { + "title": "Ustawienia komfortu cieplnego", + "data": { + "name": "Nazwa", + "temperature_sensor": "Czujnik temperatury", + "humidity_sensor": "Czujnik wilgotności", + "poll": "Włącz odpytywanie", + "scan_interval": "Interwał odpytywania (sekundy)", + "custom_icons": "Użyj niestandardowego pakietu ikon", + "enabled_sensors": "Włączone czujniki" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Brak", + "unlikely": "Małe", + "probable": "Możliwe", + "high": "Wysokie" + } + }, + "dew_point_perception": { + "state": { + "dry": "Dla niektórych może być sucho", + "very_comfortable": "Bardzo komfortowe", + "comfortable": "Komfortowe", + "ok_but_humid": "OK dla większości, ale wilgotno", + "somewhat_uncomfortable": "Trochę niekomfortowe", + "quite_uncomfortable": "Bardzo wilgotno, całkiem niekomfortowe", + "extremely_uncomfortable": "Bardzo niekomfortowe, uciążliwe", + "severely_high": "Wysoce niebezpieczne dla astmatyków, bardzo uciążliwe" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Komfortowo", + "noticable_discomfort": "Lekki dyskomfort", + "evident_discomfort": "Wyraźny dyskomfort", + "great_discomfort": "Duży dyskomfort, unikaj wysiłku", + "dangerous_discomfort": "Niebezpieczny dyskomfort", + "heat_stroke": "Możliwy udar cieplny" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Poza obliczalnym zakresem", + "comfortable": "Komfortowo", + "slight_discomfort": "Lekki dyskomfort", + "discomfort": "Dyskomfort", + "significant_discomfort": "Znaczący dyskomfort", + "extreme_discomfort": "Ekstremalny dyskomfort" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Zimno", + "slightly_cool": "Chłodnawo", + "comfortable": "Komfortowo", + "slightly_warm": "Ciepło", + "increasing_discomfort": "Trochę za ciepło", + "extremely_warm": "Bardzo ciepło", + "danger_of_heatstroke": "Możliwy udar cieplny", + "extreme_danger_of_heatstroke": "Wysokie niebezpieczeństwo udaru", + "circulatory_collapse_imminent": "Zdecydowane problemy z krążeniem, zapaść" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Poza obliczalnym zakresem", + "comfortable": "Komfortowo", + "slightly_uncomfortable": "Lekki dyskomfort", + "moderatly_uncomfortable": "Dyskomfort", + "highly_uncomfortable": "Wysoki dyskomfort" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Brak dyskomfortu", + "less_then_half": "Mniej niż połowa populacji odczuwa dyskomfort", + "more_then_half": "Ponad połowa populacji odczuwa dyskomfort", + "most": "Większość osób odczuwa dyskomfort i pogorszenie warunków psychofizycznych", + "everyone": "Wszyscy odczuwają znaczny dyskomfort", + "dangerous": "Niebezpieczny, bardzo silny dyskomfort, który może powodować udary cieplne" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/pt-BR.json b/custom_components/thermal_comfort/translations/pt-BR.json new file mode 100644 index 00000000..346835e7 --- /dev/null +++ b/custom_components/thermal_comfort/translations/pt-BR.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Sem risco", + "unlikely": "Improvável", + "probable": "Provável", + "high": "Muito provável" + } + }, + "dew_point_perception": { + "state": { + "dry": "Seco", + "very_comfortable": "Muito confortável", + "comfortable": "Confortável", + "ok_but_humid": "Confortável mas úmido", + "somewhat_uncomfortable": "Um pouco desconfortável", + "quite_uncomfortable": "Muito desconfortável", + "extremely_uncomfortable": "Extremamente desconfortável", + "severely_high": "Severamente úmido" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Frio", + "slightly_cool": "Um pouco frio", + "comfortable": "Confortável", + "slightly_warm": "Um pouco quente", + "increasing_discomfort": "Desconforto crescente", + "extremely_warm": "Muito quente", + "danger_of_heatstroke": "Perigo de insolação", + "extreme_danger_of_heatstroke": "Perigo extremo de insolação", + "circulatory_collapse_imminent": "Colapso circulatório iminente" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/pt.json b/custom_components/thermal_comfort/translations/pt.json new file mode 100644 index 00000000..2ebd5758 --- /dev/null +++ b/custom_components/thermal_comfort/translations/pt.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Sem risco", + "unlikely": "Improvável", + "probable": "Provável", + "high": "Muito provável" + } + }, + "dew_point_perception": { + "state": { + "dry": "Um pouco seco", + "very_comfortable": "Muito confortável", + "comfortable": "Confortável", + "ok_but_humid": "Um pouco húmido", + "somewhat_uncomfortable": "Algo desconfortável", + "quite_uncomfortable": "Muito húmido, muito desconfortável", + "extremely_uncomfortable": "Extremamente desconfortável, opressivo", + "severely_high": "Alto risco para a saúde, mortal em caso de asma" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Frio", + "slightly_cool": "Um pouco frio", + "comfortable": "Confortável", + "slightly_warm": "Um pouco quente", + "increasing_discomfort": "Desconforto crescente", + "extremely_warm": "Muito quente", + "danger_of_heatstroke": "Perigo de insolação", + "extreme_danger_of_heatstroke": "Perigo extremo de insolação", + "circulatory_collapse_imminent": "Colapso circulatório iminente" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/ro.json b/custom_components/thermal_comfort/translations/ro.json new file mode 100644 index 00000000..7928ceaf --- /dev/null +++ b/custom_components/thermal_comfort/translations/ro.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Senzorul de temperatură nu a fost găsit", + "humidity_not_found": "Senzorul de umiditate nu a fost găsit" + }, + "step": { + "init": { + "title": "Setări de confort termic", + "data": { + "temperature_sensor": "Senzor de temperatura", + "humidity_sensor": "Senzor de umiditate", + "poll": "Activați sondajul", + "scan_interval": "Interval de sondaj (secunde)", + "custom_icons": "Utilizați pachetul de pictograme personalizate" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Această combinație de senzori de temperatură și umiditate este deja configurată", + "no_sensors": "Nu s-au găsit senzori de temperatură sau umiditate. Încercați din nou în modul avansat.", + "no_sensors_advanced": "Nu s-au găsit senzori de temperatură sau umiditate." + }, + "error": { + "temperature_not_found": "Senzorul de temperatură nu a fost găsit", + "humidity_not_found": "Senzorul de umiditate nu a fost găsit" + }, + "step": { + "user": { + "title": "Setări de confort termic", + "data": { + "name": "Nume", + "temperature_sensor": "Senzor de temperatura", + "humidity_sensor": "Senzor de umiditate", + "poll": "Activați sondajul", + "scan_interval": "Interval de sondaj (secunde)", + "custom_icons": "Utilizați pachetul de pictograme personalizate", + "enabled_sensors": "Senzori activați" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Niciun risc", + "unlikely": "Improbabil", + "probable": "Probabil", + "high": "Probabilitate mare" + } + }, + "dew_point_perception": { + "state": { + "dry": "Cam uscat pentru unii", + "very_comfortable": "Foarte confortabil", + "comfortable": "Confortabil", + "ok_but_humid": "OK pentru majoritatea, dar umed", + "somewhat_uncomfortable": "Oarecum inconfortabil", + "quite_uncomfortable": "Foarte umed, destul de inconfortabil", + "extremely_uncomfortable": "Extrem de inconfortabil, opresiv", + "severely_high": "Sever ridicat, chiar mortal pentru bolile legate de astm" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Confortabil", + "noticable_discomfort": "Disconfort perceptibil", + "evident_discomfort": "Disconfort evident", + "great_discomfort": "Disconfort mare, evitați eforturile", + "dangerous_discomfort": "Disconfort periculos", + "heat_stroke": "Posibil accident vascular cerebral cauzat de căldură excesivă" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "În afara intervalului calculabil", + "comfortable": "Confortabil", + "slight_discomfort": "Ușor disconfort", + "discomfort": "Disconfort", + "significant_discomfort": "Disconfort semnificativ", + "extreme_discomfort": "Disconfort extrem" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Rece", + "slightly_cool": "Ușor rece", + "comfortable": "Confortabil", + "slightly_warm": "Ușor cald", + "increasing_discomfort": "Creșterea disconfortului", + "extremely_warm": "Extrem de cald", + "danger_of_heatstroke": "Pericol de insolație", + "extreme_danger_of_heatstroke": "Pericol extrem de insolație", + "circulatory_collapse_imminent": "Colapsul circulator iminent" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "În afara intervalului calculabil", + "comfortable": "Confortabil", + "slightly_uncomfortable": "Ușor inconfortabil", + "moderatly_uncomfortable": "Moderat inconfortabil", + "highly_uncomfortable": "Foarte inconfortabil" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Niciun disconfort", + "less_then_half": "Mai puțin de jumătate din populație simte disconfort", + "more_then_half": "Mai mult de jumătate din populație simte disconfort", + "most": "Majoritatea persoanelor simt disconfort și deteriorarea condițiilor psihofizice", + "everyone": "Toată lumea simte disconfort semnificativ", + "dangerous": "Disconfort periculos, foarte puternic, care poate provoca epuizare termică" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/ru.json b/custom_components/thermal_comfort/translations/ru.json new file mode 100644 index 00000000..3d43a682 --- /dev/null +++ b/custom_components/thermal_comfort/translations/ru.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Датчик температуры не найден", + "humidity_not_found": "Датчик влажности не найден" + }, + "step": { + "init": { + "title": "Настройки теплового комфорта", + "data": { + "temperature_sensor": "Датчик температуры", + "humidity_sensor": "Датчик влажности", + "poll": "Включить опрос датчиков", + "scan_interval": "Интервал опроса (секунды)", + "custom_icons": "Использовать иконки от интеграции (требуется установка Thermal Comfort Icons)" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Эта комбинация датчиков температуры и влажности уже настроена.", + "no_sensors": "Не найдено ни одного подходящего датчика температуры или влажности. Попробуйте настройку в Расширеном режиме.", + "no_sensors_advanced": "Не найдено ни одного датчика температуры или влажности." + }, + "error": { + "temperature_not_found": "Датчик температуры не найден", + "humidity_not_found": "Датчик влажности не найден" + }, + "step": { + "user": { + "title": "Настройки теплового комфорта", + "data": { + "name": "Название", + "temperature_sensor": "Датчик температуры", + "humidity_sensor": "Датчик влажности", + "poll": "Включить опрос датчиков", + "scan_interval": "Интервал опроса (секунды)", + "custom_icons": "Использовать иконки от интеграции (требуется установка Thermal Comfort Icons)", + "enabled_sensors": "Активировать следующие датчики" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Не опасно", + "unlikely": "Маловероятно", + "probable": "Вероятно", + "high": "Очень вероятно" + } + }, + "dew_point_perception": { + "state": { + "dry": "Сухо", + "very_comfortable": "Очень комфортно", + "comfortable": "Комфортно", + "ok_but_humid": "Нормально, но сыровато", + "somewhat_uncomfortable": "Слегка некомфортно", + "quite_uncomfortable": "Довольно влажно, некомфортно", + "extremely_uncomfortable": "Очень влажно, угнетающе", + "severely_high": "Очень высокая влажность, опасно для астматиков" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Комфортно", + "noticable_discomfort": "Заметный дискомфорт", + "evident_discomfort": "Дискомфотно", + "great_discomfort": "Серьезный дискомфорт, избегайте физических нагрузок", + "dangerous_discomfort": "Опасный дискомфорт", + "heat_stroke": "Возможен тепловой удар" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Значения датчиков вне допустимого диапазона", + "comfortable": "Комфортно", + "slight_discomfort": "Слегка некомфортно", + "discomfort": "Некомфортно", + "significant_discomfort": "Очень некомфортно", + "extreme_discomfort": "Черезвычайно некомфортно" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Холодно", + "slightly_cool": "Прохладно", + "comfortable": "Комфортно", + "slightly_warm": "Тепло", + "increasing_discomfort": "Жарко", + "extremely_warm": "Очень жарко", + "danger_of_heatstroke": "Риск теплового удара", + "extreme_danger_of_heatstroke": "Серьёзный риск теплового удара", + "circulatory_collapse_imminent": "Возможны сосудистые нарушения" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Значения датчиков вне допустимого диапазона", + "comfortable": "Комфортно", + "slightly_uncomfortable": "Слегка некомфортно", + "moderatly_uncomfortable": "Некомфортно", + "highly_uncomfortable": "Очень некомфортно" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Нет дискомфорта", + "less_then_half": "Менее половины населения испытывает дискомфорт", + "more_then_half": "Больше половины населения испытывает дискомфорт", + "most": "Большинство испытывает дискомфорт и ухудшение психофизического состояния", + "everyone": "Все испытывают существенный дискомфорт", + "dangerous": "Опасно, очень сильный дискомфорт, который может вызвать тепловой удар" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/sk.json b/custom_components/thermal_comfort/translations/sk.json new file mode 100644 index 00000000..7e9cc4f7 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sk.json @@ -0,0 +1,121 @@ +{ + "options": { + "error": { + "temperature_not_found": "Teplotný snímač nenájdený", + "humidity_not_found": "Snímač vlhkosti nenájdený" + }, + "step": { + "init": { + "title": "Nastavenia tepelnej pohody", + "data": { + "temperature_sensor": "Teplotný snímač", + "humidity_sensor": "Snímač vlhkosti", + "poll": "Povoliť dotazovanie", + "scan_interval": "Interval dotazovania (sekundy)", + "custom_icons": "Používanie vlastného balíka ikon" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Táto kombinácia snímačov teploty a vlhkosti je už nakonfigurovaná", + "no_sensors": "Nenašli sa žiadne snímače teploty alebo vlhkosti. Skúste to znova v rozšírenom režime.", + "no_sensors_advanced": "Nenašli sa žiadne snímače teploty alebo vlhkosti." + }, + "error": { + "temperature_not_found": "Teplotný snímač nenájdený", + "humidity_not_found": "Snímač vlhkosti nenájdený" + }, + "step": { + "user": { + "title": "Nastavenia tepelnej pohody", + "data": { + "name": "Názov", + "temperature_sensor": "Teplotný snímač", + "humidity_sensor": "Snímač vlhkosti", + "poll": "Povoliť dotazovanie", + "scan_interval": "Interval dotazovania (sekundy)", + "custom_icons": "Používanie vlastného balíka ikon", + "enabled_sensors": "Povolené senzory" + } + } + } + }, + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Žiadne riziko", + "unlikely": "Nepravdepodobné", + "probable": "Pravdepodobné", + "high": "Vysoká pravdepodobnosť" + } + }, + "dew_point_perception": { + "state": { + "dry": "Pre niekoho suché", + "very_comfortable": "Veľmi komfortné", + "comfortable": "Príjemné", + "ok_but_humid": "Pre vǎčšinu OK, ale vlhké", + "somewhat_uncomfortable": "Trochu nepríjemné", + "quite_uncomfortable": "Veľmi vlhké, dosť nepríjemné", + "extremely_uncomfortable": "Extrémne nekomfortné, tiesnivé", + "severely_high": "Veľmi vysoká, pre astmatikov smrteľná vlhkosť" + } + }, + "humidex_perception": { + "state": { + "comfortable": "Príjemne", + "noticable_discomfort": "Noticeable discomfort", + "evident_discomfort": "Evident discomfort", + "great_discomfort": "Great discomfort, avoid exertion", + "dangerous_discomfort": "Dangerous discomfort", + "heat_stroke": "Heat stroke possible" + } + }, + "relative_strain_perception": { + "state": { + "outside_calculable_range": "Outside of the calculable range", + "comfortable": "Príjemne", + "slight_discomfort": "Mierne nepohodlie", + "discomfort": "Diskomfort", + "significant_discomfort": "Významný diskomfort", + "extreme_discomfort": "Extrémny discomfort" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Chladno", + "slightly_cool": "Mierne chladno", + "comfortable": "Príjemne", + "slightly_warm": "Mierne teplo", + "increasing_discomfort": "Stupňujúce sa nepohodlie", + "extremely_warm": "Extrémne teplo", + "danger_of_heatstroke": "Nebezpečenstvo úpalu", + "extreme_danger_of_heatstroke": "Extrémne nebezpečenstvo úpalu", + "circulatory_collapse_imminent": "Hroziaci kolaps krvného obehu" + } + }, + "scharlau_perception": { + "state": { + "outside_calculable_range": "Mimo vypočítateľného rozsahu", + "comfortable": "Príjemne", + "slightly_uncomfortable": "Mierny diskomfort", + "moderatly_uncomfortable": "Stredne nepríjemné", + "highly_uncomfortable": "Veľmy nepríjemné" + } + }, + "thoms_discomfort_perception": { + "state": { + "no_discomfort": "Žiadne nepohodlie", + "less_then_half": "Menej ako polovica populácie pociťuje nepríjemné pocity", + "more_then_half": "Viac ako polovica populácie pociťuje nepohodlie", + "most": "Väčšina jednotlivcov pociťuje nepohodlie a zhoršenie psychofyzických podmienok", + "everyone": "Každý pociťuje výrazné nepohodlie", + "dangerous": "Nebezpečné, veľmi silné nepohodlie, ktoré môže spôsobiť úpal" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/sv.json b/custom_components/thermal_comfort/translations/sv.json new file mode 100644 index 00000000..b0128b1f --- /dev/null +++ b/custom_components/thermal_comfort/translations/sv.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Ingen risk", + "unlikely": "Osannolikt", + "probable": "Sannolikt", + "high": "Stor risk" + } + }, + "dew_point_perception": { + "state": { + "dry": "Lite torrt", + "very_comfortable": "Väldigt bekväm", + "comfortable": "Bekvämt", + "ok_but_humid": "Ok, något fuktigt", + "somewhat_uncomfortable": "Något obekvämt", + "quite_uncomfortable": "Väldigt fuktigt, ganska obekvämt", + "extremely_uncomfortable": "Tryckande, extremt obekvämt", + "severely_high": "Allvarligt högt, kan vara dödlig för astmarelaterade sjukdomar" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Svalt", + "slightly_cool": "Ganska svalt", + "comfortable": "Bekvämt", + "slightly_warm": "Ganska varmt", + "increasing_discomfort": "Börjar bli obekvämt", + "extremely_warm": "Extremt varmt", + "danger_of_heatstroke": "Risk för värmeslag", + "extreme_danger_of_heatstroke": "Extrem risk för värmeslag", + "circulatory_collapse_imminent": "Allvarlig fara för kollaps" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/uk.json b/custom_components/thermal_comfort/translations/uk.json new file mode 100644 index 00000000..9b2911be --- /dev/null +++ b/custom_components/thermal_comfort/translations/uk.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "frost_risk": { + "state": { + "no_risk": "Безпечно", + "unlikely": "Малоймовірно", + "probable": "Ймовірно", + "high": "Висока ймовірність" + } + }, + "dew_point_perception": { + "state": { + "dry": "Сухо", + "very_comfortable": "Дуже комфортно", + "comfortable": "Комфортно", + "ok_but_humid": "Нормально, але волого", + "somewhat_uncomfortable": "Трохи некомфортно", + "quite_uncomfortable": "Досить волого, некомфортно", + "extremely_uncomfortable": "Дуже волого, гнітюче", + "severely_high": "Дуже висока вологість, може бути смертельною для для астматиків" + } + }, + "summer_simmer_perception": { + "state": { + "cool": "Холодно", + "slightly_cool": "Прохолодно", + "comfortable": "Комфортно", + "slightly_warm": "Тепло", + "increasing_discomfort": "Жарко", + "extremely_warm": "Дуже жарко", + "danger_of_heatstroke": "Ризик теплового удару", + "extreme_danger_of_heatstroke": "Серйозний ризик теплового удару", + "circulatory_collapse_imminent": "Можливі судинні розлади" + } + } + } + } +} From 970bb9efbd5b6c35533dba05176446ae76a37875 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:41:02 +0200 Subject: [PATCH 4/8] Better thermostat 1.0.2 --- .../better_thermostat/adapters/deconz.py | 2 +- .../better_thermostat/climate.py | 1 - .../better_thermostat/config_flow.py | 3 --- .../better_thermostat/events/trv.py | 1 - .../better_thermostat/manifest.json | 20 ++++++++-------- .../better_thermostat/model_fixes/SPZB0001.py | 4 ++++ .../better_thermostat/utils/controlling.py | 23 ++++++++++++++++--- .../better_thermostat/utils/helpers.py | 8 +++---- 8 files changed, 39 insertions(+), 23 deletions(-) diff --git a/custom_components/better_thermostat/adapters/deconz.py b/custom_components/better_thermostat/adapters/deconz.py index 6cc07f17..696d159c 100644 --- a/custom_components/better_thermostat/adapters/deconz.py +++ b/custom_components/better_thermostat/adapters/deconz.py @@ -54,7 +54,7 @@ async def set_offset(self, entity_id, offset): await self.hass.services.async_call( "deconz", "configure", - {"entity_id": entity_id, "offset": offset}, + {"entity": entity_id, "field": "/config", "data": {"offset": offset}}, blocking=True, limit=None, context=self._context, diff --git a/custom_components/better_thermostat/climate.py b/custom_components/better_thermostat/climate.py index b941c0b1..aa9fa801 100644 --- a/custom_components/better_thermostat/climate.py +++ b/custom_components/better_thermostat/climate.py @@ -768,7 +768,6 @@ async def startup(self): break async def calculate_heating_power(self): - if ( self.heating_start_temp is not None and self.heating_end_temp is not None diff --git a/custom_components/better_thermostat/config_flow.py b/custom_components/better_thermostat/config_flow.py index 2526168a..d4982256 100644 --- a/custom_components/better_thermostat/config_flow.py +++ b/custom_components/better_thermostat/config_flow.py @@ -126,7 +126,6 @@ async def async_step_confirm(self, user_input=None, confirm_type=None): async def async_step_advanced(self, user_input=None, _trv_config=None): """Handle options flow.""" if user_input is not None: - self.trv_bundle[self.i]["advanced"] = user_input self.trv_bundle[self.i]["adapter"] = None @@ -354,7 +353,6 @@ async def async_step_advanced( ): """Manage the advanced options.""" if user_input is not None: - self.trv_bundle[self.i]["advanced"] = user_input self.trv_bundle[self.i]["adapter"] = None @@ -484,7 +482,6 @@ async def async_step_advanced( ) async def async_step_user(self, user_input=None): - if user_input is not None: current_config = self.config_entry.data self.updated_config = dict(current_config) diff --git a/custom_components/better_thermostat/events/trv.py b/custom_components/better_thermostat/events/trv.py index 91f7dc2f..2b6d21d1 100644 --- a/custom_components/better_thermostat/events/trv.py +++ b/custom_components/better_thermostat/events/trv.py @@ -319,7 +319,6 @@ def convert_outbound_states(self, entity_id, hvac_mode) -> Union[dict, None]: _new_heating_setpoint = self.bt_target_temp elif _calibration_type == 1: - _round_calibration = self.real_trvs[entity_id]["advanced"].get( "calibration_round" ) diff --git a/custom_components/better_thermostat/manifest.json b/custom_components/better_thermostat/manifest.json index dd0fd3bf..b81bb160 100644 --- a/custom_components/better_thermostat/manifest.json +++ b/custom_components/better_thermostat/manifest.json @@ -1,20 +1,20 @@ { "domain": "better_thermostat", "name": "Better Thermostat", - "documentation": "https://github.com/KartoffelToby/better_thermostat", - "issue_tracker": "https://github.com/KartoffelToby/better_thermostat/issues", - "iot_class": "local_push", - "version": "1.0.1", - "config_flow": true, - "dependencies": [ - "climate", - "recorder" - ], "after_dependencies": [ "climate" ], "codeowners": [ "@kartoffeltoby" ], - "requirements": [] + "config_flow": true, + "dependencies": [ + "climate", + "recorder" + ], + "documentation": "https://github.com/KartoffelToby/better_thermostat", + "iot_class": "local_push", + "issue_tracker": "https://github.com/KartoffelToby/better_thermostat/issues", + "requirements": [], + "version": "1.0.2" } diff --git a/custom_components/better_thermostat/model_fixes/SPZB0001.py b/custom_components/better_thermostat/model_fixes/SPZB0001.py index f3f27188..19d5d61f 100644 --- a/custom_components/better_thermostat/model_fixes/SPZB0001.py +++ b/custom_components/better_thermostat/model_fixes/SPZB0001.py @@ -1,4 +1,8 @@ def fix_local_calibration(self, entity_id, offset): + if offset > 5: + offset = 5 + elif offset < -5: + offset = -5 return offset diff --git a/custom_components/better_thermostat/utils/controlling.py b/custom_components/better_thermostat/utils/controlling.py index 92045171..dfed705e 100644 --- a/custom_components/better_thermostat/utils/controlling.py +++ b/custom_components/better_thermostat/utils/controlling.py @@ -42,11 +42,28 @@ async def control_queue(self): self.ignore_states = True result = True for trv in self.real_trvs.keys(): - _temp = await control_trv(self, trv) - if _temp is False: + try: + _temp = await control_trv(self, trv) + if _temp is False: + result = False + except Exception: + _LOGGER.exception( + "better_thermostat %s: ERROR controlling: %s", + self.name, + trv, + ) result = False + + # Retry task if some TRVs failed. Discard the task if the queue is full + # to avoid blocking and therefore deadlocking this function. if result is False: - await self.control_queue_task.put(self) + try: + self.control_queue_task.put_nowait(self) + except asyncio.QueueFull: + _LOGGER.debug( + "better_thermostat %s: control queue is full, discarding task" + ) + self.control_queue_task.task_done() self.ignore_states = False diff --git a/custom_components/better_thermostat/utils/helpers.py b/custom_components/better_thermostat/utils/helpers.py index 4788e4af..8fac4b59 100644 --- a/custom_components/better_thermostat/utils/helpers.py +++ b/custom_components/better_thermostat/utils/helpers.py @@ -147,8 +147,8 @@ def calculate_local_setpoint_delta(self, entity_id) -> Union[float, None]: ) if _overheating_protection is True: - if (self.cur_temp + 0.10) >= self.bt_target_temp: - _new_local_calibration += 1.0 + if self.cur_temp >= self.bt_target_temp: + _new_local_calibration += (self.cur_temp - self.bt_target_temp) * 10.0 _new_local_calibration = round_down_to_half_degree(_new_local_calibration) @@ -229,8 +229,8 @@ def calculate_setpoint_override(self, entity_id) -> Union[float, None]: ) if _overheating_protection is True: - if (self.cur_temp + 0.10) >= self.bt_target_temp: - _calibrated_setpoint -= 1.0 + if self.cur_temp >= self.bt_target_temp: + _calibrated_setpoint -= (self.cur_temp - self.bt_target_temp) * 10.0 _calibrated_setpoint = round_down_to_half_degree(_calibrated_setpoint) From f03954646b3b28ceef4ba3a3e646db872d3fd9ba Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:47:35 +0200 Subject: [PATCH 5/8] Octopus energy v6.6.1 --- custom_components/octopus_energy/__init__.py | 89 +- .../octopus_energy/api_client.py | 286 +++- .../octopus_energy/binary_sensor.py | 302 +--- .../octopus_energy/binary_sensors/__init__.py | 236 +++ .../binary_sensors/saving_sessions.py | 100 ++ .../binary_sensors/target_rate.py | 233 +++ .../octopus_energy/config_flow.py | 58 +- custom_components/octopus_energy/const.py | 17 +- .../octopus_energy/diagnostics.py | 12 +- .../octopus_energy/manifest.json | 18 +- custom_components/octopus_energy/sensor.py | 1316 ++--------------- .../octopus_energy/sensors/__init__.py | 276 ++++ .../sensors/electricity/__init__.py | 0 .../sensors/electricity/base.py | 41 + .../electricity/current_consumption.py | 91 ++ .../sensors/electricity/current_demand.py | 88 ++ .../sensors/electricity/current_rate.py | 114 ++ .../sensors/electricity/next_rate.py | 105 ++ .../previous_accumulative_consumption.py | 110 ++ .../electricity/previous_accumulative_cost.py | 130 ++ .../sensors/electricity/previous_rate.py | 105 ++ .../sensors/electricity/standing_charge.py | 98 ++ .../octopus_energy/sensors/gas/__init__.py | 0 .../octopus_energy/sensors/gas/base.py | 35 + .../sensors/gas/current_consumption.py | 91 ++ .../sensors/gas/current_rate.py | 106 ++ .../gas/previous_accumulative_consumption.py | 115 ++ .../previous_accumulative_consumption_kwh.py | 113 ++ .../sensors/gas/previous_accumulative_cost.py | 134 ++ .../sensors/gas/standing_charge.py | 98 ++ .../sensors/saving_sessions/__init__.py | 0 .../sensors/saving_sessions/points.py | 73 + .../octopus_energy/translations/en.json | 28 +- .../octopus_energy/utils/__init__.py | 125 ++ .../octopus_energy/utils/check_tariff.py | 41 + 35 files changed, 3180 insertions(+), 1604 deletions(-) create mode 100644 custom_components/octopus_energy/binary_sensors/__init__.py create mode 100644 custom_components/octopus_energy/binary_sensors/saving_sessions.py create mode 100644 custom_components/octopus_energy/binary_sensors/target_rate.py create mode 100644 custom_components/octopus_energy/sensors/__init__.py create mode 100644 custom_components/octopus_energy/sensors/electricity/__init__.py create mode 100644 custom_components/octopus_energy/sensors/electricity/base.py create mode 100644 custom_components/octopus_energy/sensors/electricity/current_consumption.py create mode 100644 custom_components/octopus_energy/sensors/electricity/current_demand.py create mode 100644 custom_components/octopus_energy/sensors/electricity/current_rate.py create mode 100644 custom_components/octopus_energy/sensors/electricity/next_rate.py create mode 100644 custom_components/octopus_energy/sensors/electricity/previous_accumulative_consumption.py create mode 100644 custom_components/octopus_energy/sensors/electricity/previous_accumulative_cost.py create mode 100644 custom_components/octopus_energy/sensors/electricity/previous_rate.py create mode 100644 custom_components/octopus_energy/sensors/electricity/standing_charge.py create mode 100644 custom_components/octopus_energy/sensors/gas/__init__.py create mode 100644 custom_components/octopus_energy/sensors/gas/base.py create mode 100644 custom_components/octopus_energy/sensors/gas/current_consumption.py create mode 100644 custom_components/octopus_energy/sensors/gas/current_rate.py create mode 100644 custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption.py create mode 100644 custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption_kwh.py create mode 100644 custom_components/octopus_energy/sensors/gas/previous_accumulative_cost.py create mode 100644 custom_components/octopus_energy/sensors/gas/standing_charge.py create mode 100644 custom_components/octopus_energy/sensors/saving_sessions/__init__.py create mode 100644 custom_components/octopus_energy/sensors/saving_sessions/points.py create mode 100644 custom_components/octopus_energy/utils/__init__.py create mode 100644 custom_components/octopus_energy/utils/check_tariff.py diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index 768ff034..5671fcdc 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -8,11 +8,15 @@ DataUpdateCoordinator ) +from homeassistant.helpers import issue_registry as ir + from .const import ( DOMAIN, CONFIG_MAIN_API_KEY, CONFIG_MAIN_ACCOUNT_ID, + CONFIG_MAIN_ELECTRICITY_PRICE_CAP, + CONFIG_MAIN_GAS_PRICE_CAP, CONFIG_TARGET_NAME, @@ -31,14 +35,21 @@ get_active_tariff_code ) +from .utils.check_tariff import (async_check_valid_tariff) + _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry): """This is called from the config flow.""" hass.data.setdefault(DOMAIN, {}) - if CONFIG_MAIN_API_KEY in entry.data: - await async_setup_dependencies(hass, entry.data) + config = dict(entry.data) + + if entry.options: + config.update(entry.options) + + if CONFIG_MAIN_API_KEY in config: + await async_setup_dependencies(hass, config) # Forward our entry to setup our default sensors hass.async_create_task( @@ -48,7 +59,7 @@ async def async_setup_entry(hass, entry): hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "binary_sensor") ) - elif CONFIG_TARGET_NAME in entry.data: + elif CONFIG_TARGET_NAME in config: if DOMAIN not in hass.data or DATA_ELECTRICITY_RATES_COORDINATOR not in hass.data[DOMAIN] or DATA_ACCOUNT not in hass.data[DOMAIN]: raise ConfigEntryNotReady @@ -61,12 +72,31 @@ async def async_setup_entry(hass, entry): return True -async def async_get_current_electricity_agreement_tariff_codes(client, config): - account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID]) +async def async_get_current_electricity_agreement_tariff_codes(hass, client: OctopusEnergyApiClient, account_id: str): + account_info = None + try: + account_info = await client.async_get_account(account_id) + except: + # count exceptions as failure to retrieve account + _LOGGER.debug('Failed to retrieve account') + + if account_info is None: + ir.async_create_issue( + hass, + DOMAIN, + f"account_not_found_{account_id}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/blob/develop/_docs/repairs/account_not_found.md", + translation_key="account_not_found", + translation_placeholders={ "account_id": account_id }, + ) + else: + ir.async_delete_issue(hass, DOMAIN, f"account_not_found_{account_id}") tariff_codes = {} current = now() - if len(account_info["electricity_meter_points"]) > 0: + if account_info is not None and len(account_info["electricity_meter_points"]) > 0: for point in account_info["electricity_meter_points"]: active_tariff_code = get_active_tariff_code(current, point["agreements"]) # The type of meter (ie smart vs dumb) can change the tariff behaviour, so we @@ -77,33 +107,52 @@ async def async_get_current_electricity_agreement_tariff_codes(client, config): key = (point["mpan"], is_smart_meter) if key not in tariff_codes: tariff_codes[(point["mpan"], is_smart_meter)] = active_tariff_code + await async_check_valid_tariff(hass, client, active_tariff_code, True) return tariff_codes async def async_setup_dependencies(hass, config): """Setup the coordinator and api client which will be shared by various entities""" - if DATA_CLIENT not in hass.data[DOMAIN]: - client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY]) - hass.data[DOMAIN][DATA_CLIENT] = client - hass.data[DOMAIN][DATA_ACCOUNT_ID] = config[CONFIG_MAIN_ACCOUNT_ID] + electricity_price_cap = None + if CONFIG_MAIN_ELECTRICITY_PRICE_CAP in config: + electricity_price_cap = config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] + + gas_price_cap = None + if CONFIG_MAIN_GAS_PRICE_CAP in config: + gas_price_cap = config[CONFIG_MAIN_GAS_PRICE_CAP] - setup_rates_coordinator(hass, client, config) + _LOGGER.info(f'electricity_price_cap: {electricity_price_cap}') + _LOGGER.info(f'gas_price_cap: {gas_price_cap}') - setup_saving_sessions_coordinators(hass, client) - - account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID]) + client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY], electricity_price_cap, gas_price_cap) + hass.data[DOMAIN][DATA_CLIENT] = client + hass.data[DOMAIN][DATA_ACCOUNT_ID] = config[CONFIG_MAIN_ACCOUNT_ID] - hass.data[DOMAIN][DATA_ACCOUNT] = account_info + setup_rates_coordinator(hass, config[CONFIG_MAIN_ACCOUNT_ID]) -def setup_rates_coordinator(hass, client, config): + setup_saving_sessions_coordinators(hass) + + account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID]) + + hass.data[DOMAIN][DATA_ACCOUNT] = account_info + +def setup_rates_coordinator(hass, account_id: str): + # Reset data rates as we might have new information + hass.data[DOMAIN][DATA_RATES] = [] + + if DATA_ELECTRICITY_RATES_COORDINATOR in hass.data[DOMAIN]: + _LOGGER.info("Rates coordinator has already been configured, so skipping") + return + async def async_update_electricity_rates_data(): """Fetch data from API endpoint.""" # Only get data every half hour or if we don't have any data current = now() + client: OctopusEnergyApiClient = hass.data[DOMAIN][DATA_CLIENT] if (DATA_RATES not in hass.data[DOMAIN] or (current.minute % 30) == 0 or len(hass.data[DOMAIN][DATA_RATES]) == 0): - tariff_codes = await async_get_current_electricity_agreement_tariff_codes(client, config) + tariff_codes = await async_get_current_electricity_agreement_tariff_codes(hass, client, account_id) _LOGGER.debug(f'tariff_codes: {tariff_codes}') period_from = as_utc(current.replace(hour=0, minute=0, second=0, microsecond=0)) @@ -133,11 +182,15 @@ async def async_update_electricity_rates_data(): update_interval=timedelta(minutes=1), ) -def setup_saving_sessions_coordinators(hass, client: OctopusEnergyApiClient): +def setup_saving_sessions_coordinators(hass): + if DATA_SAVING_SESSIONS_COORDINATOR in hass.data[DOMAIN]: + return + async def async_update_saving_sessions(): """Fetch data from API endpoint.""" # Only get data every half hour or if we don't have any data current = now() + client: OctopusEnergyApiClient = hass.data[DOMAIN][DATA_CLIENT] if DATA_SAVING_SESSIONS not in hass.data[DOMAIN] or current.minute % 30 == 0: savings = await client.async_get_saving_sessions(hass.data[DOMAIN][DATA_ACCOUNT_ID]) diff --git a/custom_components/octopus_energy/api_client.py b/custom_components/octopus_energy/api_client.py index 1f88c96d..5b00aa82 100644 --- a/custom_components/octopus_energy/api_client.py +++ b/custom_components/octopus_energy/api_client.py @@ -24,12 +24,20 @@ meterPoint {{ mpan meters(includeInactive: false) {{ + makeAndType serialNumber + makeAndType smartExportElectricityMeter {{ deviceId + manufacturer + model + firmwareVersion }} smartImportElectricityMeter {{ deviceId + manufacturer + model + firmwareVersion }} }} agreements {{ @@ -38,18 +46,23 @@ tariff {{ ...on StandardTariff {{ tariffCode + productCode }} ...on DayNightTariff {{ tariffCode + productCode }} ...on ThreeRateTariff {{ tariffCode + productCode }} ...on HalfHourlyTariff {{ tariffCode + productCode }} ...on PrepayTariff {{ tariffCode + productCode }} }} }} @@ -61,12 +74,20 @@ meters(includeInactive: false) {{ serialNumber consumptionUnits + modelName + smartGasMeter {{ + deviceId + manufacturer + model + firmwareVersion + }} }} agreements {{ validFrom validTo tariff {{ tariffCode + productCode }} }} }} @@ -92,9 +113,23 @@ }} }}''' +live_consumption_query = '''query {{ + smartMeterTelemetry( + deviceId: "{device_id}" + grouping: ONE_MINUTE + start: "{period_from}" + end: "{period_to}" + ) {{ + readAt + consumptionDelta + demand + }} +}}''' + + class OctopusEnergyApiClient: - def __init__(self, api_key): + def __init__(self, api_key, electricity_price_cap = None, gas_price_cap = None): if (api_key == None): raise Exception('API KEY is not set') @@ -106,9 +141,12 @@ def __init__(self, api_key): self._product_tracker_cache = dict() + self._electricity_price_cap = electricity_price_cap + self._gas_price_cap = gas_price_cap + async def async_refresh_token(self): """Get the user's refresh token""" - if (self._graphql_expiration != None and (self._graphql_expiration - timedelta(minutes=5)) > now()): + if (self._graphql_expiration is not None and (self._graphql_expiration - timedelta(minutes=5)) > now()): return async with aiohttp.ClientSession() as client: @@ -116,12 +154,16 @@ async def async_refresh_token(self): payload = { "query": api_token_query.format(api_key=self._api_key) } async with client.post(url, json=payload) as token_response: token_response_body = await self.__async_read_response(token_response, url) - if (token_response_body != None and "data" in token_response_body): + if (token_response_body is not None and + "data" in token_response_body and + "obtainKrakenToken" in token_response_body["data"] and + token_response_body["data"]["obtainKrakenToken"] is not None and + "token" in token_response_body["data"]["obtainKrakenToken"]): + self._graphql_token = token_response_body["data"]["obtainKrakenToken"]["token"] self._graphql_expiration = now() + timedelta(hours=1) else: _LOGGER.error("Failed to retrieve auth token") - raise Exception('Failed to refresh token') async def async_get_account(self, account_id): """Get the user's account""" @@ -135,36 +177,92 @@ async def async_get_account(self, account_id): async with client.post(url, json=payload, headers=headers) as account_response: account_response_body = await self.__async_read_response(account_response, url) - _LOGGER.debug(account_response_body) + _LOGGER.debug(f'account: {account_response_body}') - if (account_response_body != None and "data" in account_response_body): + if (account_response_body is not None and + "data" in account_response_body and + "account" in account_response_body["data"] and + account_response_body["data"]["account"] is not None): return { "electricity_meter_points": list(map(lambda mp: { - "mpan": mp["meterPoint"]["mpan"], - "meters": list(map(lambda m: { - "serial_number": m["serialNumber"], - "is_export": m["smartExportElectricityMeter"] != None, - "is_smart_meter": m["smartImportElectricityMeter"] != None or m["smartExportElectricityMeter"] != None, - }, mp["meterPoint"]["meters"])), - "agreements": list(map(lambda a: { - "valid_from": a["validFrom"], - "valid_to": a["validTo"], - "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, - }, mp["meterPoint"]["agreements"])) - }, account_response_body["data"]["account"]["electricityAgreements"])), - "gas_meter_points": list(map(lambda mp: { + "mpan": mp["meterPoint"]["mpan"], + "meters": list(map(lambda m: { + "serial_number": m["serialNumber"], + "is_export": m["smartExportElectricityMeter"] is not None, + "is_smart_meter": m["smartImportElectricityMeter"] is not None or m["smartExportElectricityMeter"] is not None, + "device_id": m["smartImportElectricityMeter"]["deviceId"] if m["smartImportElectricityMeter"] is not None else None, + "manufacturer": m["smartImportElectricityMeter"]["manufacturer"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["manufacturer"] + if m["smartExportElectricityMeter"] is not None + else m["makeAndType"], + "model": m["smartImportElectricityMeter"]["model"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["model"] + if m["smartExportElectricityMeter"] is not None + else None, + "firmware": m["smartImportElectricityMeter"]["firmwareVersion"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["firmwareVersion"] + if m["smartExportElectricityMeter"] is not None + else None + }, + mp["meterPoint"]["meters"] + if "meterPoint" in mp and "meters" in mp["meterPoint"] and mp["meterPoint"]["meters"] is not None + else [] + )), + "agreements": list(map(lambda a: { + "valid_from": a["validFrom"], + "valid_to": a["validTo"], + "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, + "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, + }, + mp["meterPoint"]["agreements"] + if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None + else [] + )) + }, + account_response_body["data"]["account"]["electricityAgreements"] + if "electricityAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["electricityAgreements"] is not None + else [] + )), + "gas_meter_points": list(map(lambda mp: { "mprn": mp["meterPoint"]["mprn"], "meters": list(map(lambda m: { - "serial_number": m["serialNumber"], - "consumption_units": m["consumptionUnits"], - }, mp["meterPoint"]["meters"])), + "serial_number": m["serialNumber"], + "consumption_units": m["consumptionUnits"], + "is_smart_meter": m["smartGasMeter"] is not None, + "device_id": m["smartGasMeter"]["deviceId"] if m["smartGasMeter"] is not None else None, + "manufacturer": m["smartGasMeter"]["manufacturer"] + if m["smartGasMeter"] is not None + else m["modelName"], + "model": m["smartGasMeter"]["model"] + if m["smartGasMeter"] is not None + else None, + "firmware": m["smartGasMeter"]["firmwareVersion"] + if m["smartGasMeter"] is not None + else None + }, + mp["meterPoint"]["meters"] + if "meterPoint" in mp and "meters" in mp["meterPoint"] and mp["meterPoint"]["meters"] is not None + else [] + )), "agreements": list(map(lambda a: { - "valid_from": a["validFrom"], - "valid_to": a["validTo"], - "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, - }, mp["meterPoint"]["agreements"])) - }, account_response_body["data"]["account"]["gasAgreements"])), - } + "valid_from": a["validFrom"], + "valid_to": a["validTo"], + "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, + "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, + }, + mp["meterPoint"]["agreements"] + if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None + else [] + )) + }, + account_response_body["data"]["account"]["gasAgreements"] + if "gasAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["gasAgreements"] is not None + else [] + )), + } else: _LOGGER.error("Failed to retrieve account") @@ -182,7 +280,7 @@ async def async_get_saving_sessions(self, account_id): async with client.post(url, json=payload, headers=headers) as account_response: response_body = await self.__async_read_response(account_response, url) - if (response_body != None and "data" in response_body): + if (response_body is not None and "data" in response_body): return { "points": int(response_body["data"]["octoPoints"]["account"]["currentPointsInWallet"]), "events": list(map(lambda ev: { @@ -195,6 +293,29 @@ async def async_get_saving_sessions(self, account_id): return None + async def async_get_smart_meter_consumption(self, device_id, period_from, period_to): + """Get the user's smart meter consumption""" + await self.async_refresh_token() + + async with aiohttp.ClientSession() as client: + url = f'{self._base_url}/v1/graphql/' + + payload = { "query": live_consumption_query.format(device_id=device_id, period_from=period_from, period_to=period_to) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as live_consumption_response: + response_body = await self.__async_read_response(live_consumption_response, url) + + if (response_body is not None and "data" in response_body and "smartMeterTelemetry" in response_body["data"] and response_body["data"]["smartMeterTelemetry"] is not None and len(response_body["data"]["smartMeterTelemetry"]) > 0): + return list(map(lambda mp: { + "consumption": float(mp["consumptionDelta"]), + "demand": float(mp["demand"]) if "demand" in mp and mp["demand"] is not None else None, + "startAt": parse_datetime(mp["readAt"]) + }, response_body["data"]["smartMeterTelemetry"])) + else: + _LOGGER.debug(f"Failed to retrieve smart meter consumption data - device_id: {device_id}; period_from: {period_from}; period_to: {period_to}") + + return None + async def async_get_electricity_standard_rates(self, product_code, tariff_code, period_from, period_to): """Get the current standard rates""" results = [] @@ -205,8 +326,9 @@ async def async_get_electricity_standard_rates(self, product_code, tariff_code, try: data = await self.__async_read_response(response, url) if data == None: - return None - results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code) + return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to, self._electricity_price_cap) + + results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) except: _LOGGER.error(f'Failed to extract standard rates: {url}') raise @@ -223,10 +345,10 @@ async def async_get_electricity_day_night_rates(self, product_code, tariff_code, try: data = await self.__async_read_response(response, url) if data == None: - return None + return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to, self._electricity_price_cap) # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our day period - day_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code) + day_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) for rate in day_rates: if (self.__is_night_rate(rate, is_smart_meter)) == False: results.append(rate) @@ -242,7 +364,7 @@ async def async_get_electricity_day_night_rates(self, product_code, tariff_code, return None # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our night period - night_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code) + night_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) for rate in night_rates: if (self.__is_night_rate(rate, is_smart_meter)) == True: results.append(rate) @@ -252,7 +374,6 @@ async def async_get_electricity_day_night_rates(self, product_code, tariff_code, # Because we retrieve our day and night periods separately over a 2 day period, we need to sort our rates results.sort(key=get_valid_from) - _LOGGER.debug(results) return results @@ -260,10 +381,13 @@ async def async_get_electricity_rates(self, tariff_code, is_smart_meter, period_ """Get the current rates""" tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + product_code = tariff_parts["product_code"] - if (await self.__async_is_tracker_tariff(tariff_code)): - return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to) + if (self.__async_is_tracker_tariff(tariff_code)): + return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to, self._electricity_price_cap) elif (tariff_parts["rate"].startswith("1")): return await self.async_get_electricity_standard_rates(product_code, tariff_code, period_from, period_to) else: @@ -277,7 +401,7 @@ async def async_get_electricity_consumption(self, mpan, serial_number, period_fr async with client.get(url, auth=auth) as response: data = await self.__async_read_response(response, url) - if (data != None and "results" in data): + if (data is not None and "results" in data): data = data["results"] results = [] for item in data: @@ -296,10 +420,13 @@ async def async_get_electricity_consumption(self, mpan, serial_number, period_fr async def async_get_gas_rates(self, tariff_code, period_from, period_to): """Get the gas rates""" tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + product_code = tariff_parts["product_code"] - if (await self.__async_is_tracker_tariff(tariff_code)): - return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to) + if (self.__async_is_tracker_tariff(tariff_code)): + return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to, self._gas_price_cap) results = [] async with aiohttp.ClientSession() as client: @@ -309,9 +436,9 @@ async def async_get_gas_rates(self, tariff_code, period_from, period_to): try: data = await self.__async_read_response(response, url) if data == None: - return None + return await self.__async_get_tracker_rates__(tariff_code, period_from, period_to, self._gas_price_cap) - results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code) + results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._gas_price_cap) except: _LOGGER.error(f'Failed to extract standard gas rates: {url}') raise @@ -325,7 +452,7 @@ async def async_get_gas_consumption(self, mprn, serial_number, period_from, peri url = f'{self._base_url}/v1/gas-meter-points/{mprn}/meters/{serial_number}/consumption?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' async with client.get(url, auth=auth) as response: data = await self.__async_read_response(response, url) - if (data != None and "results" in data): + if (data is not None and "results" in data): data = data["results"] results = [] for item in data: @@ -341,24 +468,25 @@ async def async_get_gas_consumption(self, mprn, serial_number, period_from, peri return None - async def async_get_products(self, is_variable): + async def async_get_product(self, product_code): """Get all products""" async with aiohttp.ClientSession() as client: auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products?is_variable={is_variable}' + url = f'{self._base_url}/v1/products/{product_code}' async with client.get(url, auth=auth) as response: - data = await self.__async_read_response(response, url) - if (data != None and "results" in data): - return data["results"] + return await self.__async_read_response(response, url) - return [] + return None async def async_get_electricity_standing_charge(self, tariff_code, period_from, period_to): """Get the electricity standing charges""" tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + product_code = tariff_parts["product_code"] - if await self.__async_is_tracker_tariff(tariff_code): + if self.__async_is_tracker_tariff(tariff_code): return await self.__async_get_tracker_standing_charge__(tariff_code, period_from, period_to) result = None @@ -368,9 +496,11 @@ async def async_get_electricity_standing_charge(self, tariff_code, period_from, async with client.get(url, auth=auth) as response: try: data = await self.__async_read_response(response, url) - if (data != None and "results" in data and len(data["results"]) > 0): + if data is None: + return await self.__async_get_tracker_standing_charge__(tariff_code, period_from, period_to) + + if ("results" in data and len(data["results"]) > 0): result = { - "value_exc_vat": float(data["results"][0]["value_exc_vat"]), "value_inc_vat": float(data["results"][0]["value_inc_vat"]) } except: @@ -382,9 +512,12 @@ async def async_get_electricity_standing_charge(self, tariff_code, period_from, async def async_get_gas_standing_charge(self, tariff_code, period_from, period_to): """Get the gas standing charges""" tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + product_code = tariff_parts["product_code"] - if await self.__async_is_tracker_tariff(tariff_code): + if self.__async_is_tracker_tariff(tariff_code): return await self.__async_get_tracker_standing_charge__(tariff_code, period_from, period_to) result = None @@ -394,9 +527,11 @@ async def async_get_gas_standing_charge(self, tariff_code, period_from, period_t async with client.get(url, auth=auth) as response: try: data = await self.__async_read_response(response, url) - if (data != None and "results" in data and len(data["results"]) > 0): + if data is None: + return await self.__async_get_tracker_standing_charge__(tariff_code, period_from, period_to) + + if ("results" in data and len(data["results"]) > 0): result = { - "value_exc_vat": float(data["results"][0]["value_exc_vat"]), "value_inc_vat": float(data["results"][0]["value_inc_vat"]) } except: @@ -405,27 +540,29 @@ async def async_get_gas_standing_charge(self, tariff_code, period_from, period_t return result - async def __async_is_tracker_tariff(self, tariff_code): + def __async_is_tracker_tariff(self, tariff_code): tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + product_code = tariff_parts["product_code"] if product_code in self._product_tracker_cache: return self._product_tracker_cache[product_code] - async with aiohttp.ClientSession() as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'https://api.octopus.energy/v1/products/{product_code}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response(response, url) - if data == None: - return False - - is_tracker = "is_tracker" in data and data["is_tracker"] - self._product_tracker_cache[product_code] = is_tracker - return is_tracker + return False - async def __async_get_tracker_rates__(self, tariff_code, period_from, period_to): + async def __async_get_tracker_rates__(self, tariff_code, period_from, period_to, price_cap: float = None): """Get the tracker rates""" + tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + return None + + product_code = tariff_parts["product_code"] + + # If we know our tariff is not a tracker rate, then don't bother asking + if product_code in self._product_tracker_cache and self._product_tracker_cache[product_code] == False: + return None results = [] async with aiohttp.ClientSession() as client: @@ -441,20 +578,18 @@ async def __async_get_tracker_rates__(self, tariff_code, period_from, period_to) for period in data["periods"]: valid_from = parse_datetime(f'{period["date"]}T00:00:00Z') valid_to = parse_datetime(f'{period["date"]}T00:00:00Z') + timedelta(days=1) - vat = float(period["breakdown"]["standing_charge"]["VAT"]) if ((valid_from >= period_from and valid_from <= period_to) or (valid_to >= period_from and valid_to <= period_to)): - vat = float(period["breakdown"]["unit_charge"]["VAT"]) items.append( { "valid_from": valid_from.strftime("%Y-%m-%dT%H:%M:%SZ"), "valid_to": valid_to.strftime("%Y-%m-%dT%H:%M:%SZ"), - "value_exc_vat": float(period["unit_rate"]) - vat, "value_inc_vat": float(period["unit_rate"]), } ) - results = rates_to_thirty_minute_increments({ "results": items }, period_from, period_to, tariff_code) + results = rates_to_thirty_minute_increments({ "results": items }, period_from, period_to, tariff_code, price_cap) + self._product_tracker_cache[product_code] = True except: _LOGGER.error(f'Failed to extract tracker gas rates: {url}') raise @@ -478,9 +613,7 @@ async def __async_get_tracker_standing_charge__(self, tariff_code, period_from, valid_from = parse_datetime(f'{period["date"]}T00:00:00Z') valid_to = parse_datetime(f'{period["date"]}T00:00:00Z') + timedelta(days=1) if ((valid_from >= period_from and valid_from <= period_to) or (valid_to >= period_from and valid_to <= period_to)): - vat = float(period["breakdown"]["standing_charge"]["VAT"]) return { - "value_exc_vat": float(period["standing_charge"]) - vat, "value_inc_vat": float(period["standing_charge"]) } except: @@ -536,7 +669,10 @@ async def __async_read_response(self, response, url): text = await response.text() if response.status >= 400: - _LOGGER.error(f'Request failed ({url}): {response.status}; {text}') + if response.status >= 500: + _LOGGER.error(f'Octopus Energy server error ({url}): {response.status}; {text}') + else: + _LOGGER.error(f'Failed to send request ({url}): {response.status}; {text}') return None try: diff --git a/custom_components/octopus_energy/binary_sensor.py b/custom_components/octopus_energy/binary_sensor.py index d81b4a41..696e57e9 100644 --- a/custom_components/octopus_energy/binary_sensor.py +++ b/custom_components/octopus_energy/binary_sensor.py @@ -1,53 +1,23 @@ from datetime import timedelta import logging -from custom_components.octopus_energy.utils import apply_offset +from .binary_sensors.saving_sessions import OctopusEnergySavingSessions +from .binary_sensors.target_rate import OctopusEnergyTargetRate -import re import voluptuous as vol -from homeassistant.core import callback -from homeassistant.util.dt import (utcnow, now) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity -) -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, -) -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( - CONFIG_TARGET_OFFSET, DOMAIN, CONFIG_MAIN_API_KEY, CONFIG_TARGET_NAME, - CONFIG_TARGET_HOURS, - CONFIG_TARGET_TYPE, - CONFIG_TARGET_START_TIME, - CONFIG_TARGET_END_TIME, CONFIG_TARGET_MPAN, - CONFIG_TARGET_ROLLING_TARGET, - - REGEX_HOURS, - REGEX_TIME, - REGEX_OFFSET_PARTS, DATA_ELECTRICITY_RATES_COORDINATOR, DATA_SAVING_SESSIONS_COORDINATOR, DATA_ACCOUNT ) -from .target_sensor_utils import ( - calculate_continuous_times, - calculate_intermittent_times, - is_target_rate_active -) - -from .sensor_utils import ( - is_saving_sessions_event_active, - get_next_saving_sessions_event -) - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=1) @@ -115,269 +85,3 @@ async def async_setup_target_sensors(hass, entry, async_add_entities): entities = [OctopusEnergyTargetRate(coordinator, config, is_export)] async_add_entities(entities, True) - -class OctopusEnergyTargetRate(CoordinatorEntity, BinarySensorEntity): - """Sensor for calculating when a target should be turned on or off.""" - - def __init__(self, coordinator, config, is_export): - """Init sensor.""" - # Pass coordinator to base class - super().__init__(coordinator) - - self._config = config - self._is_export = is_export - self._attributes = self._config.copy() - self._is_export = is_export - self._attributes["is_target_export"] = is_export - is_rolling_target = True - if CONFIG_TARGET_ROLLING_TARGET in self._config: - is_rolling_target = self._config[CONFIG_TARGET_ROLLING_TARGET] - self._attributes[CONFIG_TARGET_ROLLING_TARGET] = is_rolling_target - self._target_rates = [] - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_target_{self._config[CONFIG_TARGET_NAME]}" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Target {self._config[CONFIG_TARGET_NAME]}" - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:camera-timer" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def is_on(self): - """The state of the sensor.""" - - if CONFIG_TARGET_OFFSET in self._config: - offset = self._config[CONFIG_TARGET_OFFSET] - else: - offset = None - - # Find the current rate. Rates change a maximum of once every 30 minutes. - current_date = utcnow() - if (current_date.minute % 30) == 0 or len(self._target_rates) == 0: - _LOGGER.debug(f'Updating OctopusEnergyTargetRate {self._config[CONFIG_TARGET_NAME]}') - - # If all of our target times have passed, it's time to recalculate the next set - all_rates_in_past = True - for rate in self._target_rates: - if rate["valid_to"] > current_date: - all_rates_in_past = False - break - - if all_rates_in_past: - if self.coordinator.data != None: - all_rates = self.coordinator.data - - # Retrieve our rates. For backwards compatibility, if CONFIG_TARGET_MPAN is not set, then pick the first set - if CONFIG_TARGET_MPAN not in self._config: - _LOGGER.debug(f"'CONFIG_TARGET_MPAN' not set.'{len(all_rates)}' rates available. Retrieving the first rate.") - all_rates = next(iter(all_rates.values())) - else: - _LOGGER.debug(f"Retrieving rates for '{self._config[CONFIG_TARGET_MPAN]}'") - all_rates = all_rates.get(self._config[CONFIG_TARGET_MPAN]) - else: - _LOGGER.debug(f"Rate data missing. Setting to empty array") - all_rates = [] - - _LOGGER.debug(f'{len(all_rates) if all_rates != None else None} rate periods found') - - start_time = None - if CONFIG_TARGET_START_TIME in self._config: - start_time = self._config[CONFIG_TARGET_START_TIME] - - end_time = None - if CONFIG_TARGET_END_TIME in self._config: - end_time = self._config[CONFIG_TARGET_END_TIME] - - # True by default for backwards compatibility - is_rolling_target = True - if CONFIG_TARGET_ROLLING_TARGET in self._config: - is_rolling_target = self._config[CONFIG_TARGET_ROLLING_TARGET] - - target_hours = float(self._config[CONFIG_TARGET_HOURS]) - - if (self._config[CONFIG_TARGET_TYPE] == "Continuous"): - self._target_rates = calculate_continuous_times( - now(), - start_time, - end_time, - target_hours, - all_rates, - offset, - is_rolling_target, - self._is_export - ) - elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"): - self._target_rates = calculate_intermittent_times( - now(), - start_time, - end_time, - target_hours, - all_rates, - offset, - is_rolling_target, - self._is_export - ) - else: - _LOGGER.error(f"Unexpected target type: {self._config[CONFIG_TARGET_TYPE]}") - - self._attributes["target_times"] = self._target_rates - - active_result = is_target_rate_active(current_date, self._target_rates, offset) - - if offset != None and active_result["next_time"] != None: - self._attributes["next_time"] = apply_offset(active_result["next_time"], offset) - else: - self._attributes["next_time"] = active_result["next_time"] - - self._attributes["current_duration_in_hours"] = active_result["current_duration_in_hours"] - self._attributes["next_duration_in_hours"] = active_result["next_duration_in_hours"] - - return active_result["is_active"] - - @callback - def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None): - """Update sensors config""" - - config = dict(self._config) - - if target_hours is not None: - # Inputs from automations can include quotes, so remove these - trimmed_target_hours = target_hours.strip('\"') - matches = re.search(REGEX_HOURS, trimmed_target_hours) - if matches == None: - raise vol.Invalid(f"Target hours of '{trimmed_target_hours}' must be in half hour increments.") - else: - trimmed_target_hours = float(trimmed_target_hours) - if trimmed_target_hours % 0.5 != 0: - raise vol.Invalid(f"Target hours of '{trimmed_target_hours}' must be in half hour increments.") - else: - config.update({ - CONFIG_TARGET_HOURS: trimmed_target_hours - }) - - if target_start_time is not None: - # Inputs from automations can include quotes, so remove these - trimmed_target_start_time = target_start_time.strip('\"') - matches = re.search(REGEX_TIME, trimmed_target_start_time) - if matches == None: - raise vol.Invalid("Start time must be in the format HH:MM") - else: - config.update({ - CONFIG_TARGET_START_TIME: trimmed_target_start_time - }) - - if target_end_time is not None: - # Inputs from automations can include quotes, so remove these - trimmed_target_end_time = target_end_time.strip('\"') - matches = re.search(REGEX_TIME, trimmed_target_end_time) - if matches == None: - raise vol.Invalid("End time must be in the format HH:MM") - else: - config.update({ - CONFIG_TARGET_END_TIME: trimmed_target_end_time - }) - - if target_offset is not None: - # Inputs from automations can include quotes, so remove these - trimmed_target_offset = target_offset.strip('\"') - matches = re.search(REGEX_OFFSET_PARTS, trimmed_target_offset) - if matches == None: - raise vol.Invalid("Offset must be in the form of HH:MM:SS with an optional negative symbol") - else: - config.update({ - CONFIG_TARGET_OFFSET: trimmed_target_offset - }) - - self._config = config - self._attributes = self._config.copy() - self._attributes["is_target_export"] = self._is_export - self._target_rates = [] - self.async_write_ha_state() - -class OctopusEnergySavingSessions(CoordinatorEntity, BinarySensorEntity, RestoreEntity): - """Sensor for determining if a saving session is active.""" - - def __init__(self, coordinator): - """Init sensor.""" - - super().__init__(coordinator) - - self._state = None - self._events = [] - self._attributes = { - "joined_events": [], - "next_joined_event_start": None - } - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_saving_sessions" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Saving Session" - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:leaf" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def is_on(self): - """The state of the sensor.""" - saving_session = self.coordinator.data - if (saving_session is not None and "events" in saving_session): - self._events = saving_session["events"] - else: - self._events = [] - - self._attributes = { - "joined_events": self._events, - "next_joined_event_start": None, - "next_joined_event_end": None, - "next_joined_event_duration_in_minutes": None - } - - current_date = now() - self._state = is_saving_sessions_event_active(current_date, self._events) - next_event = get_next_saving_sessions_event(current_date, self._events) - if (next_event is not None): - self._attributes["next_joined_event_start"] = next_event["start"] - self._attributes["next_joined_event_end"] = next_event["end"] - self._attributes["next_joined_event_duration_in_minutes"] = next_event["duration_in_minutes"] - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - - if (self._state is None): - self._state = False - - _LOGGER.debug(f'Restored state: {self._state}') diff --git a/custom_components/octopus_energy/binary_sensors/__init__.py b/custom_components/octopus_energy/binary_sensors/__init__.py new file mode 100644 index 00000000..b0a56502 --- /dev/null +++ b/custom_components/octopus_energy/binary_sensors/__init__.py @@ -0,0 +1,236 @@ +from datetime import datetime, timedelta +import math +from homeassistant.util.dt import (as_utc, parse_datetime) +from ..utils import (apply_offset) +import logging + +_LOGGER = logging.getLogger(__name__) + +def __get_applicable_rates(current_date: datetime, target_start_time: str, target_end_time: str, rates, is_rolling_target: bool): + if (target_start_time is not None): + target_start = parse_datetime(current_date.strftime(f"%Y-%m-%dT{target_start_time}:00%z")) + else: + target_start = parse_datetime(current_date.strftime(f"%Y-%m-%dT00:00:00%z")) + + if (target_end_time is not None): + target_end = parse_datetime(current_date.strftime(f"%Y-%m-%dT{target_end_time}:00%z")) + else: + target_end = parse_datetime(current_date.strftime(f"%Y-%m-%dT00:00:00%z")) + timedelta(days=1) + + target_start = as_utc(target_start) + target_end = as_utc(target_end) + + if (target_start >= target_end): + _LOGGER.debug(f'{target_start} is after {target_end}, so setting target end to tomorrow') + if target_start > current_date: + target_start = target_start - timedelta(days=1) + else: + target_end = target_end + timedelta(days=1) + + # If our start date has passed, reset it to current_date to avoid picking a slot in the past + if (is_rolling_target == True and target_start < current_date and current_date < target_end): + _LOGGER.debug(f'Rolling target and {target_start} is in the past. Setting start to {current_date}') + target_start = current_date + + # If our start and end are both in the past, then look to the next day + if (target_start < current_date and target_end < current_date): + target_start = target_start + timedelta(days=1) + target_end = target_end + timedelta(days=1) + + _LOGGER.debug(f'Finding rates between {target_start} and {target_end}') + + # Retrieve the rates that are applicable for our target rate + applicable_rates = [] + if rates != None: + for rate in rates: + if rate["valid_from"] >= target_start and (target_end == None or rate["valid_to"] <= target_end): + applicable_rates.append(rate) + + # Make sure that we have enough rates that meet our target period + date_diff = target_end - target_start + hours = (date_diff.days * 24) + (date_diff.seconds // 3600) + periods = hours * 2 + if len(applicable_rates) < periods: + _LOGGER.debug(f'Incorrect number of periods discovered. Require {periods}, but only have {len(applicable_rates)}') + return None + + return applicable_rates + +def __get_rate(rate): + return rate["value_inc_vat"] + +def __get_valid_to(rate): + return rate["valid_to"] + +def calculate_continuous_times(current_date: datetime, target_start_time: str, target_end_time: str, target_hours: float, rates, is_rolling_target = True, search_for_highest_rate = False): + applicable_rates = __get_applicable_rates(current_date, target_start_time, target_end_time, rates, is_rolling_target) + if (applicable_rates is None): + return [] + + applicable_rates_count = len(applicable_rates) + total_required_rates = math.ceil(target_hours * 2) + + best_continuous_rates = None + best_continuous_rates_total = None + + _LOGGER.debug(f'{applicable_rates_count} applicable rates found') + + # Loop through our rates and try and find the block of time that meets our desired + # hours and has the lowest combined rates + for index, rate in enumerate(applicable_rates): + continuous_rates = [rate] + continuous_rates_total = rate["value_inc_vat"] + + for offset in range(1, total_required_rates): + if (index + offset) < applicable_rates_count: + offset_rate = applicable_rates[(index + offset)] + continuous_rates.append(offset_rate) + continuous_rates_total += offset_rate["value_inc_vat"] + else: + break + + if ((best_continuous_rates == None or (search_for_highest_rate == False and continuous_rates_total < best_continuous_rates_total) or (search_for_highest_rate and continuous_rates_total > best_continuous_rates_total)) and len(continuous_rates) == total_required_rates): + best_continuous_rates = continuous_rates + best_continuous_rates_total = continuous_rates_total + else: + _LOGGER.debug(f'Total rates for current block {continuous_rates_total}. Total rates for best block {best_continuous_rates_total}') + + if best_continuous_rates is not None: + # Make sure our rates are in ascending order before returning + best_continuous_rates.sort(key=__get_valid_to) + return best_continuous_rates + + return [] + +def calculate_intermittent_times(current_date: datetime, target_start_time: str, target_end_time: str, target_hours: float, rates, is_rolling_target = True, search_for_highest_rate = False): + applicable_rates = __get_applicable_rates(current_date, target_start_time, target_end_time, rates, is_rolling_target) + if (applicable_rates is None): + return [] + + total_required_rates = math.ceil(target_hours * 2) + + applicable_rates.sort(key=__get_rate, reverse=search_for_highest_rate) + applicable_rates = applicable_rates[:total_required_rates] + + _LOGGER.debug(f'{len(applicable_rates)} applicable rates found') + + if (len(applicable_rates) < total_required_rates): + return [] + + # Make sure our rates are in ascending order before returning + applicable_rates.sort(key=__get_valid_to) + return applicable_rates + +def get_target_rate_info(current_date: datetime, applicable_rates, offset: str = None): + is_active = False + next_time = None + current_duration_in_hours = 0 + next_duration_in_hours = 0 + total_applicable_rates = len(applicable_rates) + + overall_total_cost = 0 + overall_min_cost = None + overall_max_cost = None + + current_average_cost = None + current_min_cost = None + current_max_cost = None + + next_average_cost = None + next_min_cost = None + next_max_cost = None + + if (total_applicable_rates > 0): + + # Find the applicable rates that when combine become a continuous block. This is more for + # intermittent rates. + applicable_rates.sort(key=__get_valid_to) + applicable_rate_blocks = list() + block_valid_from = applicable_rates[0]["valid_from"] + + total_cost = 0 + min_cost = None + max_cost = None + + for index, rate in enumerate(applicable_rates): + if (index > 0 and applicable_rates[index - 1]["valid_to"] != rate["valid_from"]): + diff = applicable_rates[index - 1]["valid_to"] - block_valid_from + minutes = diff.total_seconds() / 60 + applicable_rate_blocks.append({ + "valid_from": block_valid_from, + "valid_to": applicable_rates[index - 1]["valid_to"], + "duration_in_hours": minutes / 60, + "average_cost": total_cost / (minutes / 30), + "min_cost": min_cost, + "max_cost": max_cost + }) + + block_valid_from = rate["valid_from"] + total_cost = 0 + min_cost = None + max_cost = None + + total_cost += rate["value_inc_vat"] + if min_cost is None or min_cost > rate["value_inc_vat"]: + min_cost = rate["value_inc_vat"] + + if max_cost is None or max_cost < rate["value_inc_vat"]: + max_cost = rate["value_inc_vat"] + + overall_total_cost += rate["value_inc_vat"] + if overall_min_cost is None or overall_min_cost > rate["value_inc_vat"]: + overall_min_cost = rate["value_inc_vat"] + + if overall_max_cost is None or overall_max_cost < rate["value_inc_vat"]: + overall_max_cost = rate["value_inc_vat"] + + # Make sure our final block is added + diff = applicable_rates[-1]["valid_to"] - block_valid_from + minutes = diff.total_seconds() / 60 + applicable_rate_blocks.append({ + "valid_from": block_valid_from, + "valid_to": applicable_rates[-1]["valid_to"], + "duration_in_hours": minutes / 60, + "average_cost": total_cost / (minutes / 30), + "min_cost": min_cost, + "max_cost": max_cost + }) + + # Find out if we're within an active block, or find the next block + for index, rate in enumerate(applicable_rate_blocks): + if (offset != None): + valid_from = apply_offset(rate["valid_from"], offset) + valid_to = apply_offset(rate["valid_to"], offset) + else: + valid_from = rate["valid_from"] + valid_to = rate["valid_to"] + + if current_date >= valid_from and current_date < valid_to: + current_duration_in_hours = rate["duration_in_hours"] + current_average_cost = rate["average_cost"] + current_min_cost = rate["min_cost"] + current_max_cost = rate["max_cost"] + is_active = True + elif current_date < valid_from: + next_time = valid_from + next_duration_in_hours = rate["duration_in_hours"] + next_average_cost = rate["average_cost"] + next_min_cost = rate["min_cost"] + next_max_cost = rate["max_cost"] + break + + return { + "is_active": is_active, + "overall_average_cost": round(overall_total_cost / total_applicable_rates, 5) if total_applicable_rates > 0 else 0, + "overall_min_cost": overall_min_cost, + "overall_max_cost": overall_max_cost, + "current_duration_in_hours": current_duration_in_hours, + "current_average_cost": current_average_cost, + "current_min_cost": current_min_cost, + "current_max_cost": current_max_cost, + "next_time": next_time, + "next_duration_in_hours": next_duration_in_hours, + "next_average_cost": next_average_cost, + "next_min_cost": next_min_cost, + "next_max_cost": next_max_cost, + } diff --git a/custom_components/octopus_energy/binary_sensors/saving_sessions.py b/custom_components/octopus_energy/binary_sensors/saving_sessions.py new file mode 100644 index 00000000..1f4780da --- /dev/null +++ b/custom_components/octopus_energy/binary_sensors/saving_sessions.py @@ -0,0 +1,100 @@ +import logging + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from ..sensors import ( + current_saving_sessions_event, + get_next_saving_sessions_event +) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergySavingSessions(CoordinatorEntity, BinarySensorEntity, RestoreEntity): + """Sensor for determining if a saving session is active.""" + + def __init__(self, coordinator): + """Init sensor.""" + + super().__init__(coordinator) + + self._state = None + self._events = [] + self._attributes = { + "joined_events": [], + "next_joined_event_start": None + } + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_saving_sessions" + + @property + def name(self): + """Name of the sensor.""" + return f"Octopus Energy Saving Session" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:leaf" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def is_on(self): + """The state of the sensor.""" + saving_session = self.coordinator.data + if (saving_session is not None and "events" in saving_session): + self._events = saving_session["events"] + else: + self._events = [] + + self._attributes = { + "joined_events": self._events, + "next_joined_event_start": None, + "next_joined_event_end": None, + "next_joined_event_duration_in_minutes": None + } + + current_date = now() + current_event = current_saving_sessions_event(current_date, self._events) + if (current_event is not None): + self._state = True + self._attributes["current_joined_event_start"] = current_event["start"] + self._attributes["current_joined_event_end"] = current_event["end"] + self._attributes["current_joined_event_duration_in_minutes"] = current_event["duration_in_minutes"] + else: + self._state = False + + next_event = get_next_saving_sessions_event(current_date, self._events) + if (next_event is not None): + self._attributes["next_joined_event_start"] = next_event["start"] + self._attributes["next_joined_event_end"] = next_event["end"] + self._attributes["next_joined_event_duration_in_minutes"] = next_event["duration_in_minutes"] + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None: + self._state = state.state + + if (self._state is None): + self._state = False + + _LOGGER.debug(f'Restored state: {self._state}') diff --git a/custom_components/octopus_energy/binary_sensors/target_rate.py b/custom_components/octopus_energy/binary_sensors/target_rate.py new file mode 100644 index 00000000..9eb54c3f --- /dev/null +++ b/custom_components/octopus_energy/binary_sensors/target_rate.py @@ -0,0 +1,233 @@ +import logging +from custom_components.octopus_energy.utils import apply_offset + +import re +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.util.dt import (utcnow, now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, +) +from ..const import ( + CONFIG_TARGET_OFFSET, + + CONFIG_TARGET_NAME, + CONFIG_TARGET_HOURS, + CONFIG_TARGET_TYPE, + CONFIG_TARGET_START_TIME, + CONFIG_TARGET_END_TIME, + CONFIG_TARGET_MPAN, + CONFIG_TARGET_ROLLING_TARGET, + + REGEX_HOURS, + REGEX_TIME, + REGEX_OFFSET_PARTS, +) + +from . import ( + calculate_continuous_times, + calculate_intermittent_times, + get_target_rate_info +) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyTargetRate(CoordinatorEntity, BinarySensorEntity): + """Sensor for calculating when a target should be turned on or off.""" + + def __init__(self, coordinator, config, is_export): + """Init sensor.""" + # Pass coordinator to base class + super().__init__(coordinator) + + self._config = config + self._is_export = is_export + self._attributes = self._config.copy() + self._is_export = is_export + self._attributes["is_target_export"] = is_export + is_rolling_target = True + if CONFIG_TARGET_ROLLING_TARGET in self._config: + is_rolling_target = self._config[CONFIG_TARGET_ROLLING_TARGET] + self._attributes[CONFIG_TARGET_ROLLING_TARGET] = is_rolling_target + self._target_rates = [] + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_target_{self._config[CONFIG_TARGET_NAME]}" + + @property + def name(self): + """Name of the sensor.""" + return f"Octopus Energy Target {self._config[CONFIG_TARGET_NAME]}" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:camera-timer" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def is_on(self): + """The state of the sensor.""" + + if CONFIG_TARGET_OFFSET in self._config: + offset = self._config[CONFIG_TARGET_OFFSET] + else: + offset = None + + # Find the current rate. Rates change a maximum of once every 30 minutes. + current_date = utcnow() + if (current_date.minute % 30) == 0 or len(self._target_rates) == 0: + _LOGGER.debug(f'Updating OctopusEnergyTargetRate {self._config[CONFIG_TARGET_NAME]}') + + # If all of our target times have passed, it's time to recalculate the next set + all_rates_in_past = True + for rate in self._target_rates: + if rate["valid_to"] > current_date: + all_rates_in_past = False + break + + if all_rates_in_past: + if self.coordinator.data != None: + all_rates = self.coordinator.data + + # Retrieve our rates. For backwards compatibility, if CONFIG_TARGET_MPAN is not set, then pick the first set + if CONFIG_TARGET_MPAN not in self._config: + _LOGGER.debug(f"'CONFIG_TARGET_MPAN' not set.'{len(all_rates)}' rates available. Retrieving the first rate.") + all_rates = next(iter(all_rates.values())) + else: + _LOGGER.debug(f"Retrieving rates for '{self._config[CONFIG_TARGET_MPAN]}'") + all_rates = all_rates.get(self._config[CONFIG_TARGET_MPAN]) + else: + _LOGGER.debug(f"Rate data missing. Setting to empty array") + all_rates = [] + + _LOGGER.debug(f'{len(all_rates) if all_rates != None else None} rate periods found') + + start_time = None + if CONFIG_TARGET_START_TIME in self._config: + start_time = self._config[CONFIG_TARGET_START_TIME] + + end_time = None + if CONFIG_TARGET_END_TIME in self._config: + end_time = self._config[CONFIG_TARGET_END_TIME] + + # True by default for backwards compatibility + is_rolling_target = True + if CONFIG_TARGET_ROLLING_TARGET in self._config: + is_rolling_target = self._config[CONFIG_TARGET_ROLLING_TARGET] + + target_hours = float(self._config[CONFIG_TARGET_HOURS]) + + if (self._config[CONFIG_TARGET_TYPE] == "Continuous"): + self._target_rates = calculate_continuous_times( + now(), + start_time, + end_time, + target_hours, + all_rates, + is_rolling_target, + self._is_export + ) + elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"): + self._target_rates = calculate_intermittent_times( + now(), + start_time, + end_time, + target_hours, + all_rates, + is_rolling_target, + self._is_export + ) + else: + _LOGGER.error(f"Unexpected target type: {self._config[CONFIG_TARGET_TYPE]}") + + self._attributes["target_times"] = self._target_rates + + active_result = get_target_rate_info(current_date, self._target_rates, offset) + + self._attributes["overall_average_cost"] = f'{active_result["overall_average_cost"]}p' if active_result["overall_average_cost"] is not None else None + self._attributes["overall_min_cost"] = f'{active_result["overall_min_cost"]}p' if active_result["overall_min_cost"] is not None else None + self._attributes["overall_max_cost"] = f'{active_result["overall_max_cost"]}p' if active_result["overall_max_cost"] is not None else None + + self._attributes["current_duration_in_hours"] = active_result["current_duration_in_hours"] + self._attributes["current_average_cost"] = f'{active_result["current_average_cost"]}p' if active_result["current_average_cost"] is not None else None + self._attributes["current_min_cost"] = f'{active_result["current_min_cost"]}p' if active_result["current_min_cost"] is not None else None + self._attributes["current_max_cost"] = f'{active_result["current_max_cost"]}p' if active_result["current_max_cost"] is not None else None + + self._attributes["next_time"] = active_result["next_time"] + self._attributes["next_duration_in_hours"] = active_result["next_duration_in_hours"] + self._attributes["next_average_cost"] = f'{active_result["next_average_cost"]}p' if active_result["next_average_cost"] is not None else None + self._attributes["next_min_cost"] = f'{active_result["next_min_cost"]}p' if active_result["next_min_cost"] is not None else None + self._attributes["next_max_cost"] = f'{active_result["next_max_cost"]}p' if active_result["next_max_cost"] is not None else None + + return active_result["is_active"] + + @callback + def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None): + """Update sensors config""" + + config = dict(self._config) + + if target_hours is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_hours = target_hours.strip('\"') + matches = re.search(REGEX_HOURS, trimmed_target_hours) + if matches == None: + raise vol.Invalid(f"Target hours of '{trimmed_target_hours}' must be in half hour increments.") + else: + trimmed_target_hours = float(trimmed_target_hours) + if trimmed_target_hours % 0.5 != 0: + raise vol.Invalid(f"Target hours of '{trimmed_target_hours}' must be in half hour increments.") + else: + config.update({ + CONFIG_TARGET_HOURS: trimmed_target_hours + }) + + if target_start_time is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_start_time = target_start_time.strip('\"') + matches = re.search(REGEX_TIME, trimmed_target_start_time) + if matches == None: + raise vol.Invalid("Start time must be in the format HH:MM") + else: + config.update({ + CONFIG_TARGET_START_TIME: trimmed_target_start_time + }) + + if target_end_time is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_end_time = target_end_time.strip('\"') + matches = re.search(REGEX_TIME, trimmed_target_end_time) + if matches == None: + raise vol.Invalid("End time must be in the format HH:MM") + else: + config.update({ + CONFIG_TARGET_END_TIME: trimmed_target_end_time + }) + + if target_offset is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_offset = target_offset.strip('\"') + matches = re.search(REGEX_OFFSET_PARTS, trimmed_target_offset) + if matches == None: + raise vol.Invalid("Offset must be in the form of HH:MM:SS with an optional negative symbol") + else: + config.update({ + CONFIG_TARGET_OFFSET: trimmed_target_offset + }) + + self._config = config + self._attributes = self._config.copy() + self._attributes["is_target_export"] = self._is_export + self._target_rates = [] + self.async_write_ha_state() \ No newline at end of file diff --git a/custom_components/octopus_energy/config_flow.py b/custom_components/octopus_energy/config_flow.py index 0d7605fd..05b125f8 100644 --- a/custom_components/octopus_energy/config_flow.py +++ b/custom_components/octopus_energy/config_flow.py @@ -13,6 +13,12 @@ CONFIG_MAIN_API_KEY, CONFIG_MAIN_ACCOUNT_ID, + CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION, + CONFIG_MAIN_CALORIFIC_VALUE, + CONFIG_MAIN_ELECTRICITY_PRICE_CAP, + CONFIG_MAIN_CLEAR_ELECTRICITY_PRICE_CAP, + CONFIG_MAIN_GAS_PRICE_CAP, + CONFIG_MAIN_CLEAR_GAS_PRICE_CAP, CONFIG_TARGET_NAME, CONFIG_TARGET_HOURS, @@ -81,7 +87,15 @@ async def async_setup_initial_account(self, user_input): """Setup the initial account based on the provided user input""" errors = {} - client = OctopusEnergyApiClient(user_input[CONFIG_MAIN_API_KEY]) + electricity_price_cap = None + if CONFIG_MAIN_ELECTRICITY_PRICE_CAP in user_input: + electricity_price_cap = user_input[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] + + gas_price_cap = None + if CONFIG_MAIN_GAS_PRICE_CAP in user_input: + gas_price_cap = user_input[CONFIG_MAIN_GAS_PRICE_CAP] + + client = OctopusEnergyApiClient(user_input[CONFIG_MAIN_API_KEY], electricity_price_cap, gas_price_cap) account_info = await client.async_get_account(user_input[CONFIG_MAIN_ACCOUNT_ID]) if (account_info == None): errors[CONFIG_MAIN_ACCOUNT_ID] = "account_not_found" @@ -91,7 +105,7 @@ async def async_setup_initial_account(self, user_input): # Setup our basic sensors return self.async_create_entry( - title="Octopus Energy", + title="Account", data=user_input ) @@ -101,7 +115,7 @@ async def async_setup_target_rate_schema(self): meters = [] now = utcnow() - if len(account_info["electricity_meter_points"]) > 0: + if account_info is not None and len(account_info["electricity_meter_points"]) > 0: for point in account_info["electricity_meter_points"]: active_tariff_code = get_active_tariff_code(now, point["agreements"]) if active_tariff_code != None: @@ -183,10 +197,12 @@ def __init__(self, entry) -> None: async def __async_setup_target_rate_schema(self, config, errors): client = self.hass.data[DOMAIN][DATA_CLIENT] account_info = await client.async_get_account(self.hass.data[DOMAIN][DATA_ACCOUNT_ID]) + if account_info is None: + errors[CONFIG_TARGET_MPAN] = "account_not_found" meters = [] now = utcnow() - if len(account_info["electricity_meter_points"]) > 0: + if account_info is not None and len(account_info["electricity_meter_points"]) > 0: for point in account_info["electricity_meter_points"]: active_tariff_code = get_active_tariff_code(now, point["agreements"]) if active_tariff_code != None: @@ -215,7 +231,12 @@ async def __async_setup_target_rate_schema(self, config, errors): return self.async_show_form( step_id="target_rate", data_schema=vol.Schema({ + vol.Required(CONFIG_TARGET_NAME, default=config[CONFIG_TARGET_NAME]): str, vol.Required(CONFIG_TARGET_HOURS, default=f'{config[CONFIG_TARGET_HOURS]}'): str, + vol.Required(CONFIG_TARGET_TYPE, default=config[CONFIG_TARGET_TYPE]): vol.In({ + "Continuous": "Continuous", + "Intermittent": "Intermittent" + }), vol.Required(CONFIG_TARGET_MPAN, default=config[CONFIG_TARGET_MPAN]): vol.In( meters ), @@ -234,10 +255,32 @@ async def async_step_init(self, user_input): config = dict(self._entry.data) if self._entry.options is not None: config.update(self._entry.options) + + supports_live_consumption = False + if CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION in config: + supports_live_consumption = config[CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION] + + calorific_value = 40 + if CONFIG_MAIN_CALORIFIC_VALUE in config: + calorific_value = config[CONFIG_MAIN_CALORIFIC_VALUE] + + electricity_price_cap_key = vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP) + if (CONFIG_MAIN_ELECTRICITY_PRICE_CAP in config): + electricity_price_cap_key = vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP, default=config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP]) + + gas_price_cap_key = vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP) + if (CONFIG_MAIN_GAS_PRICE_CAP in config): + gas_price_cap_key = vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP, default=config[CONFIG_MAIN_GAS_PRICE_CAP]) return self.async_show_form( step_id="user", data_schema=vol.Schema({ vol.Required(CONFIG_MAIN_API_KEY, default=config[CONFIG_MAIN_API_KEY]): str, + vol.Required(CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION, default=supports_live_consumption): bool, + vol.Required(CONFIG_MAIN_CALORIFIC_VALUE, default=calorific_value): cv.positive_float, + electricity_price_cap_key: cv.positive_float, + vol.Required(CONFIG_MAIN_CLEAR_ELECTRICITY_PRICE_CAP): bool, + gas_price_cap_key: cv.positive_float, + vol.Required(CONFIG_MAIN_CLEAR_GAS_PRICE_CAP): bool, }) ) elif CONFIG_TARGET_TYPE in self._entry.data: @@ -255,6 +298,13 @@ async def async_step_user(self, user_input): if user_input is not None: config = dict(self._entry.data) config.update(user_input) + + if config[CONFIG_MAIN_CLEAR_ELECTRICITY_PRICE_CAP] == True: + del config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] + + if config[CONFIG_MAIN_CLEAR_GAS_PRICE_CAP] == True: + del config[CONFIG_MAIN_GAS_PRICE_CAP] + return self.async_create_entry(title="", data=config) return self.async_abort(reason="not_supported") diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index a0f69ac0..f97985e9 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -1,9 +1,16 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv DOMAIN = "octopus_energy" CONFIG_MAIN_API_KEY = "Api key" CONFIG_MAIN_ACCOUNT_ID = "Account Id" +CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION = "supports_live_consumption" +CONFIG_MAIN_CALORIFIC_VALUE = "calorific_value" +CONFIG_MAIN_ELECTRICITY_PRICE_CAP = "electricity_price_cap" +CONFIG_MAIN_CLEAR_ELECTRICITY_PRICE_CAP = "clear_electricity_price_cap" +CONFIG_MAIN_GAS_PRICE_CAP = "gas_price_cap" +CONFIG_MAIN_CLEAR_GAS_PRICE_CAP = "clear_gas_price_cap" CONFIG_TARGET_NAME = "Name" CONFIG_TARGET_HOURS = "Hours" @@ -23,14 +30,22 @@ DATA_ACCOUNT = "ACCOUNT" DATA_SAVING_SESSIONS = "SAVING_SESSIONS" DATA_SAVING_SESSIONS_COORDINATOR = "SAVING_SESSIONS_COORDINATOR" +DATA_KNOWN_TARIFF = "KNOWN_TARIFF" +DATA_GAS_RATES = "GAS_RATES" REGEX_HOURS = "^[0-9]+(\\.[0-9]+)*$" REGEX_TIME = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$" REGEX_ENTITY_NAME = "^[a-z0-9_]+$" -REGEX_TARIFF_PARTS = "^([A-Z])-([0-9A-Z]+)-([A-Z0-9-]+)-([A-Z])$" +# According to https://www.guylipman.com/octopus/api_guide.html#s1b, this part should indicate the types of tariff +# However it looks like there are some tariffs that don't fit this mold +REGEX_TARIFF_PARTS = "^((?P[A-Z])-(?P[0-9A-Z]+)-)?(?P[A-Z0-9-]+)-(?P[A-Z])$" REGEX_OFFSET_PARTS = "^(-)?([0-1]?[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$" DATA_SCHEMA_ACCOUNT = vol.Schema({ vol.Required(CONFIG_MAIN_API_KEY): str, vol.Required(CONFIG_MAIN_ACCOUNT_ID): str, + vol.Required(CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION): bool, + vol.Required(CONFIG_MAIN_CALORIFIC_VALUE, default=40.0): cv.positive_float, + vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP): cv.positive_float, + vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP): cv.positive_float }) diff --git a/custom_components/octopus_energy/diagnostics.py b/custom_components/octopus_energy/diagnostics.py index eaa2f23b..23943d95 100644 --- a/custom_components/octopus_energy/diagnostics.py +++ b/custom_components/octopus_energy/diagnostics.py @@ -21,21 +21,21 @@ async def async_get_device_diagnostics(hass, config_entry, device): account_info = await client.async_get_account(hass.data[DOMAIN][DATA_ACCOUNT_ID]) - points_length = len(account_info["electricity_meter_points"]) - if points_length > 0: + points_length = account_info is not None and len(account_info["electricity_meter_points"]) + if account_info is not None and points_length > 0: for point_index in range(points_length): account_info["electricity_meter_points"][point_index] = async_redact_data(account_info["electricity_meter_points"][point_index], { "mpan" }) meters_length = len(account_info["electricity_meter_points"][point_index]["meters"]) for meter_index in range(meters_length): - account_info["electricity_meter_points"][point_index]["meters"][meter_index] = async_redact_data(account_info["electricity_meter_points"][point_index]["meters"][meter_index], { "serial_number" }) + account_info["electricity_meter_points"][point_index]["meters"][meter_index] = async_redact_data(account_info["electricity_meter_points"][point_index]["meters"][meter_index], { "serial_number", "device_id" }) - points_length = len(account_info["gas_meter_points"]) - if points_length > 0: + points_length = account_info is not None and len(account_info["gas_meter_points"]) + if account_info is not None and points_length > 0: for point_index in range(points_length): account_info["gas_meter_points"][point_index] = async_redact_data(account_info["gas_meter_points"][point_index], { "mprn" }) meters_length = len(account_info["gas_meter_points"][point_index]["meters"]) for meter_index in range(meters_length): - account_info["gas_meter_points"][point_index]["meters"][meter_index] = async_redact_data(account_info["gas_meter_points"][point_index]["meters"][meter_index], { "serial_number" }) + account_info["gas_meter_points"][point_index]["meters"][meter_index] = async_redact_data(account_info["gas_meter_points"][point_index]["meters"][meter_index], { "serial_number", "device_id" }) _LOGGER.info(f'Returning diagnostic details; {len(account_info["electricity_meter_points"])} electricity meter point(s), {len(account_info["gas_meter_points"])} gas meter point(s)') diff --git a/custom_components/octopus_energy/manifest.json b/custom_components/octopus_energy/manifest.json index f0b47316..7f0d4b64 100644 --- a/custom_components/octopus_energy/manifest.json +++ b/custom_components/octopus_energy/manifest.json @@ -1,16 +1,18 @@ { "domain": "octopus_energy", "name": "Octopus Energy", + "codeowners": [ + "@bottlecapdave" + ], "config_flow": true, + "dependencies": [ + "repairs" + ], "documentation": "https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/", + "homekit": {}, + "iot_class": "cloud_polling", "issue_tracker": "https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues", "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], - "codeowners": [ - "@bottlecapdave" - ], - "version": "5.4.1", - "iot_class": "cloud_polling" + "version": "6.6.1", + "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index dfdd3272..173904be 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,28 +1,32 @@ from datetime import timedelta import logging +from .utils.check_tariff import async_check_valid_tariff +from .sensors.electricity.current_consumption import OctopusEnergyCurrentElectricityConsumption +from .sensors.electricity.current_demand import OctopusEnergyCurrentElectricityDemand +from .sensors.electricity.current_rate import OctopusEnergyElectricityCurrentRate +from .sensors.electricity.next_rate import OctopusEnergyElectricityNextRate +from .sensors.electricity.previous_accumulative_consumption import OctopusEnergyPreviousAccumulativeElectricityConsumption +from .sensors.electricity.previous_accumulative_cost import OctopusEnergyPreviousAccumulativeElectricityCost +from .sensors.electricity.previous_rate import OctopusEnergyElectricityPreviousRate +from .sensors.electricity.standing_charge import OctopusEnergyElectricityCurrentStandingCharge +from .sensors.gas.current_rate import OctopusEnergyGasCurrentRate +from .sensors.gas.previous_accumulative_consumption import OctopusEnergyPreviousAccumulativeGasConsumption +from .sensors.gas.previous_accumulative_consumption_kwh import OctopusEnergyPreviousAccumulativeGasConsumptionKwh +from .sensors.gas.previous_accumulative_cost import OctopusEnergyPreviousAccumulativeGasCost +from .sensors.gas.current_consumption import OctopusEnergyCurrentGasConsumption +from .sensors.gas.standing_charge import OctopusEnergyGasCurrentStandingCharge + +from .sensors.saving_sessions.points import OctopusEnergySavingSessionPoints + from homeassistant.util.dt import (utcnow, now, as_utc, parse_datetime) from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, DataUpdateCoordinator ) -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass -) -from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - VOLUME_CUBIC_METERS -) -from homeassistant.helpers.restore_state import RestoreEntity -from .sensor_utils import ( +from .sensors import ( async_get_consumption_data, - calculate_electricity_consumption, - async_calculate_electricity_cost, - calculate_gas_consumption, - async_calculate_gas_cost + async_get_live_consumption ) from .utils import (get_active_tariff_code) @@ -30,18 +34,25 @@ DOMAIN, CONFIG_MAIN_API_KEY, + CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION, + CONFIG_MAIN_CALORIFIC_VALUE, + CONFIG_MAIN_ELECTRICITY_PRICE_CAP, + CONFIG_MAIN_GAS_PRICE_CAP, DATA_ELECTRICITY_RATES_COORDINATOR, DATA_SAVING_SESSIONS_COORDINATOR, DATA_CLIENT, - DATA_ACCOUNT + DATA_ACCOUNT, + DATA_GAS_RATES ) +from .api_client import (OctopusEnergyApiClient) + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=1) -def create_reading_coordinator(hass, client, is_electricity, identifier, serial_number): +def create_reading_coordinator(hass, client: OctopusEnergyApiClient, is_electricity: bool, identifier: str, serial_number: str): """Create reading coordinator""" async def async_update_data(): @@ -75,7 +86,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, - name="rates", + name=f"rates_{identifier}_{serial_number}", update_method=async_update_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes @@ -86,6 +97,61 @@ async def async_update_data(): return coordinator +def create_current_consumption_coordinator(hass, client: OctopusEnergyApiClient, device_id: str, is_electricity: bool): + """Create current consumption coordinator""" + + async def async_update_data(): + """Fetch data from API endpoint.""" + previous_current_consumption_date_key = f'{device_id}_previous_current_consumption_date' + last_date = None + if previous_current_consumption_date_key in hass.data[DOMAIN]: + last_date = hass.data[DOMAIN][previous_current_consumption_date_key] + elif is_electricity == False: + last_date = (now() - timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + + data = await async_get_live_consumption(client, device_id, utcnow(), last_date) + if data is not None: + hass.data[DOMAIN][previous_current_consumption_date_key] = data["startAt"] + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"current_consumption_{device_id}", + update_method=async_update_data, + update_interval=timedelta(minutes=1), + ) + + return coordinator + +def create_gas_rate_coordinator(hass, client: OctopusEnergyApiClient, tariff_code: str): + """Create gas rate coordinator""" + + async def async_update_data(): + """Fetch data from API endpoint.""" + current = utcnow() + + rate_key = f'{DATA_GAS_RATES}_{tariff_code}' + if (rate_key not in hass.data[DOMAIN] or (current.minute % 30) == 0 or len(hass.data[DOMAIN][rate_key]) == 0): + period_from = as_utc(parse_datetime(current.strftime("%Y-%m-%dT00:00:00Z"))) + period_to = as_utc(parse_datetime((current + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) + + hass.data[DOMAIN][rate_key] = await client.async_get_gas_rates(tariff_code, period_from, period_to) + await async_check_valid_tariff(hass, client, tariff_code, False) + + return hass.data[DOMAIN][rate_key] + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"gas_rates_{tariff_code}", + update_method=async_update_data, + update_interval=timedelta(minutes=1), + ) + + return coordinator + async def async_setup_entry(hass, entry, async_add_entities): """Setup sensors based on our entry""" @@ -115,19 +181,30 @@ async def async_setup_default_sensors(hass, entry, async_add_entities): now = utcnow() if len(account_info["electricity_meter_points"]) > 0: + electricity_price_cap = None + if CONFIG_MAIN_ELECTRICITY_PRICE_CAP in config: + electricity_price_cap = config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] + for point in account_info["electricity_meter_points"]: # We only care about points that have active agreements electricity_tariff_code = get_active_tariff_code(now, point["agreements"]) if electricity_tariff_code != None: for meter in point["meters"]: _LOGGER.info(f'Adding electricity meter; mpan: {point["mpan"]}; serial number: {meter["serial_number"]}') - coordinator = create_reading_coordinator(hass, client, True, point["mpan"], meter["serial_number"]) - entities.append(OctopusEnergyPreviousAccumulativeElectricityReading(coordinator, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) - entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(coordinator, client, electricity_tariff_code, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) - entities.append(OctopusEnergyElectricityCurrentRate(rate_coordinator, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) - entities.append(OctopusEnergyElectricityPreviousRate(rate_coordinator, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) - entities.append(OctopusEnergyElectricityNextRate(rate_coordinator, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) - entities.append(OctopusEnergyElectricityCurrentStandingCharge(client, electricity_tariff_code, point["mpan"], meter["serial_number"], meter["is_export"], meter["is_smart_meter"])) + entities.append(OctopusEnergyElectricityCurrentRate(rate_coordinator, meter, point, electricity_price_cap)) + entities.append(OctopusEnergyElectricityPreviousRate(rate_coordinator, meter, point)) + entities.append(OctopusEnergyElectricityNextRate(rate_coordinator, meter, point)) + entities.append(OctopusEnergyElectricityCurrentStandingCharge(client, electricity_tariff_code, meter, point)) + + if meter["is_smart_meter"] == True: + coordinator = create_reading_coordinator(hass, client, True, point["mpan"], meter["serial_number"]) + entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(coordinator, meter, point)) + entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(coordinator, client, electricity_tariff_code, meter, point)) + + if meter["is_export"] == False and CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION in config and config[CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION] == True: + consumption_coordinator = create_current_consumption_coordinator(hass, client, meter["device_id"], True) + entities.append(OctopusEnergyCurrentElectricityConsumption(consumption_coordinator, meter, point)) + entities.append(OctopusEnergyCurrentElectricityDemand(consumption_coordinator, meter, point)) else: for meter in point["meters"]: _LOGGER.info(f'Skipping electricity meter due to no active agreement; mpan: {point["mpan"]}; serial number: {meter["serial_number"]}') @@ -136,18 +213,34 @@ async def async_setup_default_sensors(hass, entry, async_add_entities): _LOGGER.info('No electricity meters available') if len(account_info["gas_meter_points"]) > 0: + + calorific_value = 40 + if CONFIG_MAIN_CALORIFIC_VALUE in config: + calorific_value = config[CONFIG_MAIN_CALORIFIC_VALUE] + + gas_price_cap = None + if CONFIG_MAIN_GAS_PRICE_CAP in config: + gas_price_cap = config[CONFIG_MAIN_GAS_PRICE_CAP] + for point in account_info["gas_meter_points"]: # We only care about points that have active agreements gas_tariff_code = get_active_tariff_code(now, point["agreements"]) if gas_tariff_code != None: for meter in point["meters"]: _LOGGER.info(f'Adding gas meter; mprn: {point["mprn"]}; serial number: {meter["serial_number"]}') - coordinator = create_reading_coordinator(hass, client, False, point["mprn"], meter["serial_number"]) - entities.append(OctopusEnergyPreviousAccumulativeGasReading(coordinator, point["mprn"], meter["serial_number"], meter["consumption_units"])) - entities.append(OctopusEnergyPreviousAccumulativeGasReadingKwh(coordinator, point["mprn"], meter["serial_number"], meter["consumption_units"])) - entities.append(OctopusEnergyPreviousAccumulativeGasCost(coordinator, client, gas_tariff_code, point["mprn"], meter["serial_number"], meter["consumption_units"])) - entities.append(OctopusEnergyGasCurrentRate(client, gas_tariff_code, point["mprn"], meter["serial_number"])) - entities.append(OctopusEnergyGasCurrentStandingCharge(client, gas_tariff_code, point["mprn"], meter["serial_number"])) + rate_coordinator = create_gas_rate_coordinator(hass, client, gas_tariff_code) + entities.append(OctopusEnergyGasCurrentRate(rate_coordinator, gas_tariff_code, meter, point, gas_price_cap)) + entities.append(OctopusEnergyGasCurrentStandingCharge(client, gas_tariff_code, meter, point)) + + if meter["is_smart_meter"] == True: + previous_consumption_coordinator = create_reading_coordinator(hass, client, False, point["mprn"], meter["serial_number"]) + entities.append(OctopusEnergyPreviousAccumulativeGasConsumption(previous_consumption_coordinator, meter, point, calorific_value)) + entities.append(OctopusEnergyPreviousAccumulativeGasConsumptionKwh(previous_consumption_coordinator, meter, point, calorific_value)) + entities.append(OctopusEnergyPreviousAccumulativeGasCost(previous_consumption_coordinator, client, gas_tariff_code, meter, point, calorific_value)) + + if CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION in config and config[CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION] == True: + consumption_coordinator = create_current_consumption_coordinator(hass, client, meter["device_id"], False) + entities.append(OctopusEnergyCurrentGasConsumption(consumption_coordinator, meter, point)) else: for meter in point["meters"]: _LOGGER.info(f'Skipping gas meter due to no active agreement; mprn: {point["mprn"]}; serial number: {meter["serial_number"]}') @@ -156,1162 +249,3 @@ async def async_setup_default_sensors(hass, entry, async_add_entities): _LOGGER.info('No gas meters available') async_add_entities(entities, True) - -class OctopusEnergyElectricitySensor(SensorEntity, RestoreEntity): - def __init__(self, mpan, serial_number, is_export, is_smart_meter): - """Init sensor""" - self._mpan = mpan - self._serial_number = serial_number - self._is_export = is_export - self._is_smart_meter = is_smart_meter - - self._attributes = { - "mpan": self._mpan, - "serial_number": self._serial_number, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter - } - - @property - def device_info(self): - return { - "identifiers": { - # Serial numbers/mpan are unique identifiers within a specific domain - (DOMAIN, f"electricity_{self._serial_number}_{self._mpan}") - }, - "default_name": "Electricity Meter", - } - -class OctopusEnergyElectricityCurrentRate(CoordinatorEntity, OctopusEnergyElectricitySensor): - """Sensor for displaying the current rate.""" - - def __init__(self, coordinator, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - # Pass coordinator to base class - super().__init__(coordinator) - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._state = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_rate" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Electricity {self._serial_number} {self._mpan} Current Rate" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP/kWh" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """The state of the sensor.""" - # Find the current rate. We only need to do this every half an hour - now = utcnow() - if (now.minute % 30) == 0 or self._state == None: - _LOGGER.debug(f"Updating OctopusEnergyElectricityCurrentRate for '{self._mpan}/{self._serial_number}'") - - current_rate = None - if self.coordinator.data != None: - rate = self.coordinator.data[self._mpan] - if rate != None: - for period in rate: - if now >= period["valid_from"] and now <= period["valid_to"]: - current_rate = period - break - - if current_rate != None: - ratesAttributes = list(map(lambda x: { - "from": x["valid_from"], - "to": x["valid_to"], - "rate": x["value_inc_vat"] - }, rate)) - self._attributes = { - "rate": current_rate, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter, - "rates": ratesAttributes - } - - self._state = current_rate["value_inc_vat"] / 100 - else: - self._state = None - self._attributes = {} - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyElectricityPreviousRate(CoordinatorEntity, OctopusEnergyElectricitySensor): - """Sensor for displaying the previous rate.""" - - def __init__(self, coordinator, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - # Pass coordinator to base class - super().__init__(coordinator) - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._state = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_previous_rate" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Electricity {self._serial_number} {self._mpan} Previous Rate" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP/kWh" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """The state of the sensor.""" - # Find the previous rate. We only need to do this every half an hour - now = utcnow() - if (now.minute % 30) == 0 or self._state == None: - _LOGGER.debug(f"Updating OctopusEnergyElectricityPreviousRate for '{self._mpan}/{self._serial_number}'") - - target = now - timedelta(minutes=30) - - previous_rate = None - if self.coordinator.data != None: - rate = self.coordinator.data[self._mpan] - if rate != None: - for period in rate: - if target >= period["valid_from"] and target <= period["valid_to"]: - previous_rate = period - break - - if previous_rate != None: - self._attributes = { - "rate": previous_rate, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter - } - - self._state = previous_rate["value_inc_vat"] / 100 - else: - self._state = None - self._attributes = {} - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyElectricityNextRate(CoordinatorEntity, OctopusEnergyElectricitySensor): - """Sensor for displaying the next rate.""" - - def __init__(self, coordinator, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - # Pass coordinator to base class - super().__init__(coordinator) - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._state = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_next_rate" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Electricity {self._serial_number} {self._mpan} Next Rate" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP/kWh" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """The state of the sensor.""" - # Find the next rate. We only need to do this every half an hour - now = utcnow() - if (now.minute % 30) == 0 or self._state == None: - _LOGGER.debug(f"Updating OctopusEnergyElectricityNextRate for '{self._mpan}/{self._serial_number}'") - - target = now + timedelta(minutes=30) - - next_rate = None - if self.coordinator.data != None: - rate = self.coordinator.data[self._mpan] - if rate != None: - for period in rate: - if target >= period["valid_from"] and target <= period["valid_to"]: - next_rate = period - break - - if next_rate != None: - self._attributes = { - "rate": next_rate, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter - } - - self._state = next_rate["value_inc_vat"] / 100 - else: - self._state = None - self._attributes = {} - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyElectricityCurrentStandingCharge(OctopusEnergyElectricitySensor): - """Sensor for displaying the current standing charge.""" - - def __init__(self, client, tariff_code, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._client = client - self._tariff_code = tariff_code - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f'octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_standing_charge'; - - @property - def name(self): - """Name of the sensor.""" - return f'Octopus Energy Electricity {self._serial_number} {self._mpan} Current Standing Charge' - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """Retrieve the latest electricity standing charge""" - return self._state - - async def async_update(self): - """Get the current price.""" - # Find the current rate. We only need to do this every day - - utc_now = utcnow() - if (self._latest_date == None or (self._latest_date + timedelta(days=1)) < utc_now): - _LOGGER.debug('Updating OctopusEnergyElectricityCurrentStandingCharge') - - period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z"))) - period_to = as_utc(parse_datetime((utc_now + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) - - standard_charge_result = await self._client.async_get_electricity_standing_charge(self._tariff_code, period_from, period_to) - - if standard_charge_result != None: - self._latest_date = period_from - self._state = standard_charge_result["value_inc_vat"] / 100 - - # Adjust our period, as our gas only changes on a daily basis - self._attributes["valid_from"] = period_from - self._attributes["valid_to"] = period_to - else: - self._state = None - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyPreviousAccumulativeElectricityReading(CoordinatorEntity, OctopusEnergyElectricitySensor): - """Sensor for displaying the previous days accumulative electricity reading.""" - - def __init__(self, coordinator, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - super().__init__(coordinator) - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_previous_accumulative_consumption" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Electricity {self._serial_number} {self._mpan} Previous Accumulative Consumption" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.ENERGY - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL - - @property - def unit_of_measurement(self): - """The unit of measurement of sensor""" - return ENERGY_KILO_WATT_HOUR - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:lightning-bolt" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._latest_date - - @property - def state(self): - """Retrieve the previous days accumulative consumption""" - consumption = calculate_electricity_consumption( - self.coordinator.data, - self._latest_date - ) - - if (consumption != None): - _LOGGER.debug(f"Calculated previous electricity consumption for '{self._mpan}/{self._serial_number}'...") - self._state = consumption["total"] - self._latest_date = consumption["last_calculated_timestamp"] - - self._attributes = { - "mpan": self._mpan, - "serial_number": self._serial_number, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter, - "total": consumption["total"], - "last_calculated_timestamp": consumption["last_calculated_timestamp"], - "charges": consumption["consumptions"] - } - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyPreviousAccumulativeElectricityCost(CoordinatorEntity, OctopusEnergyElectricitySensor): - """Sensor for displaying the previous days accumulative electricity cost.""" - - def __init__(self, coordinator, client, tariff_code, mpan, serial_number, is_export, is_smart_meter): - """Init sensor.""" - super().__init__(coordinator) - OctopusEnergyElectricitySensor.__init__(self, mpan, serial_number, is_export, is_smart_meter) - - self._client = client - self._tariff_code = tariff_code - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_previous_accumulative_cost" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Electricity {self._serial_number} {self._mpan} Previous Accumulative Cost" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL - - @property - def unit_of_measurement(self): - """The unit of measurement of sensor""" - return "GBP" - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def should_poll(self): - return True - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._latest_date - - @property - def state(self): - """Retrieve the previously calculated state""" - return self._state - - async def async_update(self): - current_datetime = now() - period_from = as_utc((current_datetime - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)) - period_to = as_utc(current_datetime.replace(hour=0, minute=0, second=0, microsecond=0)) - - consumption_cost = await async_calculate_electricity_cost( - self._client, - self.coordinator.data, - self._latest_date, - period_from, - period_to, - self._tariff_code, - self._is_smart_meter - ) - - if (consumption_cost != None): - _LOGGER.debug(f"Calculated previous electricity consumption cost for '{self._mpan}/{self._serial_number}'...") - self._latest_date = consumption_cost["last_calculated_timestamp"] - self._state = consumption_cost["total"] - - self._attributes = { - "mpan": self._mpan, - "serial_number": self._serial_number, - "is_export": self._is_export, - "is_smart_meter": self._is_smart_meter, - "tariff_code": self._tariff_code, - "standing_charge": f'{consumption_cost["standing_charge"]}p', - "total_without_standing_charge": f'£{consumption_cost["total_without_standing_charge"]}', - "total": f'£{consumption_cost["total"]}', - "last_calculated_timestamp": consumption_cost["last_calculated_timestamp"], - "charges": consumption_cost["charges"] - } - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyGasSensor(SensorEntity, RestoreEntity): - def __init__(self, mprn, serial_number): - """Init sensor""" - self._mprn = mprn - self._serial_number = serial_number - - self._attributes = { - "mprn": self._mprn, - "serial_number": self._serial_number - } - - @property - def device_info(self): - return { - "identifiers": { - # Serial numbers/mpan are unique identifiers within a specific domain - (DOMAIN, f"electricity_{self._serial_number}_{self._mprn}") - }, - "default_name": "Gas Meter", - } - -class OctopusEnergyGasCurrentRate(OctopusEnergyGasSensor): - """Sensor for displaying the current rate.""" - - def __init__(self, client, tariff_code, mprn, serial_number): - """Init sensor.""" - OctopusEnergyGasSensor.__init__(self, mprn, serial_number) - - self._client = client - self._tariff_code = tariff_code - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f'octopus_energy_gas_{self._serial_number}_{self._mprn}_current_rate'; - - @property - def name(self): - """Name of the sensor.""" - return f'Octopus Energy Gas {self._serial_number} {self._mprn} Current Rate' - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP/kWh" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """Retrieve the latest gas price""" - return self._state - - async def async_update(self): - """Get the current price.""" - # Find the current rate. We only need to do this every day - - utc_now = utcnow() - if (self._latest_date == None or (self._latest_date + timedelta(days=1)) < utc_now): - _LOGGER.debug('Updating OctopusEnergyGasCurrentRate') - - period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z"))) - period_to = as_utc(parse_datetime((utc_now + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) - - rates = await self._client.async_get_gas_rates(self._tariff_code, period_from, period_to) - - current_rate = None - if rates != None: - for period in rates: - if utc_now >= period["valid_from"] and utc_now <= period["valid_to"]: - current_rate = period - break - - if current_rate != None: - self._latest_date = period_from - self._state = current_rate["value_inc_vat"] / 100 - - # Adjust our period, as our gas only changes on a daily basis - current_rate["valid_from"] = period_from - current_rate["valid_to"] = period_to - self._attributes = current_rate - else: - self._state = None - self._attributes = {} - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyGasCurrentStandingCharge(OctopusEnergyGasSensor): - """Sensor for displaying the current standing charge.""" - - def __init__(self, client, tariff_code, mprn, serial_number): - """Init sensor.""" - OctopusEnergyGasSensor.__init__(self, mprn, serial_number) - - self._client = client - self._tariff_code = tariff_code - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f'octopus_energy_gas_{self._serial_number}_{self._mprn}_current_standing_charge'; - - @property - def name(self): - """Name of the sensor.""" - return f'Octopus Energy Gas {self._serial_number} {self._mprn} Current Standing Charge' - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def unit_of_measurement(self): - """Unit of measurement of the sensor.""" - return "GBP" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state(self): - """Retrieve the latest gas standing charge""" - return self._state - - async def async_update(self): - """Get the current price.""" - # Find the current rate. We only need to do this every day - - utc_now = utcnow() - if (self._latest_date == None or (self._latest_date + timedelta(days=1)) < utc_now): - _LOGGER.debug('Updating OctopusEnergyGasCurrentStandingCharge') - - period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z"))) - period_to = as_utc(parse_datetime((utc_now + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) - - standard_charge_result = await self._client.async_get_gas_standing_charge(self._tariff_code, period_from, period_to) - - if standard_charge_result != None: - self._latest_date = period_from - self._state = standard_charge_result["value_inc_vat"] / 100 - - # Adjust our period, as our gas only changes on a daily basis - self._attributes["valid_from"] = period_from - self._attributes["valid_to"] = period_to - else: - self._state = None - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyPreviousAccumulativeGasReading(CoordinatorEntity, OctopusEnergyGasSensor): - """Sensor for displaying the previous days accumulative gas reading.""" - - def __init__(self, coordinator, mprn, serial_number, native_consumption_units): - """Init sensor.""" - super().__init__(coordinator) - OctopusEnergyGasSensor.__init__(self, mprn, serial_number) - - self._native_consumption_units = native_consumption_units - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_consumption" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.GAS - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL - - @property - def unit_of_measurement(self): - """The unit of measurement of sensor""" - return VOLUME_CUBIC_METERS - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:fire" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._latest_date - - @property - def state(self): - """Retrieve the previous days accumulative consumption""" - consumption = calculate_gas_consumption( - self.coordinator.data, - self._latest_date, - self._native_consumption_units - ) - - if (consumption != None): - _LOGGER.debug(f"Calculated previous gas consumption for '{self._mprn}/{self._serial_number}'...") - self._state = consumption["total_m3"] - self._latest_date = consumption["last_calculated_timestamp"] - - self._attributes = { - "mprn": self._mprn, - "serial_number": self._serial_number, - "is_estimated": self._native_consumption_units != "m³", - "total_kwh": consumption["total_kwh"], - "total_m3": consumption["total_m3"], - "last_calculated_timestamp": consumption["last_calculated_timestamp"], - "charges": consumption["consumptions"] - } - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyPreviousAccumulativeGasReadingKwh(CoordinatorEntity, OctopusEnergyGasSensor): - """Sensor for displaying the previous days accumulative gas reading in kwh.""" - - def __init__(self, coordinator, mprn, serial_number, native_consumption_units): - """Init sensor.""" - super().__init__(coordinator) - OctopusEnergyGasSensor.__init__(self, mprn, serial_number) - - self._native_consumption_units = native_consumption_units - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_consumption_kwh" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption (kWh)" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.GAS - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL - - @property - def unit_of_measurement(self): - """The unit of measurement of sensor""" - return ENERGY_KILO_WATT_HOUR - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:fire" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._latest_date - - @property - def state(self): - """Retrieve the previous days accumulative consumption""" - consumption = calculate_gas_consumption( - self.coordinator.data, - self._latest_date, - self._native_consumption_units - ) - - if (consumption != None): - _LOGGER.debug(f"Calculated previous gas consumption for '{self._mprn}/{self._serial_number}'...") - self._state = consumption["total_kwh"] - self._latest_date = consumption["last_calculated_timestamp"] - - self._attributes = { - "mprn": self._mprn, - "serial_number": self._serial_number, - "is_estimated": self._native_consumption_units == "m³", - "last_calculated_timestamp": consumption["last_calculated_timestamp"], - "charges": consumption["consumptions"] - } - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergyPreviousAccumulativeGasCost(CoordinatorEntity, OctopusEnergyGasSensor): - """Sensor for displaying the previous days accumulative gas cost.""" - - def __init__(self, coordinator, client, tariff_code, mprn, serial_number, native_consumption_units): - """Init sensor.""" - super().__init__(coordinator) - OctopusEnergyGasSensor.__init__(self, mprn, serial_number) - - self._client = client - self._tariff_code = tariff_code - self._native_consumption_units = native_consumption_units - - self._state = None - self._latest_date = None - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_cost" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Gas {self._serial_number} {self._mprn} Previous Accumulative Cost" - - @property - def device_class(self): - """The type of sensor""" - return SensorDeviceClass.MONETARY - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL - - @property - def unit_of_measurement(self): - """The unit of measurement of sensor""" - return "GBP" - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:currency-gbp" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def should_poll(self): - return True - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._latest_date - - @property - def state(self): - """Retrieve the previously calculated state""" - return self._state - - async def async_update(self): - current_datetime = now() - period_from = as_utc((current_datetime - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)) - period_to = as_utc(current_datetime.replace(hour=0, minute=0, second=0, microsecond=0)) - - consumption_cost = await async_calculate_gas_cost( - self._client, - self.coordinator.data, - self._latest_date, - period_from, - period_to, - { - "tariff_code": self._tariff_code, - }, - self._native_consumption_units - ) - - if (consumption_cost != None): - _LOGGER.debug(f"Calculated previous gas consumption cost for '{self._mprn}/{self._serial_number}'...") - self._latest_date = consumption_cost["last_calculated_timestamp"] - self._state = consumption_cost["total"] - - self._attributes = { - "mprn": self._mprn, - "serial_number": self._serial_number, - "tariff_code": self._tariff_code, - "standing_charge": f'{consumption_cost["standing_charge"]}p', - "total_without_standing_charge": f'£{consumption_cost["total_without_standing_charge"]}', - "total": f'£{consumption_cost["total"]}', - "last_calculated_timestamp": consumption_cost["last_calculated_timestamp"], - "charges": consumption_cost["charges"] - } - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') - -class OctopusEnergySavingSessionPoints(CoordinatorEntity, SensorEntity, RestoreEntity): - """Sensor for determining saving session points""" - - def __init__(self, coordinator): - """Init sensor.""" - - super().__init__(coordinator) - - self._state = None - self._attributes = {} - - @property - def unique_id(self): - """The id of the sensor.""" - return f"octopus_energy_saving_session_points" - - @property - def name(self): - """Name of the sensor.""" - return f"Octopus Energy Saving Session Points" - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:leaf" - - @property - def extra_state_attributes(self): - """Attributes of the sensor.""" - return self._attributes - - @property - def state_class(self): - """The state class of sensor""" - return SensorStateClass.TOTAL_INCREASING - - @property - def state(self): - """Retrieve the previously calculated state""" - saving_session = self.coordinator.data - if (saving_session is not None and "points" in saving_session): - self._state = saving_session["points"] - else: - self._state = 0 - - return self._state - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - state = await self.async_get_last_state() - - if state is not None: - self._state = state.state - self._attributes = {} - for x in state.attributes.keys(): - self._attributes[x] = state.attributes[x] - - if (self._state is None): - self._state = 0 - - _LOGGER.debug(f'Restored state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/__init__.py b/custom_components/octopus_energy/sensors/__init__.py new file mode 100644 index 00000000..3a808b46 --- /dev/null +++ b/custom_components/octopus_energy/sensors/__init__.py @@ -0,0 +1,276 @@ +from ..api_client import OctopusEnergyApiClient +from datetime import (timedelta) +from homeassistant.util.dt import (parse_datetime) + +minimum_consumption_records = 2 + +def __get_interval_end(item): + return item["interval_end"] + +def __sort_consumption(consumption_data): + sorted = consumption_data.copy() + sorted.sort(key=__get_interval_end) + return sorted + +async def async_get_consumption_data( + client: OctopusEnergyApiClient, + previous_data, + current_utc_timestamp, + period_from, + period_to, + sensor_identifier, + sensor_serial_number, + is_electricity: bool +): + if (previous_data == None or + ((len(previous_data) < 1 or previous_data[-1]["interval_end"] < period_to) and + current_utc_timestamp.minute % 30 == 0) + ): + if (is_electricity == True): + data = await client.async_get_electricity_consumption(sensor_identifier, sensor_serial_number, period_from, period_to) + else: + data = await client.async_get_gas_consumption(sensor_identifier, sensor_serial_number, period_from, period_to) + + if data != None and len(data) > 0: + data = __sort_consumption(data) + return data + + if previous_data != None: + return previous_data + else: + return [] + +def calculate_electricity_consumption(consumption_data, last_calculated_timestamp): + if (consumption_data != None and len(consumption_data) > minimum_consumption_records): + + sorted_consumption_data = __sort_consumption(consumption_data) + + if (last_calculated_timestamp == None or last_calculated_timestamp < sorted_consumption_data[-1]["interval_end"]): + total = 0 + + consumption_parts = [] + for consumption in sorted_consumption_data: + total = total + consumption["consumption"] + + current_consumption = consumption["consumption"] + + consumption_parts.append({ + "from": consumption["interval_start"], + "to": consumption["interval_end"], + "consumption": current_consumption, + }) + + last_calculated_timestamp = sorted_consumption_data[-1]["interval_end"] + + return { + "total": total, + "last_calculated_timestamp": last_calculated_timestamp, + "consumptions": consumption_parts + } + +async def async_calculate_electricity_cost(client: OctopusEnergyApiClient, consumption_data, last_calculated_timestamp, period_from, period_to, tariff_code, is_smart_meter): + if (consumption_data != None and len(consumption_data) > minimum_consumption_records): + + sorted_consumption_data = __sort_consumption(consumption_data) + + # Only calculate our consumption if our data has changed + if (last_calculated_timestamp == None or last_calculated_timestamp < sorted_consumption_data[-1]["interval_end"]): + rates = await client.async_get_electricity_rates(tariff_code, is_smart_meter, period_from, period_to) + standard_charge_result = await client.async_get_electricity_standing_charge(tariff_code, period_from, period_to) + + if (rates != None and len(rates) > 0 and standard_charge_result != None): + standard_charge = standard_charge_result["value_inc_vat"] + + charges = [] + total_cost_in_pence = 0 + for consumption in sorted_consumption_data: + value = consumption["consumption"] + consumption_from = consumption["interval_start"] + consumption_to = consumption["interval_end"] + + try: + rate = next(r for r in rates if r["valid_from"] == consumption_from and r["valid_to"] == consumption_to) + except StopIteration: + raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to} for tariff {tariff_code}") + + cost = (rate["value_inc_vat"] * value) + total_cost_in_pence = total_cost_in_pence + cost + + charges.append({ + "from": rate["valid_from"], + "to": rate["valid_to"], + "rate": f'{rate["value_inc_vat"]}p', + "consumption": f'{value} kWh', + "cost": f'£{round(cost / 100, 2)}' + }) + + total_cost = round(total_cost_in_pence / 100, 2) + total_cost_plus_standing_charge = round((total_cost_in_pence + standard_charge) / 100, 2) + + last_calculated_timestamp = sorted_consumption_data[-1]["interval_end"] + + return { + "standing_charge": standard_charge, + "total_without_standing_charge": total_cost, + "total": total_cost_plus_standing_charge, + "last_calculated_timestamp": last_calculated_timestamp, + "charges": charges + } + +# Adapted from https://www.theenergyshop.com/guides/how-to-convert-gas-units-to-kwh +def convert_m3_to_kwh(value, calorific_value): + kwh_value = value * 1.02264 # Volume correction factor + kwh_value = kwh_value * calorific_value # Calorific value + return round(kwh_value / 3.6, 3) # kWh Conversion factor + +# Adapted from https://www.theenergyshop.com/guides/how-to-convert-gas-units-to-kwh +def convert_kwh_to_m3(value, calorific_value): + m3_value = value * 3.6 # kWh Conversion factor + m3_value = m3_value / calorific_value # Calorific value + return round(m3_value / 1.02264, 3) # Volume correction factor + +def calculate_gas_consumption(consumption_data, last_calculated_timestamp, consumption_units, calorific_value): + if (consumption_data != None and len(consumption_data) > minimum_consumption_records): + + sorted_consumption_data = __sort_consumption(consumption_data) + + if (last_calculated_timestamp == None or last_calculated_timestamp < sorted_consumption_data[-1]["interval_end"]): + total_m3 = 0 + total_kwh = 0 + + consumption_parts = [] + for consumption in sorted_consumption_data: + current_consumption_m3 = 0 + current_consumption_kwh = 0 + + current_consumption = consumption["consumption"] + + if consumption_units == "m³": + current_consumption_m3 = current_consumption + current_consumption_kwh = convert_m3_to_kwh(current_consumption, calorific_value) + else: + current_consumption_m3 = convert_kwh_to_m3(current_consumption, calorific_value) + current_consumption_kwh = current_consumption + + total_m3 = total_m3 + current_consumption_m3 + total_kwh = total_kwh + current_consumption_kwh + + consumption_parts.append({ + "from": consumption["interval_start"], + "to": consumption["interval_end"], + "consumption_m3": current_consumption_m3, + "consumption_kwh": current_consumption_kwh, + }) + + last_calculated_timestamp = sorted_consumption_data[-1]["interval_end"] + + return { + "total_m3": round(total_m3, 3), + "total_kwh": round(total_kwh, 3), + "last_calculated_timestamp": last_calculated_timestamp, + "consumptions": consumption_parts + } + +async def async_calculate_gas_cost(client: OctopusEnergyApiClient, consumption_data, last_calculated_timestamp, period_from, period_to, sensor, consumption_units, calorific_value): + if (consumption_data != None and len(consumption_data) > minimum_consumption_records): + + sorted_consumption_data = __sort_consumption(consumption_data) + + # Only calculate our consumption if our data has changed + if (last_calculated_timestamp == None or last_calculated_timestamp < sorted_consumption_data[-1]["interval_end"]): + rates = await client.async_get_gas_rates(sensor["tariff_code"], period_from, period_to) + standard_charge_result = await client.async_get_gas_standing_charge(sensor["tariff_code"], period_from, period_to) + + if (rates != None and len(rates) > 0 and standard_charge_result != None): + standard_charge = standard_charge_result["value_inc_vat"] + + charges = [] + total_cost_in_pence = 0 + for consumption in sorted_consumption_data: + value = consumption["consumption"] + + if consumption_units == "m³": + value = convert_m3_to_kwh(value, calorific_value) + + consumption_from = consumption["interval_start"] + consumption_to = consumption["interval_end"] + + try: + rate = next(r for r in rates if r["valid_from"] == consumption_from and r["valid_to"] == consumption_to) + except StopIteration: + raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to} for tariff {sensor['tariff_code']}") + + cost = (rate["value_inc_vat"] * value) + total_cost_in_pence = total_cost_in_pence + cost + + charges.append({ + "from": rate["valid_from"], + "to": rate["valid_to"], + "rate": f'{rate["value_inc_vat"]}p', + "consumption": f'{value} kWh', + "cost": f'£{round(cost / 100, 2)}' + }) + + total_cost = round(total_cost_in_pence / 100, 2) + total_cost_plus_standing_charge = round((total_cost_in_pence + standard_charge) / 100, 2) + last_calculated_timestamp = sorted_consumption_data[-1]["interval_end"] + + return { + "standing_charge": standard_charge, + "total_without_standing_charge": total_cost, + "total": total_cost_plus_standing_charge, + "last_calculated_timestamp": last_calculated_timestamp, + "charges": charges + } + +def current_saving_sessions_event(current_date, events): + current_event = None + for event in events: + if (event["start"] <= current_date and event["end"] >= current_date): + current_event = { + "start": event["start"], + "end": event["end"], + "duration_in_minutes": (event["end"] - event["start"]).total_seconds() / 60 + } + break + + return current_event + +def get_next_saving_sessions_event(current_date, events): + next_event = None + for event in events: + if event["start"] > current_date and (next_event == None or event["start"] < next_event["start"]): + next_event = { + "start": event["start"], + "end": event["end"], + "duration_in_minutes": (event["end"] - event["start"]).total_seconds() / 60 + } + + return next_event + +async def async_get_live_consumption(client: OctopusEnergyApiClient, device_id, current_date, last_retrieval_date): + period_to = current_date.strftime("%Y-%m-%dT%H:%M:00Z") + if (last_retrieval_date is None): + period_from = (parse_datetime(period_to) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:00Z") + else: + period_from = (last_retrieval_date + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:00Z") + + result = await client.async_get_smart_meter_consumption(device_id, period_from, period_to) + if result is not None: + + total_consumption = 0 + latest_date = None + demand = None + for item in result: + total_consumption += item["consumption"] + if (latest_date is None or latest_date < item["startAt"]): + latest_date = item["startAt"] + demand = item["demand"] + + return { + "consumption": total_consumption, + "startAt": latest_date, + "demand": demand + } + + return None \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/__init__.py b/custom_components/octopus_energy/sensors/electricity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/octopus_energy/sensors/electricity/base.py b/custom_components/octopus_energy/sensors/electricity/base.py new file mode 100644 index 00000000..d3b0a7ec --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/base.py @@ -0,0 +1,41 @@ +from homeassistant.components.sensor import ( + SensorEntity, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from ...const import ( + DOMAIN, +) + +class OctopusEnergyElectricitySensor(SensorEntity, RestoreEntity): + def __init__(self, meter, point): + """Init sensor""" + self._point = point + self._meter = meter + + self._mpan = point["mpan"] + self._serial_number = meter["serial_number"] + self._is_export = meter["is_export"] + self._is_smart_meter = meter["is_smart_meter"] + self._export_id_addition = "_export" if self._is_export == True else "" + self._export_name_addition = " Export" if self._is_export == True else "" + + self._attributes = { + "mpan": self._mpan, + "serial_number": self._serial_number, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter + } + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers/mpan are unique identifiers within a specific domain + (DOMAIN, f"electricity_{self._serial_number}_{self._mpan}") + }, + "default_name": f"Electricity Meter{self._export_name_addition}", + "manufacturer": self._meter["manufacturer"], + "model": self._meter["model"], + "sw_version": self._meter["firmware"] + } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/current_consumption.py b/custom_components/octopus_energy/sensors/electricity/current_consumption.py new file mode 100644 index 00000000..d8fda062 --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/current_consumption.py @@ -0,0 +1,91 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyCurrentElectricityConsumption(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the current electricity consumption.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_consumption" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan} Current Consumption" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.ENERGY + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return ENERGY_KILO_WATT_HOUR + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:lightning-bolt" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the latest electricity consumption""" + + _LOGGER.debug('Updating OctopusEnergyCurrentElectricityConsumption') + consumption_result = self.coordinator.data + + if (consumption_result is not None): + self._latest_date = consumption_result["startAt"] + self._state = consumption_result["consumption"] / 1000 + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + + _LOGGER.debug(f'Restored OctopusEnergyCurrentElectricityConsumption state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/current_demand.py b/custom_components/octopus_energy/sensors/electricity/current_demand.py new file mode 100644 index 00000000..666bd71b --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/current_demand.py @@ -0,0 +1,88 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyCurrentElectricityDemand(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the current electricity demand.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_demand" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan} Current Demand" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.POWER + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return "W" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:lightning-bolt" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the latest electricity demand""" + + _LOGGER.debug('Updating OctopusEnergyCurrentElectricityConsumption') + consumption_result = self.coordinator.data + + if (consumption_result is not None): + self._latest_date = consumption_result["startAt"] + self._state = consumption_result["demand"] + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + + _LOGGER.debug(f'Restored OctopusEnergyCurrentElectricityDemand state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/current_rate.py b/custom_components/octopus_energy/sensors/electricity/current_rate.py new file mode 100644 index 00000000..f11a3c18 --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/current_rate.py @@ -0,0 +1,114 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyElectricityCurrentRate(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the current rate.""" + + def __init__(self, coordinator, meter, point, electricity_price_cap): + """Init sensor.""" + # Pass coordinator to base class + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._last_updated = None + self._electricity_price_cap = electricity_price_cap + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_current_rate" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Rate" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP/kWh" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """The state of the sensor.""" + # Find the current rate. We only need to do this every half an hour + now = utcnow() + if (self._last_updated is None or self._last_updated < (now - timedelta(minutes=30)) or (now.minute % 30) == 0): + _LOGGER.debug(f"Updating OctopusEnergyElectricityCurrentRate for '{self._mpan}/{self._serial_number}'") + + current_rate = None + if self.coordinator.data != None: + rate = self.coordinator.data[self._mpan] if self._mpan in self.coordinator.data else None + if rate != None: + for period in rate: + if now >= period["valid_from"] and now <= period["valid_to"]: + current_rate = period + break + + if current_rate != None: + ratesAttributes = list(map(lambda x: { + "from": x["valid_from"], + "to": x["valid_to"], + "rate": x["value_inc_vat"], + "is_capped": x["is_capped"] + }, rate)) + self._attributes = { + "rate": current_rate, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter, + "rates": ratesAttributes + } + + if self._electricity_price_cap is not None: + self._attributes["price_cap"] = self._electricity_price_cap + + self._state = current_rate["value_inc_vat"] / 100 + else: + self._state = None + self._attributes = {} + + self._last_updated = now + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentRate state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/next_rate.py b/custom_components/octopus_energy/sensors/electricity/next_rate.py new file mode 100644 index 00000000..20afadc2 --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/next_rate.py @@ -0,0 +1,105 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyElectricityNextRate(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the next rate.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + # Pass coordinator to base class + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_next_rate" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Next Rate" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP/kWh" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """The state of the sensor.""" + # Find the next rate. We only need to do this every half an hour + now = utcnow() + if (self._last_updated is None or self._last_updated < (now - timedelta(minutes=30)) or (now.minute % 30) == 0): + _LOGGER.debug(f"Updating OctopusEnergyElectricityNextRate for '{self._mpan}/{self._serial_number}'") + + target = now + timedelta(minutes=30) + + next_rate = None + if self.coordinator.data != None: + rate = self.coordinator.data[self._mpan] if self._mpan in self.coordinator.data else None + if rate != None: + for period in rate: + if target >= period["valid_from"] and target <= period["valid_to"]: + next_rate = period + break + + if next_rate != None: + self._attributes = { + "rate": next_rate, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter + } + + self._state = next_rate["value_inc_vat"] / 100 + else: + self._state = None + self._attributes = {} + + self._last_updated = now + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyElectricityNextRate state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/previous_accumulative_consumption.py b/custom_components/octopus_energy/sensors/electricity/previous_accumulative_consumption.py new file mode 100644 index 00000000..33bf63d3 --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/previous_accumulative_consumption.py @@ -0,0 +1,110 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR +) + +from .. import ( + calculate_electricity_consumption, +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyPreviousAccumulativeElectricityConsumption(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the previous days accumulative electricity reading.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_consumption" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Accumulative Consumption" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.ENERGY + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return ENERGY_KILO_WATT_HOUR + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:lightning-bolt" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the previous days accumulative consumption""" + consumption = calculate_electricity_consumption( + self.coordinator.data, + self._latest_date + ) + + if (consumption != None): + _LOGGER.debug(f"Calculated previous electricity consumption for '{self._mpan}/{self._serial_number}'...") + self._state = consumption["total"] + self._latest_date = consumption["last_calculated_timestamp"] + + self._attributes = { + "mpan": self._mpan, + "serial_number": self._serial_number, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter, + "total": consumption["total"], + "last_calculated_timestamp": consumption["last_calculated_timestamp"], + "charges": consumption["consumptions"] + } + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeElectricityConsumption state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/previous_accumulative_cost.py b/custom_components/octopus_energy/sensors/electricity/previous_accumulative_cost.py new file mode 100644 index 00000000..3e90346b --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/previous_accumulative_cost.py @@ -0,0 +1,130 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (now, as_utc) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from .. import ( + async_calculate_electricity_cost, +) + +from ...api_client import (OctopusEnergyApiClient) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyPreviousAccumulativeElectricityCost(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the previous days accumulative electricity cost.""" + + def __init__(self, coordinator, client: OctopusEnergyApiClient, tariff_code, meter, point): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._client = client + self._tariff_code = tariff_code + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_cost" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Accumulative Cost" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return "GBP" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def should_poll(self): + return True + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the previously calculated state""" + return self._state + + async def async_update(self): + current_datetime = now() + period_from = as_utc((current_datetime - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)) + period_to = as_utc(current_datetime.replace(hour=0, minute=0, second=0, microsecond=0)) + + consumption_cost = await async_calculate_electricity_cost( + self._client, + self.coordinator.data, + self._latest_date, + period_from, + period_to, + self._tariff_code, + self._is_smart_meter + ) + + if (consumption_cost != None): + _LOGGER.debug(f"Calculated previous electricity consumption cost for '{self._mpan}/{self._serial_number}'...") + self._latest_date = consumption_cost["last_calculated_timestamp"] + self._state = consumption_cost["total"] + + self._attributes = { + "mpan": self._mpan, + "serial_number": self._serial_number, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter, + "tariff_code": self._tariff_code, + "standing_charge": f'{consumption_cost["standing_charge"]}p', + "total_without_standing_charge": f'£{consumption_cost["total_without_standing_charge"]}', + "total": f'£{consumption_cost["total"]}', + "last_calculated_timestamp": consumption_cost["last_calculated_timestamp"], + "charges": consumption_cost["charges"] + } + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeElectricityCost state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/previous_rate.py b/custom_components/octopus_energy/sensors/electricity/previous_rate.py new file mode 100644 index 00000000..e06e175c --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/previous_rate.py @@ -0,0 +1,105 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyElectricityPreviousRate(CoordinatorEntity, OctopusEnergyElectricitySensor): + """Sensor for displaying the previous rate.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + # Pass coordinator to base class + super().__init__(coordinator) + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_rate" + + @property + def name(self): + """Name of the sensor.""" + return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Rate" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP/kWh" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """The state of the sensor.""" + # Find the previous rate. We only need to do this every half an hour + now = utcnow() + if (self._last_updated is None or self._last_updated < (now - timedelta(minutes=30)) or (now.minute % 30) == 0): + _LOGGER.debug(f"Updating OctopusEnergyElectricityPreviousRate for '{self._mpan}/{self._serial_number}'") + + target = now - timedelta(minutes=30) + + previous_rate = None + if self.coordinator.data != None: + rate = self.coordinator.data[self._mpan] if self._mpan in self.coordinator.data else None + if rate != None: + for period in rate: + if target >= period["valid_from"] and target <= period["valid_to"]: + previous_rate = period + break + + if previous_rate != None: + self._attributes = { + "rate": previous_rate, + "is_export": self._is_export, + "is_smart_meter": self._is_smart_meter + } + + self._state = previous_rate["value_inc_vat"] / 100 + else: + self._state = None + self._attributes = {} + + self._last_updated = now + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyElectricityPreviousRate state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/electricity/standing_charge.py b/custom_components/octopus_energy/sensors/electricity/standing_charge.py new file mode 100644 index 00000000..9ea25319 --- /dev/null +++ b/custom_components/octopus_energy/sensors/electricity/standing_charge.py @@ -0,0 +1,98 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow, as_utc, parse_datetime) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from ...api_client import (OctopusEnergyApiClient) + +from .base import (OctopusEnergyElectricitySensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyElectricityCurrentStandingCharge(OctopusEnergyElectricitySensor): + """Sensor for displaying the current standing charge.""" + + def __init__(self, client: OctopusEnergyApiClient, tariff_code, meter, point): + """Init sensor.""" + OctopusEnergyElectricitySensor.__init__(self, meter, point) + + self._client = client + self._tariff_code = tariff_code + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f'octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_current_standing_charge' + + @property + def name(self): + """Name of the sensor.""" + return f'Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Standing Charge' + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """Retrieve the latest electricity standing charge""" + return self._state + + async def async_update(self): + """Get the current price.""" + # Find the current rate. We only need to do this every day + + utc_now = utcnow() + if (self._latest_date == None or (self._latest_date + timedelta(days=1)) < utc_now): + _LOGGER.debug('Updating OctopusEnergyElectricityCurrentStandingCharge') + + period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z"))) + period_to = as_utc(parse_datetime((utc_now + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) + + standard_charge_result = await self._client.async_get_electricity_standing_charge(self._tariff_code, period_from, period_to) + + if standard_charge_result != None: + self._latest_date = period_from + self._state = standard_charge_result["value_inc_vat"] / 100 + + # Adjust our period, as our gas only changes on a daily basis + self._attributes["valid_from"] = period_from + self._attributes["valid_to"] = period_to + else: + self._state = None + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentStandingCharge state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/__init__.py b/custom_components/octopus_energy/sensors/gas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/octopus_energy/sensors/gas/base.py b/custom_components/octopus_energy/sensors/gas/base.py new file mode 100644 index 00000000..eca9c6a5 --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/base.py @@ -0,0 +1,35 @@ +from homeassistant.components.sensor import ( + SensorEntity, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from ...const import ( + DOMAIN, +) + +class OctopusEnergyGasSensor(SensorEntity, RestoreEntity): + def __init__(self, meter, point): + """Init sensor""" + self._point = point + self._meter = meter + + self._mprn = point["mprn"] + self._serial_number = meter["serial_number"] + + self._attributes = { + "mprn": self._mprn, + "serial_number": self._serial_number + } + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers/mpan are unique identifiers within a specific domain + (DOMAIN, f"electricity_{self._serial_number}_{self._mprn}") + }, + "default_name": "Gas Meter", + "manufacturer": self._meter["manufacturer"], + "model": self._meter["model"], + "sw_version": self._meter["firmware"] + } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/current_consumption.py b/custom_components/octopus_energy/sensors/gas/current_consumption.py new file mode 100644 index 00000000..ab8f23b9 --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/current_consumption.py @@ -0,0 +1,91 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR +) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyCurrentGasConsumption(CoordinatorEntity, OctopusEnergyGasSensor): + """Sensor for displaying the current gas consumption.""" + + def __init__(self, coordinator, meter, point): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_current_consumption" + + @property + def name(self): + """Name of the sensor.""" + return f"Gas {self._serial_number} {self._mprn} Current Consumption" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.GAS + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return ENERGY_KILO_WATT_HOUR + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:lightning-bolt" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the latest gas consumption""" + + _LOGGER.debug('Updating OctopusEnergyCurrentGasConsumption') + consumption_result = self.coordinator.data + + if (consumption_result is not None): + self._latest_date = consumption_result["startAt"] + self._state = consumption_result["consumption"] / 1000 + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + + _LOGGER.debug(f'Restored OctopusEnergyCurrentGasConsumption state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/current_rate.py b/custom_components/octopus_energy/sensors/gas/current_rate.py new file mode 100644 index 00000000..346eeb83 --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/current_rate.py @@ -0,0 +1,106 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyGasCurrentRate(CoordinatorEntity, OctopusEnergyGasSensor): + """Sensor for displaying the current rate.""" + + def __init__(self, coordinator, tariff_code, meter, point, gas_price_cap): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._tariff_code = tariff_code + self._gas_price_cap = gas_price_cap + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f'octopus_energy_gas_{self._serial_number}_{self._mprn}_current_rate'; + + @property + def name(self): + """Name of the sensor.""" + return f'Gas {self._serial_number} {self._mprn} Current Rate' + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP/kWh" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """Retrieve the latest gas price""" + + utc_now = utcnow() + if (self._latest_date is None or (self._latest_date + timedelta(days=1)) < utc_now) or self._state is None: + _LOGGER.debug('Updating OctopusEnergyGasCurrentRate') + + rates = self.coordinator.data + + current_rate = None + if rates is not None: + for period in rates: + if utc_now >= period["valid_from"] and utc_now <= period["valid_to"]: + current_rate = period + break + + if current_rate is not None: + self._latest_date = rates[0]["valid_from"] + self._state = current_rate["value_inc_vat"] / 100 + + # Adjust our period, as our gas only changes on a daily basis + current_rate["valid_from"] = rates[0]["valid_from"] + current_rate["valid_to"] = rates[-1]["valid_to"] + self._attributes = current_rate + + if self._gas_price_cap is not None: + self._attributes["price_cap"] = self._gas_price_cap + else: + self._state = None + self._attributes = {} + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyGasCurrentRate state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption.py b/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption.py new file mode 100644 index 00000000..15bb3f9d --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption.py @@ -0,0 +1,115 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from homeassistant.const import ( + VOLUME_CUBIC_METERS +) + +from .. import ( + calculate_gas_consumption, +) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyPreviousAccumulativeGasConsumption(CoordinatorEntity, OctopusEnergyGasSensor): + """Sensor for displaying the previous days accumulative gas reading.""" + + def __init__(self, coordinator, meter, point, calorific_value): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._native_consumption_units = meter["consumption_units"] + self._state = None + self._latest_date = None + self._calorific_value = calorific_value + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_consumption" + + @property + def name(self): + """Name of the sensor.""" + return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.GAS + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return VOLUME_CUBIC_METERS + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:fire" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the previous days accumulative consumption""" + consumption = calculate_gas_consumption( + self.coordinator.data, + self._latest_date, + self._native_consumption_units, + self._calorific_value + ) + + if (consumption != None): + _LOGGER.debug(f"Calculated previous gas consumption for '{self._mprn}/{self._serial_number}'...") + self._state = consumption["total_m3"] + self._latest_date = consumption["last_calculated_timestamp"] + + self._attributes = { + "mprn": self._mprn, + "serial_number": self._serial_number, + "is_estimated": self._native_consumption_units != "m³", + "total_kwh": consumption["total_kwh"], + "total_m3": consumption["total_m3"], + "last_calculated_timestamp": consumption["last_calculated_timestamp"], + "charges": consumption["consumptions"], + "calorific_value": self._calorific_value + } + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeGasConsumption state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption_kwh.py b/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption_kwh.py new file mode 100644 index 00000000..1adb7762 --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/previous_accumulative_consumption_kwh.py @@ -0,0 +1,113 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR +) + +from .. import ( + calculate_gas_consumption, +) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyPreviousAccumulativeGasConsumptionKwh(CoordinatorEntity, OctopusEnergyGasSensor): + """Sensor for displaying the previous days accumulative gas consumption in kwh.""" + + def __init__(self, coordinator, meter, point, calorific_value): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._native_consumption_units = meter["consumption_units"] + self._state = None + self._latest_date = None + self._calorific_value = calorific_value + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_consumption_kwh" + + @property + def name(self): + """Name of the sensor.""" + return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption (kWh)" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.ENERGY + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return ENERGY_KILO_WATT_HOUR + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:fire" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the previous days accumulative consumption""" + consumption = calculate_gas_consumption( + self.coordinator.data, + self._latest_date, + self._native_consumption_units, + self._calorific_value + ) + + if (consumption != None): + _LOGGER.debug(f"Calculated previous gas consumption for '{self._mprn}/{self._serial_number}'...") + self._state = consumption["total_kwh"] + self._latest_date = consumption["last_calculated_timestamp"] + + self._attributes = { + "mprn": self._mprn, + "serial_number": self._serial_number, + "is_estimated": self._native_consumption_units == "m³", + "last_calculated_timestamp": consumption["last_calculated_timestamp"], + "charges": consumption["consumptions"], + "calorific_value": self._calorific_value + } + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeGasConsumptionKwh state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/previous_accumulative_cost.py b/custom_components/octopus_energy/sensors/gas/previous_accumulative_cost.py new file mode 100644 index 00000000..69309003 --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/previous_accumulative_cost.py @@ -0,0 +1,134 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (now, as_utc) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass +) +from .. import ( + async_calculate_gas_cost, +) + +from ...api_client import (OctopusEnergyApiClient) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyPreviousAccumulativeGasCost(CoordinatorEntity, OctopusEnergyGasSensor): + """Sensor for displaying the previous days accumulative gas cost.""" + + def __init__(self, coordinator, client: OctopusEnergyApiClient, tariff_code, meter, point, calorific_value): + """Init sensor.""" + super().__init__(coordinator) + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._client = client + self._tariff_code = tariff_code + self._native_consumption_units = meter["consumption_units"] + + self._state = None + self._latest_date = None + self._calorific_value = calorific_value + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_gas_{self._serial_number}_{self._mprn}_previous_accumulative_cost" + + @property + def name(self): + """Name of the sensor.""" + return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Cost" + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL + + @property + def unit_of_measurement(self): + """The unit of measurement of sensor""" + return "GBP" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def should_poll(self): + return True + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if any.""" + return self._latest_date + + @property + def state(self): + """Retrieve the previously calculated state""" + return self._state + + async def async_update(self): + current_datetime = now() + period_from = as_utc((current_datetime - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)) + period_to = as_utc(current_datetime.replace(hour=0, minute=0, second=0, microsecond=0)) + + consumption_cost = await async_calculate_gas_cost( + self._client, + self.coordinator.data, + self._latest_date, + period_from, + period_to, + { + "tariff_code": self._tariff_code, + }, + self._native_consumption_units, + self._calorific_value + ) + + if (consumption_cost != None): + _LOGGER.debug(f"Calculated previous gas consumption cost for '{self._mprn}/{self._serial_number}'...") + self._latest_date = consumption_cost["last_calculated_timestamp"] + self._state = consumption_cost["total"] + + self._attributes = { + "mprn": self._mprn, + "serial_number": self._serial_number, + "tariff_code": self._tariff_code, + "standing_charge": f'{consumption_cost["standing_charge"]}p', + "total_without_standing_charge": f'£{consumption_cost["total_without_standing_charge"]}', + "total": f'£{consumption_cost["total"]}', + "last_calculated_timestamp": consumption_cost["last_calculated_timestamp"], + "charges": consumption_cost["charges"], + "calorific_value": self._calorific_value + } + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeGasCost state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/gas/standing_charge.py b/custom_components/octopus_energy/sensors/gas/standing_charge.py new file mode 100644 index 00000000..f791f7cf --- /dev/null +++ b/custom_components/octopus_energy/sensors/gas/standing_charge.py @@ -0,0 +1,98 @@ +from datetime import timedelta +import logging + +from homeassistant.util.dt import (utcnow, as_utc, parse_datetime) +from homeassistant.components.sensor import ( + SensorDeviceClass +) + +from ...api_client import (OctopusEnergyApiClient) + +from .base import (OctopusEnergyGasSensor) + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyGasCurrentStandingCharge(OctopusEnergyGasSensor): + """Sensor for displaying the current standing charge.""" + + def __init__(self, client: OctopusEnergyApiClient, tariff_code, meter, point): + """Init sensor.""" + OctopusEnergyGasSensor.__init__(self, meter, point) + + self._client = client + self._tariff_code = tariff_code + + self._state = None + self._latest_date = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f'octopus_energy_gas_{self._serial_number}_{self._mprn}_current_standing_charge'; + + @property + def name(self): + """Name of the sensor.""" + return f'Gas {self._serial_number} {self._mprn} Current Standing Charge' + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.MONETARY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:currency-gbp" + + @property + def unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "GBP" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state(self): + """Retrieve the latest gas standing charge""" + return self._state + + async def async_update(self): + """Get the current price.""" + # Find the current rate. We only need to do this every day + + utc_now = utcnow() + if (self._latest_date == None or (self._latest_date + timedelta(days=1)) < utc_now): + _LOGGER.debug('Updating OctopusEnergyGasCurrentStandingCharge') + + period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z"))) + period_to = as_utc(parse_datetime((utc_now + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"))) + + standard_charge_result = await self._client.async_get_gas_standing_charge(self._tariff_code, period_from, period_to) + + if standard_charge_result != None: + self._latest_date = period_from + self._state = standard_charge_result["value_inc_vat"] / 100 + + # Adjust our period, as our gas only changes on a daily basis + self._attributes["valid_from"] = period_from + self._attributes["valid_to"] = period_to + else: + self._state = None + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergyGasCurrentStandingCharge state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/sensors/saving_sessions/__init__.py b/custom_components/octopus_energy/sensors/saving_sessions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/octopus_energy/sensors/saving_sessions/points.py b/custom_components/octopus_energy/sensors/saving_sessions/points.py new file mode 100644 index 00000000..afc89318 --- /dev/null +++ b/custom_components/octopus_energy/sensors/saving_sessions/points.py @@ -0,0 +1,73 @@ +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.components.sensor import ( + SensorEntity, + SensorStateClass +) +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergySavingSessionPoints(CoordinatorEntity, SensorEntity, RestoreEntity): + """Sensor for determining saving session points""" + + def __init__(self, coordinator): + """Init sensor.""" + + super().__init__(coordinator) + + self._state = None + self._attributes = {} + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_saving_session_points" + + @property + def name(self): + """Name of the sensor.""" + return f"Octopus Energy Saving Session Points" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:leaf" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL_INCREASING + + @property + def state(self): + """Retrieve the previously calculated state""" + saving_session = self.coordinator.data + if (saving_session is not None and "points" in saving_session): + self._state = saving_session["points"] + else: + self._state = 0 + + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = state.state + self._attributes = {} + for x in state.attributes.keys(): + self._attributes[x] = state.attributes[x] + + _LOGGER.debug(f'Restored OctopusEnergySavingSessionPoints state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index f0538d05..19b78df9 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -6,7 +6,11 @@ "description": "Setup your basic account information. This can be found at https://octopus.energy/dashboard/developer/.", "data": { "Api key": "Api key", - "Account Id": "Your account Id (e.g. A-AAAA1111)" + "Account Id": "Your account Id (e.g. A-AAAA1111)", + "supports_live_consumption": "I have a Home Mini", + "calorific_value": "Gas calorific value. This can be found on your gas statement and can change from time to time.", + "electricity_price_cap": "Optional electricity price cap in pence", + "gas_price_cap": "Optional gas price cap in pence" } }, "target_rate": { @@ -40,7 +44,13 @@ "title": "Update Account Info", "description": "Update your basic account information. This can be found at https://octopus.energy/dashboard/developer/.", "data": { - "Api key": "Api key" + "Api key": "Api key", + "supports_live_consumption": "I have a Home Mini", + "calorific_value": "Gas calorific value. This can be found on your gas statement and can change from time to time.", + "electricity_price_cap": "Optional electricity price cap in pence", + "clear_electricity_price_cap": "Clear electricity price cap", + "gas_price_cap": "Optional gas price cap in pence", + "clear_gas_price_cap": "Clear Gas price cap" } }, "target_rate": { @@ -64,5 +74,19 @@ "abort": { "not_supported": "Configuration for target rates is not supported at the moment." } + }, + "issues": { + "account_not_found": { + "title": "Account \"{account_id}\" not found", + "description": "The integration failed to retrieve the information associated with your configured account. Please check your account exists and that your API key is valid. Click 'Learn More' to find out how to fix this." + }, + "unknown_tariff_format": { + "title": "Invalid format - {type} - {tariff_code}", + "description": "The tariff \"{tariff_code}\" associated with your {type} meter is not in an expected format. Click on 'Learn More' with instructions on what to do next." + }, + "unknown_tariff": { + "title": "Unknown tariff - {type} - {tariff_code}", + "description": "The tariff \"{tariff_code}\" associated with your {type} meter has not been found. Click on 'Learn More' with instructions on what to do next." + } } } \ No newline at end of file diff --git a/custom_components/octopus_energy/utils/__init__.py b/custom_components/octopus_energy/utils/__init__.py new file mode 100644 index 00000000..328fd353 --- /dev/null +++ b/custom_components/octopus_energy/utils/__init__.py @@ -0,0 +1,125 @@ +from datetime import datetime, timedelta +from homeassistant.util.dt import (as_utc, parse_datetime) + +import re + +from ..const import ( + REGEX_TARIFF_PARTS, + REGEX_OFFSET_PARTS, +) + +def get_tariff_parts(tariff_code): + matches = re.search(REGEX_TARIFF_PARTS, tariff_code) + if matches == None: + return None + + # If our energy or rate isn't extracted, then assume is electricity and "single" rate as that's + # where our experimental tariffs are + energy = matches.groupdict()["energy"] or "E" + rate = matches.groupdict()["rate"] or "1R" + product_code =matches.groupdict()["product_code"] + region = matches.groupdict()["region"] + + return { + "energy": energy, + "rate": rate, + "product_code": product_code, + "region": region + } + +def get_active_tariff_code(utcnow: datetime, agreements): + latest_agreement = None + latest_valid_from = None + + # Find our latest agreement + for agreement in agreements: + if agreement["tariff_code"] == None: + continue + + valid_from = as_utc(parse_datetime(agreement["valid_from"])) + + if utcnow >= valid_from and (latest_valid_from == None or valid_from > latest_valid_from): + + latest_valid_to = None + if "valid_to" in agreement and agreement["valid_to"] != None: + latest_valid_to = as_utc(parse_datetime(agreement["valid_to"])) + + if latest_valid_to == None or latest_valid_to >= utcnow: + latest_agreement = agreement + latest_valid_from = valid_from + + if latest_agreement != None: + return latest_agreement["tariff_code"] + + return None + +def apply_offset(date_time: datetime, offset: str, inverse = False): + matches = re.search(REGEX_OFFSET_PARTS, offset) + if matches == None: + raise Exception(f'Unable to extract offset: {offset}') + + symbol = matches[1] + hours = float(matches[2]) + minutes = float(matches[3]) + seconds = float(matches[4]) + + if ((symbol == "-" and inverse == False) or (symbol != "-" and inverse == True)): + return date_time - timedelta(hours=hours, minutes=minutes, seconds=seconds) + + return date_time + timedelta(hours=hours, minutes=minutes, seconds=seconds) + +def get_valid_from(rate): + return rate["valid_from"] + +def rates_to_thirty_minute_increments(data, period_from: datetime, period_to: datetime, tariff_code: str, price_cap: float = None): + """Process the collection of rates to ensure they're in 30 minute periods""" + starting_period_from = period_from + results = [] + if ("results" in data): + items = data["results"] + items.sort(key=get_valid_from) + + # We need to normalise our data into 30 minute increments so that all of our rates across all tariffs are the same and it's + # easier to calculate our target rate sensors + for item in items: + value_inc_vat = float(item["value_inc_vat"]) + + is_capped = False + if (price_cap is not None and value_inc_vat > price_cap): + value_inc_vat = price_cap + is_capped = True + + if "valid_from" in item and item["valid_from"] != None: + valid_from = as_utc(parse_datetime(item["valid_from"])) + + # If we're on a fixed rate, then our current time could be in the past so we should go from + # our target period from date otherwise we could be adjusting times quite far in the past + if (valid_from < starting_period_from): + valid_from = starting_period_from + else: + valid_from = starting_period_from + + # Some rates don't have end dates, so we should treat this as our period to target + if "valid_to" in item and item["valid_to"] != None: + target_date = as_utc(parse_datetime(item["valid_to"])) + + # Cap our target date to our end period + if (target_date > period_to): + target_date = period_to + else: + target_date = period_to + + while valid_from < target_date: + valid_to = valid_from + timedelta(minutes=30) + results.append({ + "value_inc_vat": value_inc_vat, + "valid_from": valid_from, + "valid_to": valid_to, + "tariff_code": tariff_code, + "is_capped": is_capped + }) + + valid_from = valid_to + starting_period_from = valid_to + + return results \ No newline at end of file diff --git a/custom_components/octopus_energy/utils/check_tariff.py b/custom_components/octopus_energy/utils/check_tariff.py new file mode 100644 index 00000000..655a3f7e --- /dev/null +++ b/custom_components/octopus_energy/utils/check_tariff.py @@ -0,0 +1,41 @@ +from homeassistant.helpers import issue_registry as ir + +from ..const import ( + DOMAIN, + DATA_KNOWN_TARIFF, +) + +from ..api_client import (OctopusEnergyApiClient) + +from ..utils import get_tariff_parts + +async def async_check_valid_tariff(hass, client: OctopusEnergyApiClient, tariff_code: str, is_electricity: bool): + tariff_key = f'{DATA_KNOWN_TARIFF}_{tariff_code}' + if (tariff_key not in hass.data[DOMAIN]): + tariff_parts = get_tariff_parts(tariff_code) + if tariff_parts is None: + ir.async_create_issue( + hass, + DOMAIN, + f"unknown_tariff_format_{tariff_code}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/blob/develop/_docs/repairs/unknown_tariff_format.md", + translation_key="unknown_tariff_format", + translation_placeholders={ "type": "Electricity" if is_electricity else "Gas", "tariff_code": tariff_code }, + ) + else: + product = await client.async_get_product(tariff_parts["product_code"]) + if product is None: + ir.async_create_issue( + hass, + DOMAIN, + f"unknown_tariff_{tariff_code}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/blob/develop/_docs/repairs/unknown_tariff.md", + translation_key="unknown_tariff", + translation_placeholders={ "type": "Electricity" if is_electricity else "Gas", "tariff_code": tariff_code }, + ) + else: + hass.data[DOMAIN][tariff_key] = True \ No newline at end of file From aa5420f57dbb17d2ef2f2cb702976fbe22ac3cee Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 22:49:43 +0200 Subject: [PATCH 6/8] Adaptive lighting 1.11.0 --- .../adaptive_lighting/__init__.py | 3 + .../adaptive_lighting/_docs_helpers.py | 116 ++ custom_components/adaptive_lighting/const.py | 195 +++- .../adaptive_lighting/manifest.json | 12 +- .../adaptive_lighting/services.yaml | 245 +++- .../adaptive_lighting/strings.json | 57 +- custom_components/adaptive_lighting/switch.py | 1015 ++++++++++++----- .../adaptive_lighting/translations/cs.json | 57 + .../adaptive_lighting/translations/en.json | 57 +- .../adaptive_lighting/translations/pl.json | 2 +- 10 files changed, 1421 insertions(+), 338 deletions(-) create mode 100644 custom_components/adaptive_lighting/_docs_helpers.py create mode 100644 custom_components/adaptive_lighting/translations/cs.json diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 33881c75..dc928a6b 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service import voluptuous as vol from .const import ( @@ -37,6 +38,8 @@ def _all_unique_names(value): async def async_setup(hass: HomeAssistant, config: dict[str, Any]): """Import integration from config.""" + # This will reload any changes the user made to any YAML configurations. + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) if DOMAIN in config: for entry in config[DOMAIN]: diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py new file mode 100644 index 00000000..40afc235 --- /dev/null +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -0,0 +1,116 @@ +from typing import Any + +from homeassistant.helpers import selector +import homeassistant.helpers.config_validation as cv +import pandas as pd +import voluptuous as vol + +from .const import ( + DOCS, + DOCS_APPLY, + DOCS_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, + VALIDATION_TUPLES, + apply_service_schema, +) + + +def _format_voluptuous_instance(instance): + coerce_type = None + min_val = None + max_val = None + + for validator in instance.validators: + if isinstance(validator, vol.Coerce): + coerce_type = validator.type.__name__ + elif isinstance(validator, (vol.Clamp, vol.Range)): + min_val = validator.min + max_val = validator.max + + if min_val is not None and max_val is not None: + return f"`{coerce_type}` {min_val}-{max_val}" + elif min_val is not None: + return f"`{coerce_type} > {min_val}`" + elif max_val is not None: + return f"`{coerce_type} < {max_val}`" + else: + return f"`{coerce_type}`" + + +def _type_to_str(type_: Any) -> str: + """Convert a (voluptuous) type to a string.""" + if type_ == cv.entity_ids: + return "list of `entity_id`s" + elif type_ in (bool, int, float, str): + return f"`{type_.__name__}`" + elif type_ == cv.boolean: + return "bool" + elif isinstance(type_, vol.All): + return _format_voluptuous_instance(type_) + elif isinstance(type_, vol.In): + return f"one of `{type_.container}`" + elif isinstance(type_, selector.SelectSelector): + return f"one of `{type_.config['options']}`" + elif isinstance(type_, selector.ColorRGBSelector): + return "RGB color" + else: + raise ValueError(f"Unknown type: {type_}") + + +def generate_config_markdown_table(): + import pandas as pd + + rows = [] + for k, default, type_ in VALIDATION_TUPLES: + description = DOCS[k] + row = { + "Variable name": f"`{k}`", + "Description": description, + "Default": f"`{default}`", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: + result = {} + for key, value in schema.schema.items(): + if isinstance(key, vol.Optional): + default_value = key.default + result[key.schema] = (default_value, value) + return result + + +def _generate_service_markdown_table( + schema: dict[str, tuple[Any, Any]], alternative_docs: dict[str, str] = None +): + schema = _schema_to_dict(schema) + rows = [] + for k, (default, type_) in schema.items(): + if alternative_docs is not None and k in alternative_docs: + description = alternative_docs[k] + else: + description = DOCS[k] + row = { + "Service data attribute": f"`{k}`", + "Description": description, + "Required": "✅" if default == vol.UNDEFINED else "❌", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def generate_apply_markdown_table(): + return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY) + + +def generate_set_manual_control_markdown_table(): + return _generate_service_markdown_table( + SET_MANUAL_CONTROL_SCHEMA, DOCS_MANUAL_CONTROL + ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 62c71fa2..64620927 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -1,50 +1,180 @@ """Constants for the Adaptive Lighting integration.""" from homeassistant.components.light import VALID_TRANSITION +from homeassistant.const import CONF_ENTITY_ID from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv import voluptuous as vol -ICON = "mdi:theme-light-dark" +ICON_MAIN = "mdi:theme-light-dark" +ICON_BRIGHTNESS = "mdi:brightness-4" +ICON_COLOR_TEMP = "mdi:sun-thermometer" +ICON_SLEEP = "mdi:sleep" DOMAIN = "adaptive_lighting" SUN_EVENT_NOON = "solar_noon" SUN_EVENT_MIDNIGHT = "solar_midnight" +DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"} + + CONF_NAME, DEFAULT_NAME = "name", "default" +DOCS[CONF_NAME] = "Display name for this switch. 📝" + CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] +DOCS[CONF_LIGHTS] = "List of light entity_ids to be controlled (may be empty). 🌟" + CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( "detect_non_ha_changes", False, ) +DOCS[CONF_DETECT_NON_HA_CHANGES] = ( + "Detect non-`light.turn_on` state changes and stop adapting lights. " + "Requires `take_over_control`. 🕵️" +) + +CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( + "include_config_in_attributes", + False, +) +DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = ( + "Show all options as attributes on the switch in " + "Home Assistant when set to `true`. 📝" +) + CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 +DOCS[CONF_INITIAL_TRANSITION] = ( + "Duration of the first transition when lights turn " + "from `off` to `on` in seconds. ⏲️" +) + CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 +DOCS[CONF_SLEEP_TRANSITION] = ( + 'Duration of transition when "sleep mode" is toggled ' "in seconds. 😴" +) + CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 +DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄" + CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 +DOCS[CONF_MAX_BRIGHTNESS] = "Maximum brightness percentage. 💡" + CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500 +DOCS[CONF_MAX_COLOR_TEMP] = "Coldest color temperature in Kelvin. ❄️" + CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 +DOCS[CONF_MIN_BRIGHTNESS] = "Minimum brightness percentage. 💡" + CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 +DOCS[CONF_MIN_COLOR_TEMP] = "Warmest color temperature in Kelvin. 🔥" + CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False +DOCS[CONF_ONLY_ONCE] = ( + "Adapt lights only when they are turned on (`true`) or keep adapting them " + "(`false`). 🔄" +) + CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False +DOCS[CONF_PREFER_RGB_COLOR] = ( + "Whether to prefer RGB color adjustment over " + "light color temperature when possible. 🌈" +) + CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = ( "separate_turn_on_commands", False, ) +DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = ( + "Use separate `light.turn_on` calls for color and brightness, needed for " + "some light types. 🔀" +) + CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 +DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness percentage of lights in sleep mode. 😴" + CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 +DOCS[CONF_SLEEP_COLOR_TEMP] = ( + "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is " + "`color_temp`) in Kelvin. 😴" +) + CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] +DOCS[CONF_SLEEP_RGB_COLOR] = ( + "RGB color in sleep mode (used when " '`sleep_rgb_or_color_temp` is "rgb_color"). 🌈' +) + CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( "sleep_rgb_or_color_temp", "color_temp", ) +DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = ( + 'Use either `"rgb_color"` or `"color_temp"` ' "in sleep mode. 🌙" +) + CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 +DOCS[CONF_SUNRISE_OFFSET] = ( + "Adjust sunrise time with a positive or negative offset " "in seconds. ⏰" +) + CONF_SUNRISE_TIME = "sunrise_time" +DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅" + CONF_MAX_SUNRISE_TIME = "max_sunrise_time" +DOCS[CONF_MAX_SUNRISE_TIME] = ( + "Set the latest virtual sunrise time (HH:MM:SS), allowing" + " for earlier real sunrises. 🌅" +) + CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 +DOCS[ + CONF_SUNSET_OFFSET +] = "Adjust sunset time with a positive or negative offset in seconds. ⏰" + CONF_SUNSET_TIME = "sunset_time" +DOCS[CONF_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for sunset. 🌇" + CONF_MIN_SUNSET_TIME = "min_sunset_time" +DOCS[CONF_MIN_SUNSET_TIME] = ( + "Set the earliest virtual sunset time (HH:MM:SS), allowing" + " for later real sunsets. 🌇" +) + CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True +DOCS[CONF_TAKE_OVER_CONTROL] = ( + "Disable Adaptive Lighting if another source calls `light.turn_on` while lights " + "are on and being adapted. Note that this calls `homeassistant.update_entity` " + "every `interval`! 🔒" +) + CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 +DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. 🕑" + +CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = ( + "transition_until_sleep", + False, +) +DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( + "When enabled, Adaptive Lighting will treat sleep settings as the minimum, " + "transitioning to these values after sunset. 🌙" +) + +CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 +DOCS[CONF_ADAPT_DELAY] = ( + "Wait time (seconds) between light turn on and Adaptive Lighting applying " + "changes. Might help to avoid flickering. ⏲️" +) + +CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 +DOCS[CONF_SEND_SPLIT_DELAY] = ( + "Delay (ms) between `separate_turn_on_commands` for lights that don't support " + "simultaneous brightness and color setting. ⏲️" +) + +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 +DOCS[CONF_AUTORESET_CONTROL] = ( + "Automatically reset the manual control after a number of seconds. " + "Set to 0 to disable. ⏲️" +) SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" @@ -53,16 +183,39 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" ATTR_ADAPT_COLOR = "adapt_color" +DOCS[ATTR_ADAPT_COLOR] = "Whether to adapt the color on supporting lights. 🌈" ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" +DOCS[ATTR_ADAPT_BRIGHTNESS] = "Whether to adapt the brightness of the light. 🌞" SERVICE_SET_MANUAL_CONTROL = "set_manual_control" CONF_MANUAL_CONTROL = "manual_control" +DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" +DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆" +SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" +CONF_USE_DEFAULTS = "use_defaults" +DOCS[CONF_USE_DEFAULTS] = ( + "Sets the default values not specified in this service call. Options: " + '"current" (default, retains current values), "factory" (resets to ' + 'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' +) -CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 TURNING_OFF_DELAY = 5 -CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 + +DOCS_MANUAL_CONTROL = { + CONF_ENTITY_ID: "The `entity_id` of the switch in which to (un)mark the " + "light as being `manually controlled`. 📝", + CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the " + "switch are selected. 💡", + CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the ' + 'light from the "manual_control" list. 🔒', +} + +DOCS_APPLY = { + CONF_ENTITY_ID: "The `entity_id` of the switch with the settings to apply. 📝", + CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡", +} def int_between(min_int, max_int): @@ -73,9 +226,11 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), + (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), + (CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), @@ -110,7 +265,12 @@ def int_between(min_int, max_int): (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), - (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, int_between(0, 10000)), + (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), + ( + CONF_AUTORESET_CONTROL, + DEFAULT_AUTORESET_CONTROL, + int_between(0, 365 * 24 * 60 * 60), # 1 year max + ), ] @@ -159,3 +319,30 @@ def replace_none_str(value, replace_with=None): for key, default, validation in _yaml_validation_tuples } ) + + +def apply_service_schema(initial_transition: int = 1): + """Return the schema for the apply service.""" + return vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional( + CONF_TRANSITION, + default=initial_transition, + ): VALID_TRANSITION, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, + vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, + } + ) + + +SET_MANUAL_CONTROL_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, + } +) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index d6580942..2dec4c83 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -1,12 +1,12 @@ { "domain": "adaptive_lighting", "name": "Adaptive Lighting", - "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", - "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", + "codeowners": ["@basnijholt", "@RubenKelevra", "@th3w1zard1"], "config_flow": true, "dependencies": [], - "codeowners": ["@basnijholt", "@RubenKelevra"], - "version": "1.4.1", - "requirements": [], - "iot_class": "calculated" + "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", + "iot_class": "calculated", + "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", + "requirements": ["ulid-transform"], + "version": "1.11.0" } diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 8f449a77..cd25811b 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -1,36 +1,251 @@ +# This file is auto-generated by .github/update-services.py. apply: description: Applies the current Adaptive Lighting settings to lights. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. - example: switch.adaptive_lighting_default + description: The `entity_id` of the switch with the settings to apply. 📝 + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false lights: - description: "entity_id(s) of lights, default: lights of the switch" - example: light.bedroom_ceiling + description: A light (or list of lights) to apply the settings to. 💡 + selector: + entity: + domain: light + multiple: true transition: - description: Transition of the lights. + description: Duration of transition when lights change, in seconds. 🕑 example: 10 + selector: + text: null adapt_brightness: - description: "Adapt the 'brightness', default: true" + description: Whether to adapt the brightness of the light. 🌞 example: true + selector: + boolean: null adapt_color: - description: "Adapt the color_temp/color_rgb, default: true" + description: Whether to adapt the color on supporting lights. 🌈 example: true + selector: + boolean: null prefer_rgb_color: - description: "Prefer to use color_rgb over color_temp if possible, default: false" + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 example: false + selector: + boolean: null turn_on_lights: - description: "Turn on the lights that are off, default: false" + description: Whether to turn on lights that are currently off. 🔆 example: false + selector: + boolean: null set_manual_control: description: Mark whether a light is 'manually controlled'. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. - example: switch.adaptive_lighting_default + description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false + lights: + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 + selector: + entity: + domain: light + multiple: true manual_control: - description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" + description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 example: true - lights: - description: entity_id(s) of lights, if not specified, all lights in the switch are selected. - example: light.bedroom_ceiling + default: true + selector: + boolean: null +change_switch_settings: + description: Change any settings you'd like in the switch. All options here are the same as in the config flow. + fields: + entity_id: + description: Entity ID of the switch. 📝 + required: true + selector: + entity: + domain: switch + use_defaults: + description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' + example: current + required: false + default: current + selector: + select: + options: + - current + - configuration + - factory + include_config_in_attributes: + description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 + required: false + selector: + boolean: null + turn_on_lights: + description: Whether to turn on lights that are currently off. 🔆 + example: false + required: false + selector: + boolean: null + initial_transition: + description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ + example: 1 + required: false + selector: + text: null + sleep_transition: + description: Duration of transition when "sleep mode" is toggled in seconds. 😴 + example: 1 + required: false + selector: + text: null + max_brightness: + description: Maximum brightness percentage. 💡 + required: false + example: 100 + selector: + text: null + max_color_temp: + description: Coldest color temperature in Kelvin. ❄️ + required: false + example: 5500 + selector: + text: null + min_brightness: + description: Minimum brightness percentage. 💡 + required: false + example: 1 + selector: + text: null + min_color_temp: + description: Warmest color temperature in Kelvin. 🔥 + required: false + example: 2000 + selector: + text: null + only_once: + description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 + example: false + required: false + selector: + boolean: null + prefer_rgb_color: + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 + required: false + example: false + selector: + boolean: null + separate_turn_on_commands: + description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 + required: false + example: false + selector: + boolean: null + send_split_delay: + description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ + required: false + example: 0 + selector: + boolean: null + sleep_brightness: + description: Brightness percentage of lights in sleep mode. 😴 + required: false + example: 1 + selector: + text: null + sleep_rgb_or_color_temp: + description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 + required: false + example: color_temp + selector: + select: + options: + - rgb_color + - color_temp + sleep_rgb_color: + description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 + required: false + selector: + color_rgb: null + sleep_color_temp: + description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 + required: false + example: 1000 + selector: + text: null + sunrise_offset: + description: Adjust sunrise time with a positive or negative offset in seconds. ⏰ + required: false + example: 0 + selector: + number: + min: 0 + max: 86300 + sunrise_time: + description: Set a fixed time (HH:MM:SS) for sunrise. 🌅 + required: false + example: '' + selector: + time: null + sunset_offset: + description: Adjust sunset time with a positive or negative offset in seconds. ⏰ + required: false + example: '' + selector: + number: + min: 0 + max: 86300 + sunset_time: + description: Set a fixed time (HH:MM:SS) for sunset. 🌇 + example: '' + required: false + selector: + time: null + max_sunrise_time: + description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 + example: '' + required: false + selector: + time: null + min_sunset_time: + description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 + example: '' + required: false + selector: + time: null + take_over_control: + description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 + required: false + example: true + selector: + boolean: null + detect_non_ha_changes: + description: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ + required: false + example: false + selector: + boolean: null + transition: + description: Duration of transition when lights change, in seconds. 🕑 + required: false + example: 45 + selector: + text: null + adapt_delay: + description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ + required: false + example: 0 + selector: + text: null + autoreset_control_seconds: + description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ + required: false + example: 0 + selector: + text: null diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 74099754..54af5e11 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Choose a name for the Adaptive Lighting", + "title": "Choose a name for the Adaptive Lighting instance", "description": "Every instance can contain multiple lights!", "data": { "name": "Name" @@ -19,32 +19,35 @@ "title": "Adaptive Lighting options", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.", "data": { - "lights": "lights", - "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)", - "sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)", - "interval": "interval: Time between switch updates. (seconds)", - "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", - "max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)", - "min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)", - "min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)", - "only_once": "only_once: Only adapt the lights when turning them on.", - "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.", - "separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).", - "send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.", - "sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)", - "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'", - "sleep_rgb_color": "sleep_rgb_color, in RGB", - "sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)", - "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)", - "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)", - "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", - "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", - "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "transition": "transition: Duration of transition when lights change, in seconds. 🕑", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️", + "sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅", + "sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", + "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️" } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index ac1acc96..98747a17 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -4,7 +4,7 @@ import asyncio import base64 import bisect -from collections import defaultdict +from collections.abc import Callable, Coroutine from copy import deepcopy from dataclasses import dataclass import datetime @@ -21,10 +21,8 @@ ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -41,7 +39,6 @@ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, - VALID_TRANSITION, is_on, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -74,7 +71,7 @@ State, callback, ) -from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_platform, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change_event, @@ -88,8 +85,10 @@ color_RGB_to_xy, color_temperature_to_rgb, color_xy_to_hs, + color_xy_to_RGB, ) import homeassistant.util.dt as dt_util +import ulid_transform import voluptuous as vol from .const import ( @@ -99,7 +98,10 @@ ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, + CONF_ADAPT_UNTIL_SLEEP, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, + CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, CONF_LIGHTS, @@ -126,16 +128,23 @@ CONF_TAKE_OVER_CONTROL, CONF_TRANSITION, CONF_TURN_ON_LIGHTS, + CONF_USE_DEFAULTS, DOMAIN, EXTRA_VALIDATION, - ICON, + ICON_BRIGHTNESS, + ICON_COLOR_TEMP, + ICON_MAIN, + ICON_SLEEP, SERVICE_APPLY, + SERVICE_CHANGE_SWITCH_SETTINGS, SERVICE_SET_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, VALIDATION_TUPLES, + apply_service_schema, replace_none_str, ) @@ -160,10 +169,8 @@ COLOR_ATTRS = { # Should ATTR_PROFILE be in here? ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, } @@ -176,21 +183,58 @@ } # Keep a short domain version for the context instances (which can only be 36 chars) -_DOMAIN_SHORT = "adapt_lgt" +_DOMAIN_SHORT = "al" -def _int_to_bytes(i: int, signed: bool = False) -> bytes: - bits = i.bit_length() - if signed: - # Make room for the sign bit. - bits += 1 - return i.to_bytes((bits + 7) // 8, "little", signed=signed) +def _int_to_base36(num: int) -> str: + """ + Convert an integer to its base-36 representation using numbers and uppercase letters. + + Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive + alphanumeric representation. The function takes an integer `num` as input and returns + its base-36 representation as a string. + + Parameters + ---------- + num + The integer to convert to base-36. + + Returns + ------- + str + The base-36 representation of the input integer. + + Examples + -------- + >>> num = 123456 + >>> base36_num = int_to_base36(num) + >>> print(base36_num) + '2N9' + """ + ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + if num == 0: + return ALPHANUMERIC_CHARS[0] + + base36_str = "" + base = len(ALPHANUMERIC_CHARS) + + while num: + num, remainder = divmod(num, base) + base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str + + return base36_str def _short_hash(string: str, length: int = 4) -> str: """Create a hash of 'string' with length 'length'.""" - str_hash_bytes = _int_to_bytes(hash(string), signed=True) - return base64.b85encode(str_hash_bytes)[:length] + return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length] + + +def _remove_vowels(input_str: str, length: int = 4) -> str: + vowels = "aeiouAEIOU" + output_str = "".join([char for char in input_str if char not in vowels]) + return output_str.zfill(length)[:length] def create_context( @@ -198,12 +242,16 @@ def create_context( ) -> Context: """Create a context that can identify this integration.""" # Use a hash for the name because otherwise the context might become - # too long (max len == 36) to fit in the database. - name_hash = _short_hash(name) + # too long (max len == 26) to fit in the database. # Pack index with base85 to maximize the number of contexts we can create - # before we exceed the 36-character limit and are forced to wrap. - index_packed = base64.b85encode(_int_to_bytes(index, signed=False)) - context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36] + # before we exceed the 26-character limit and are forced to wrap. + time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID + name_hash = _short_hash(name) + which_short = _remove_vowels(which) + context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:" + chars_left = 26 - len(context_id_start) + index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:] + context_id = context_id_start + index_packed parent_id = parent.id if parent else None return Context(id=context_id, parent_id=parent_id) @@ -212,7 +260,7 @@ def is_our_context(context: Context | None) -> bool: """Check whether this integration created 'context'.""" if context is None: return False - return context.id.startswith(_DOMAIN_SHORT) + return f":{_DOMAIN_SHORT}:" in context.id def _split_service_data(service_data, adapt_brightness, adapt_color): @@ -237,57 +285,131 @@ def _split_service_data(service_data, adapt_brightness, adapt_color): return service_datas -async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): - """Handle the entity service apply.""" - hass = switch.hass +def _get_switches_with_lights( + hass: HomeAssistant, lights: list[str] +) -> list[AdaptiveSwitch]: + """Get all switches that control at least one of the lights passed.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + data = hass.data[DOMAIN] + switches = [] + for config in config_entries: + entry = data.get(config.entry_id) + if entry is None: # entry might be disabled and therefore missing + continue + switch = data[config.entry_id]["instance"] + all_check_lights = _expand_light_groups(hass, lights) + switch._expand_light_groups() + # Check if any of the lights are in the switch's lights + if set(switch._lights) & set(all_check_lights): + switches.append(switch) + return switches + + +def find_switch_for_lights( + hass: HomeAssistant, + lights: list[str], + is_on: bool = False, +) -> AdaptiveSwitch: + """Find the switch that controls the lights in 'lights'.""" + switches = _get_switches_with_lights(hass, lights) + if len(switches) == 1: + return switches[0] + elif len(switches) > 1: + on_switches = [s for s in switches if s.is_on] + if len(on_switches) == 1: + # Of the multiple switches, only one is on + return on_switches[0] + raise ValueError( + f"find_switch_for_lights: Light(s) {lights} found in multiple switch configs" + f" ({[s.entity_id for s in switches]}). You must pass a switch under" + f" 'entity_id'." + ) + else: + raise ValueError( + f"find_switch_for_lights: Light(s) {lights} not found in any switch's" + f" configuration. You must either include the light(s) that is/are" + f" in the integration config, or pass a switch under 'entity_id'." + ) + + +# For documentation on this function, see integration_entities() from HomeAssistant Core: +# https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/template.py#L1109 +def _get_switches_from_service_call( + hass: HomeAssistant, service_call: ServiceCall +) -> list[AdaptiveSwitch]: data = service_call.data - all_lights = data[CONF_LIGHTS] - if not all_lights: - all_lights = switch._lights - all_lights = _expand_light_groups(hass, all_lights) - switch.turn_on_off_listener.lights.update(all_lights) - _LOGGER.debug( - "Called 'adaptive_lighting.apply' service with '%s'", - data, - ) - for light in all_lights: - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - await switch._adapt_light( # pylint: disable=protected-access - light, - data[CONF_TRANSITION], - data[ATTR_ADAPT_BRIGHTNESS], - data[ATTR_ADAPT_COLOR], - data[CONF_PREFER_RGB_COLOR], - force=True, - context=switch.create_context("service", parent=service_call.context), + lights = data[CONF_LIGHTS] + switch_entity_ids: list[str] | None = data.get("entity_id") + + if not lights and not switch_entity_ids: + raise ValueError( + "adaptive-lighting: Neither a switch nor a light was provided in the service call." + " If you intend to adapt all lights on all switches, please inform the developers at" + " https://github.com/basnijholt/adaptive-lighting about your use case." + " Currently, you must pass either an adaptive-lighting switch or the lights to an" + " `adaptive_lighting` service call." + ) + + if switch_entity_ids is not None: + if len(switch_entity_ids) > 1 and lights: + raise ValueError( + f"adaptive-lighting: Cannot pass multiple switches with lights argument." + f" Invalid service data received: {service_call.data}" ) + switches = [] + ent_reg = entity_registry.async_get(hass) + for entity_id in switch_entity_ids: + ent_entry = ent_reg.async_get(entity_id) + config_id = ent_entry.config_entry_id + switches.append(hass.data[DOMAIN][config_id]["instance"]) + return switches + + if lights: + switch = find_switch_for_lights(hass, lights, service_call) + return [switch] + + raise ValueError( + f"adaptive-lighting: Incorrect data provided in service call." + f" Entities not found in the integration. Service data: {service_call.data}" + ) -async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): - """Set or unset lights as 'manually controlled'.""" - lights = service_call.data[CONF_LIGHTS] - if not lights: - all_lights = switch._lights # pylint: disable=protected-access +async def handle_change_switch_settings( + switch: AdaptiveSwitch, service_call: ServiceCall +) -> None: + """Allows HASS to change config values via a service call.""" + data = service_call.data + + which = data.get(CONF_USE_DEFAULTS, "current") + if which == "current": # use whatever we're already using. + defaults = switch._current_settings # pylint: disable=protected-access + elif which == "factory": # use actual defaults listed in the documentation + defaults = {key: default for key, default, _ in VALIDATION_TUPLES} + elif which == "configuration": + # use whatever's in the config flow or configuration.yaml + defaults = switch._config_backup # pylint: disable=protected-access else: - all_lights = _expand_light_groups(switch.hass, lights) + defaults = None + + switch._set_changeable_settings( + data=data, + defaults=defaults, + ) + _LOGGER.debug( - "Called 'adaptive_lighting.set_manual_control' service with '%s'", - service_call.data, + "Called 'adaptive_lighting.change_switch_settings' service with '%s'", + data, ) - if service_call.data[CONF_MANUAL_CONTROL]: - for light in all_lights: - switch.turn_on_off_listener.manual_control[light] = True - _fire_manual_control_event(switch, light, service_call.context) - else: - switch.turn_on_off_listener.reset(*all_lights) - # pylint: disable=protected-access - if switch.is_on: - await switch._update_attrs_and_maybe_adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context("service", parent=service_call.context), - ) + + all_lights = switch._lights # pylint: disable=protected-access + switch.turn_on_off_listener.reset(*all_lights, reset_manual_control=False) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context("service", parent=service_call.context), + ) @callback @@ -302,6 +424,7 @@ def _fire_manual_control_event( switch.entity_id, light, ) + switch.turn_on_off_listener.mark_as_manual_control(light) fire( f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, @@ -319,10 +442,15 @@ async def async_setup_entry( if ATTR_TURN_ON_OFF_LISTENER not in data: data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - - sleep_mode_switch = SimpleSwitch("Sleep Mode", False, hass, config_entry) - adapt_color_switch = SimpleSwitch("Adapt Color", True, hass, config_entry) - adapt_brightness_switch = SimpleSwitch("Adapt Brightness", True, hass, config_entry) + sleep_mode_switch = SimpleSwitch( + "Sleep Mode", False, hass, config_entry, ICON_SLEEP + ) + adapt_color_switch = SimpleSwitch( + "Adapt Color", True, hass, config_entry, ICON_COLOR_TEMP + ) + adapt_brightness_switch = SimpleSwitch( + "Adapt Brightness", True, hass, config_entry, ICON_BRIGHTNESS + ) switch = AdaptiveSwitch( hass, config_entry, @@ -332,6 +460,9 @@ async def async_setup_entry( adapt_brightness_switch, ) + # save our switch instance, allows us to make switch's entity_id optional in service calls. + hass.data[DOMAIN][config_entry.entry_id]["instance"] = switch + data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch @@ -342,42 +473,118 @@ async def async_setup_entry( update_before_add=True, ) + @callback + async def handle_apply(service_call: ServiceCall): + """Handle the entity service apply.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + switch.turn_on_off_listener.lights.update(all_lights) + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + await switch._adapt_light( # pylint: disable=protected-access + light, + data[CONF_TRANSITION], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], + force=True, + context=switch.create_context( + "service", parent=service_call.context + ), + ) + + @callback + async def handle_set_manual_control(service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + if service_call.data[CONF_MANUAL_CONTROL]: + for light in all_lights: + _fire_manual_control_event(switch, light, service_call.context) + else: + switch.turn_on_off_listener.reset(*all_lights) + if switch.is_on: + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context( + "service", parent=service_call.context + ), + ) + # Register `apply` service - platform = entity_platform.current_platform.get() - platform.async_register_entity_service( - SERVICE_APPLY, - { - vol.Optional( - CONF_LIGHTS, default=[] - ): cv.entity_ids, # pylint: disable=protected-access - vol.Optional( - CONF_TRANSITION, - default=switch._initial_transition, # pylint: disable=protected-access - ): VALID_TRANSITION, - vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, - vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, - vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, - vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, - }, - handle_apply, + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_APPLY, + service_func=handle_apply, + schema=apply_service_schema( + switch._initial_transition + ), # pylint: disable=protected-access + ) + + # Register `set_manual_control` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_MANUAL_CONTROL, + service_func=handle_set_manual_control, + schema=SET_MANUAL_CONTROL_SCHEMA, ) + args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string} + # Modifying these after init isn't possible + skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS) + for k, _, valid in VALIDATION_TUPLES: + if k not in skip: + args[vol.Optional(k)] = valid + platform = entity_platform.current_platform.get() platform.async_register_entity_service( - SERVICE_SET_MANUAL_CONTROL, - { - vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, - vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, - }, - handle_set_manual_control, + SERVICE_CHANGE_SWITCH_SETTINGS, + args, + handle_change_switch_settings, ) -def validate(config_entry: ConfigEntry): +def validate( + config_entry: ConfigEntry, + service_data: dict[str, Any] | None = None, + defaults: dict[str, Any] | None = None, +): """Get the options and data from the config_entry and add defaults.""" - defaults = {key: default for key, default, _ in VALIDATION_TUPLES} - data = deepcopy(defaults) - data.update(config_entry.options) # come from options flow - data.update(config_entry.data) # all yaml settings come from data + if defaults is None: + data = {key: default for key, default, _ in VALIDATION_TUPLES} + else: + data = defaults + + if config_entry is not None: + assert service_data is None + assert defaults is None + data.update(config_entry.options) # come from options flow + data.update(config_entry.data) # all yaml settings come from data + else: + assert service_data is not None + data.update(service_data) data = {key: replace_none_str(value) for key, value in data.items()} for key, (validate_value, _) in EXTRA_VALIDATION.items(): value = data.get(key) @@ -418,7 +625,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = { key for key, value in _SUPPORT_OPTS.items() if supported_features & value } @@ -464,6 +671,41 @@ def color_difference_redmean( return math.sqrt(red_term + green_term + blue_term) +# All comparisons should be done with RGB since +# converting anything to color temp is inaccurate. +def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + if ATTR_RGB_COLOR in attributes: + return attributes + + rgb = None + if ATTR_COLOR_TEMP_KELVIN in attributes: + rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN]) + elif ATTR_XY_COLOR in attributes: + rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR]) + + if rgb is not None: + attributes[ATTR_RGB_COLOR] = rgb + _LOGGER.debug(f"Converted {attributes} to rgb {rgb}") + else: + _LOGGER.debug("No suitable conversion found") + + return attributes + + +def _add_missing_attributes( + old_attributes: dict[str, Any], + new_attributes: dict[str, Any], +) -> dict[str, Any]: + if not any( + attr in old_attributes and attr in new_attributes + for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR] + ): + old_attributes = _convert_attributes(old_attributes) + new_attributes = _convert_attributes(new_attributes) + + return old_attributes, new_attributes + + def _attributes_have_changed( light: str, old_attributes: dict[str, Any], @@ -472,6 +714,11 @@ def _attributes_have_changed( adapt_color: bool, context: Context, ) -> bool: + if adapt_color: + old_attributes, new_attributes = _add_missing_attributes( + old_attributes, new_attributes + ) + if ( adapt_brightness and ATTR_BRIGHTNESS in old_attributes @@ -526,21 +773,6 @@ def _attributes_have_changed( context.id, ) return True - - switched_color_temp = ( - ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes - ) - switched_to_rgb_color = ( - ATTR_COLOR_TEMP_KELVIN in old_attributes - and ATTR_COLOR_TEMP_KELVIN not in new_attributes - ) - if switched_color_temp or switched_to_rgb_color: - # Light switched from RGB mode to color_temp or visa versa - _LOGGER.debug( - "'%s' switched from RGB mode to color_temp or visa versa", - light, - ) - return True return False @@ -557,6 +789,7 @@ def __init__( adapt_brightness_switch: SimpleSwitch, ): """Initialize the Adaptive Lighting switch.""" + # Set attributes that can't be modified during runtime self.hass = hass self.turn_on_off_listener = turn_on_off_listener self.sleep_mode_switch = sleep_mode_switch @@ -564,20 +797,94 @@ def __init__( self.adapt_brightness_switch = adapt_brightness_switch data = validate(config_entry) + self._name = data[CONF_NAME] + self._interval = data[CONF_INTERVAL] self._lights = data[CONF_LIGHTS] + # backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS + self._config_backup = deepcopy(data) + self._set_changeable_settings( + data=data, + defaults=None, + ) + + # Set other attributes + self._icon = ICON_MAIN + self._state = None + + # Tracks 'off' → 'on' state changes + self._on_to_off_event: dict[str, Event] = {} + # Tracks 'on' → 'off' state changes + self._off_to_on_event: dict[str, Event] = {} + # Locks that prevent light adjusting when waiting for a light to 'turn_off' + self._locks: dict[str, asyncio.Lock] = {} + # To count the number of `Context` instances + self._context_cnt: int = 0 + + # Set in self._update_attrs_and_maybe_adapt_lights + self._settings: dict[str, Any] = {} + + # Set and unset tracker in async_turn_on and async_turn_off + self.remove_listeners = [] + _LOGGER.debug( + "%s: Setting up with '%s'," + " config_entry.data: '%s'," + " config_entry.options: '%s', converted to '%s'.", + self._name, + self._lights, + config_entry.data, + config_entry.options, + data, + ) + + def _set_changeable_settings( + self, + data: dict, + defaults: dict, + ): + # Only pass settings users can change during runtime + data = validate( + config_entry=None, + service_data=data, + defaults=defaults, + ) + + # backup data for use in change_switch_settings "current" CONF_USE_DEFAULTS + self._current_settings = data + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + self._include_config_in_attributes = data[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] + self._config: dict[str, Any] = {} + if self._include_config_in_attributes: + attrdata = deepcopy(data) + for k, v in attrdata.items(): + if isinstance(v, (datetime.date, datetime.datetime)): + attrdata[k] = v.isoformat() + if isinstance(v, (datetime.timedelta)): + attrdata[k] = v.total_seconds() + self._config.update(attrdata) + self._initial_transition = data[CONF_INITIAL_TRANSITION] self._sleep_transition = data[CONF_SLEEP_TRANSITION] - self._interval = data[CONF_INTERVAL] self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] - self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._take_over_control = data[CONF_TAKE_OVER_CONTROL] + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]: + _LOGGER.warning( + "%s: Config mismatch: 'detect_non_ha_changes: true' " + "requires 'take_over_control' to be enabled. Adjusting config " + "and continuing setup with `take_over_control: true`.", + self._name, + ) + self._take_over_control = True + self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] + self._expand_light_groups() # updates manual control timers _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): # Astral v2.2 @@ -589,6 +896,7 @@ def __init__( self._sun_light_settings = SunLightSettings( name=self._name, astral_location=location, + adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], max_brightness=data[CONF_MAX_BRIGHTNESS], max_color_temp=data[CONF_MAX_COLOR_TEMP], min_brightness=data[CONF_MIN_BRIGHTNESS], @@ -606,33 +914,10 @@ def __init__( time_zone=self.hass.config.time_zone, transition=data[CONF_TRANSITION], ) - - # Set other attributes - self._icon = ICON - self._state = None - - # Tracks 'off' → 'on' state changes - self._on_to_off_event: dict[str, Event] = {} - # Tracks 'on' → 'off' state changes - self._off_to_on_event: dict[str, Event] = {} - # Locks that prevent light adjusting when waiting for a light to 'turn_off' - self._locks: dict[str, asyncio.Lock] = {} - # To count the number of `Context` instances - self._context_cnt: int = 0 - - # Set in self._update_attrs_and_maybe_adapt_lights - self._settings: dict[str, Any] = {} - - # Set and unset tracker in async_turn_on and async_turn_off - self.remove_listeners = [] _LOGGER.debug( - "%s: Setting up with '%s'," - " config_entry.data: '%s'," - " config_entry.options: '%s', converted to '%s'.", + "%s: Set switch settings for lights '%s'. now using data: '%s'", self._name, self._lights, - config_entry.data, - config_entry.options, data, ) @@ -674,6 +959,9 @@ async def async_will_remove_from_hass(self): def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) + self.turn_on_off_listener.set_auto_reset_manual_control_times( + all_lights, self._auto_reset_manual_control_time + ) self._lights = list(all_lights) async def _setup_listeners(self, _=None) -> None: @@ -715,14 +1003,24 @@ def icon(self) -> str: @property def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes of the switch.""" + extra_state_attributes = {"configuration": self._config} if not self.is_on: - return {key: None for key in self._settings} - manual_control = [ + for key in self._settings: + extra_state_attributes[key] = None + return extra_state_attributes + extra_state_attributes["manual_control"] = [ light for light in self._lights if self.turn_on_off_listener.manual_control.get(light) ] - return dict(self._settings, manual_control=manual_control) + extra_state_attributes.update(self._settings) + timers = self.turn_on_off_listener.auto_reset_manual_control_timers + extra_state_attributes["autoreset_time_remaining"] = { + light: time + for light in self._lights + if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0 + } + return extra_state_attributes def create_context( self, which: str = "default", parent: Context | None = None @@ -790,9 +1088,6 @@ async def _adapt_light( if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return - service_data = {ATTR_ENTITY_ID: light} - features = _supported_features(self.hass, light) - if transition is None: transition = self._transition if adapt_brightness is None: @@ -802,14 +1097,18 @@ async def _adapt_light( if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color - if "transition" in features: - service_data[ATTR_TRANSITION] = transition - # The switch might be off and not have _settings set. self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition ) + # Build service data. + service_data = {ATTR_ENTITY_ID: light} + features = _supported_features(self.hass, light) + + # Check transition == 0 to fix #378 + if "transition" in features and transition > 0: + service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness @@ -836,20 +1135,19 @@ async def _adapt_light( service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") - if ( - self._take_over_control - and self._detect_non_ha_changes - and not force - and await self.turn_on_off_listener.significant_change( - self, + + # See #80. Doesn't check if transitions differ but it does the job. + last_service_data = self.turn_on_off_listener.last_service_data + if not force and last_service_data.get(light) == service_data: + _LOGGER.debug( + "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", + self._name, light, - adapt_brightness, - adapt_color, - context, + context.id, ) - ): return - self.turn_on_off_listener.last_service_data[light] = service_data + else: + self.turn_on_off_listener.last_service_data[light] = service_data async def turn_on(service_data): _LOGGER.debug( @@ -895,17 +1193,42 @@ async def _update_attrs_and_maybe_adapt_lights( context.id, ) assert self.is_on - self._settings = self._sun_light_settings.get_settings( - self.sleep_mode_switch.is_on, transition + self._settings.update( + self._sun_light_settings.get_settings( + self.sleep_mode_switch.is_on, transition + ) ) self.async_write_ha_state() + if lights is None: lights = self._lights - if (self._only_once and not force) or not lights: + + filtered_lights = [] + if not force: + if self._only_once: + return + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + timer = self.turn_on_off_listener.transition_timers.get(light) + if timer is not None and timer.is_running(): + _LOGGER.debug( + "%s: Light '%s' is still transitioning", + self._name, + light, + ) + else: + filtered_lights.append(light) + else: + filtered_lights = lights + + if not filtered_lights: return - await self._adapt_lights(lights, transition, force, context) - async def _adapt_lights( + await self._update_manual_control_and_maybe_adapt( + filtered_lights, transition, force, context + ) + + async def _update_manual_control_and_maybe_adapt( self, lights: list[str], transition: int | None, @@ -914,34 +1237,53 @@ async def _adapt_lights( ) -> None: assert context is not None _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", + "%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called", self.name, lights, transition, force, context.id, ) + + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on + for light in lights: if not is_on(self.hass, light): continue - if ( - self._take_over_control - and self.turn_on_off_listener.is_manually_controlled( + + manually_controlled = self.turn_on_off_listener.is_manually_controlled( + self, + light, + force, + adapt_brightness, + adapt_color, + ) + + significant_change = ( + self._detect_non_ha_changes + and not force + and await self.turn_on_off_listener.significant_change( self, light, - force, - self.adapt_brightness_switch.is_on, - self.adapt_color_switch.is_on, + adapt_brightness, + adapt_color, + context, ) - ): - _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", - self._name, - light, - context.id, - ) - continue - await self._adapt_light(light, transition, force=force, context=context) + ) + + if self._take_over_control and (manually_controlled or significant_change): + if manually_controlled: + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", + self._name, + light, + context.id, + ) + else: + _fire_manual_control_event(self, light, context) + else: + await self._adapt_light(light, transition, force=force, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): @@ -1028,12 +1370,17 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" def __init__( - self, which: str, initial_state: bool, hass: HomeAssistant, config_entry + self, + which: str, + initial_state: bool, + hass: HomeAssistant, + config_entry: ConfigEntry, + icon: str, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) - self._icon = ICON + self._icon = icon self._state = None self._which = which name = data[CONF_NAME] @@ -1089,6 +1436,7 @@ class SunLightSettings: name: str astral_location: astral.Location + adapt_until_sleep: bool max_brightness: int max_color_temp: int min_brightness: int @@ -1238,7 +1586,12 @@ def calc_color_temp_kelvin(self, percent: float) -> int: delta = self.max_color_temp - self.min_color_temp ct = (delta * percent) + self.min_color_temp return 5 * round(ct / 5) # round to nearest 5 - return self.min_color_temp + if percent == 0 or not self.adapt_until_sleep: + return self.min_color_temp + if self.adapt_until_sleep and percent < 0: + delta = abs(self.min_color_temp - self.sleep_color_temp) + ct = (delta * abs(1 + percent)) + self.sleep_color_temp + return 5 * round(ct / 5) # round to nearest 5 def get_settings( self, is_sleep, transition @@ -1261,11 +1614,14 @@ def get_settings( rgb_color: tuple[float, float, float] = color_temperature_to_rgb( color_temp_kelvin ) + # backwards compatibility for versions < 1.3.1 - see #403 + color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) return { "brightness_pct": brightness_pct, "color_temp_kelvin": color_temp_kelvin, + "color_temp_mired": color_temp_mired, "rgb_color": rgb_color, "xy_color": xy_color, "hs_color": hs_color, @@ -1289,16 +1645,17 @@ def __init__(self, hass: HomeAssistant): self.sleep_tasks: dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manual_control: dict[str, bool] = {} - # Counts the number of times (in a row) a light had a changed state. - self.cnt_significant_changes: dict[str, int] = defaultdict(int) # Track 'state_changed' events of self.lights resulting from this integration self.last_state_change: dict[str, list[State]] = {} # Track last 'service_data' to 'light.turn_on' resulting from this integration self.last_service_data: dict[str, dict[str, Any]] = {} - # When a state is different `max_cnt_significant_changes` times in a row, - # mark it as manually_controlled. - self.max_cnt_significant_changes = 2 + # Track auto reset of manual_control + self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} + self.auto_reset_manual_control_times: dict[str, float] = {} + + # Track light transitions + self.transition_timers: dict[str, _AsyncSingleShotTimer] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -1307,14 +1664,106 @@ def __init__(self, hass: HomeAssistant): EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def _handle_timer( + self, + light: str, + timers_dict: dict[str, _AsyncSingleShotTimer], + delay: float | None, + reset_coroutine: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + timer = timers_dict.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + timers_dict.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + timer = _AsyncSingleShotTimer(delay, reset_coroutine) + timers_dict[light] = timer + timer.start() + + def start_transition_timer(self, light: str) -> None: + """Mark a light as manually controlled.""" + last_service_data = self.last_service_data.get(light) + if not last_service_data: + _LOGGER.debug("This should not ever happen. Please report to the devs.") + return + last_transition = last_service_data.get(ATTR_TRANSITION) + if not last_transition: + _LOGGER.debug( + "No transition in last adapt for light %s, continuing...", light + ) + return + _LOGGER.debug( + "Start transition timer of %s seconds for light %s", last_transition, light + ) + + async def reset(): + ValueError("TEST") + _LOGGER.debug( + "Transition finished for light %s", + light, + ) + + self._handle_timer(light, self.transition_timers, last_transition, reset) + + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): + """Set the time after which the lights are automatically reset.""" + if time == 0: + return + for light in lights: + old_time = self.auto_reset_manual_control_times.get(light) + if (old_time is not None) and (old_time != time): + _LOGGER.info( + "Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds." + " This might happen because the light is in multiple swiches" + " or because of a config change.", + light, + old_time, + time, + ) + self.auto_reset_manual_control_times[light] = time + + def mark_as_manual_control(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Marking '%s' as manually controlled.", light) + self.manual_control[light] = True + delay = self.auto_reset_manual_control_times.get(light) + + async def reset(): + self.reset(light) + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + await switch._update_attrs_and_maybe_adapt_lights( + [light], + transition=switch._initial_transition, + force=True, + context=switch.create_context("autoreset"), + ) + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + assert not self.manual_control[light] + + self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) + def reset(self, *lights, reset_manual_control=True) -> None: """Reset the 'manual_control' status of the lights.""" for light in lights: if reset_manual_control: self.manual_control[light] = False + timer = self.auto_reset_manual_control_timers.pop(light, None) + if timer is not None: + timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) - self.cnt_significant_changes[light] = 0 async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -1369,11 +1818,19 @@ async def turn_on_off_event_listener(self, event: Event) -> None: if task is not None: task.cancel() self.turn_on_event[eid] = event + timer = self.auto_reset_manual_control_timers.get(eid) + if ( + timer is not None + and timer.is_running() + and event.time_fired > timer.start_time + ): + # Restart the auto reset timer + timer.start() async def state_changed_event_listener(self, event: Event) -> None: """Track 'state_changed' events.""" entity_id = event.data.get(ATTR_ENTITY_ID, "") - if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN: + if entity_id not in self.lights: return new_state = event.data.get("new_state") @@ -1385,11 +1842,7 @@ async def state_changed_event_listener(self, event: Event) -> None: new_state.context.id, ) - if ( - new_state is not None - and new_state.state == STATE_ON - and is_our_context(new_state.context) - ): + if new_state is not None and new_state.state == STATE_ON: # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of @@ -1402,21 +1855,33 @@ async def state_changed_event_listener(self, event: Event) -> None: # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). old_state: list[State] | None = self.last_state_change.get(entity_id) - if ( - old_state is not None - and old_state[0].context.id == new_state.context.id - ): - # If there is already a state change event from this event (with this - # context) then append it to the already existing list. - _LOGGER.debug( - "State change event of '%s' is already in 'self.last_state_change' (%s)" - " adding this state also", - entity_id, - new_state.context.id, - ) + if is_our_context(new_state.context): + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): + _LOGGER.debug( + "TurnOnOffListener: State change event of '%s' is already" + " in 'self.last_state_change' (%s)" + " adding this state also", + entity_id, + new_state.context.id, + ) + self.last_state_change[entity_id].append(new_state) + else: + _LOGGER.debug( + "TurnOnOffListener: New adapt '%s' found for %s", + new_state, + entity_id, + ) + self.last_state_change[entity_id] = [new_state] + _LOGGER.debug( + "Last transition: %s", + self.last_service_data[entity_id].get(ATTR_TRANSITION), + ) + self.start_transition_timer(entity_id) + elif old_state is not None: self.last_state_change[entity_id].append(new_state) - else: - self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, @@ -1444,7 +1909,7 @@ def is_manually_controlled( ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.manual_control[light] = True + manual_control = True _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1471,64 +1936,56 @@ async def significant_change( detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ - if light not in self.last_state_change: - return False - old_states: list[State] = self.last_state_change[light] - await self.hass.helpers.entity_component.async_update_entity(light) - new_state = self.hass.states.get(light) + last_service_data = self.last_service_data.get(light) + if last_service_data is None: + return compare_to = functools.partial( _attributes_have_changed, light=light, - new_attributes=new_state.attributes, adapt_brightness=adapt_brightness, adapt_color=adapt_color, context=context, ) - for index, old_state in enumerate(old_states): - changed = compare_to(old_attributes=old_state.attributes) - if not changed: - _LOGGER.debug( - "State of '%s' didn't change wrt change event nr. %s (context.id=%s)", - light, - index, - context.id, - ) - break - - last_service_data = self.last_service_data.get(light) - if changed and last_service_data is not None: - # It can happen that the state change events that are associated - # with the last 'light.turn_on' call by this integration were not - # final states. Possibly a later EVENT_STATE_CHANGED happened, where - # the correct target brightness/color was reached. - changed = compare_to(old_attributes=last_service_data) - if not changed: + # Update state and check for a manual change not done in HA. + # Ensure HASS is correctly updating your light's state with + # light.turn_on calls if any problems arise. This + # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. + if switch._detect_non_ha_changes: + _LOGGER.debug( + "%s: 'detect_non_ha_changes: true', calling update_entity(%s)" + " and check if it's last adapt succeeded.", + switch._name, + light, + ) + # This update_entity probably isn't necessary now that we're checking + # if transitions finished from our last adapt. + await self.hass.helpers.entity_component.async_update_entity(light) + refreshed_state = self.hass.states.get(light) + _LOGGER.debug( + "%s: Current state of %s: %s", + switch._name, + light, + refreshed_state, + ) + changed = compare_to( + old_attributes=last_service_data, + new_attributes=refreshed_state.attributes, + ) + if changed: _LOGGER.debug( "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", light, context.id, ) - - n_changes = self.cnt_significant_changes[light] - if changed: - self.cnt_significant_changes[light] += 1 - if n_changes >= self.max_cnt_significant_changes: - # Only mark a light as significantly changing, if changed==True - # N times in a row. We do this because sometimes a state changes - # happens only *after* a new update interval has already started. - self.manual_control[light] = True - _fire_manual_control_event(switch, light, context, is_async=False) - else: - if n_changes > 1: - _LOGGER.debug( - "State of '%s' had 'cnt_significant_changes=%s' but the state" - " changed to the expected settings now", - light, - n_changes, - ) - self.cnt_significant_changes[light] = 0 - - return changed + return True + _LOGGER.debug( + "%s: Light '%s' correctly matches our last adapt's service data, continuing..." + " context.id=%s.", + switch._name, + light, + context.id, + ) + return False async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None @@ -1627,3 +2084,45 @@ async def maybe_cancel_adjusting( # other 'off' → 'on' state switches resulting from polling. That # would mean we 'return True' here. return False + + +class _AsyncSingleShotTimer: + def __init__(self, delay, callback): + """Initialize the timer.""" + self.delay = delay + self.callback = callback + self.task = None + self.start_time: int | None = None + + async def _run(self): + """Run the timer. Don't call this directly, use start() instead.""" + self.start_time = dt_util.utcnow() + await asyncio.sleep(self.delay) + if self.callback: + if asyncio.iscoroutinefunction(self.callback): + await self.callback() + else: + self.callback() + + def is_running(self): + """Return whether the timer is running.""" + return self.task is not None and not self.task.done() + + def start(self): + """Start the timer.""" + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = asyncio.create_task(self._run()) + + def cancel(self): + """Cancel the timer.""" + if self.task: + self.task.cancel() + self.callback = None + + def remaining_time(self): + """Return the remaining time before the timer expires.""" + if self.start_time is not None: + elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds() + return max(0, self.delay - elapsed_time) + return 0 diff --git a/custom_components/adaptive_lighting/translations/cs.json b/custom_components/adaptive_lighting/translations/cs.json new file mode 100644 index 00000000..58f8fead --- /dev/null +++ b/custom_components/adaptive_lighting/translations/cs.json @@ -0,0 +1,57 @@ +{ + "title": "Adaptivní osvětlení", + "config": { + "step": { + "user": { + "title": "Vyberte název instance Adaptivního osvětlení", + "description": "Vyberte název pro tuto instanci. Můžete spustit několik instancí Adaptivního osvětlení, každá z nich může obsahovat více světel!", + "data": { + "name": "Název" + } + } + }, + "abort": { + "already_configured": "Toto zařízení je již nakonfigurováno" + } + }, + "options": { + "step": { + "init": { + "title": "Nastavení adaptivního osvětlení", + "description": "Všechna nastavení komponenty Adaptivního osvětlení. Názvy možností odpovídají nastavení YAML. Pokud máte v konfiguraci YAML definovánu položku 'adaptive_lighting', nezobrazí se žádné možnosti.", + "data": { + "lights": "osvětlení", + "initial_transition": "initial_transition: Prodlení pro změnu z 'vypnuto' do 'zapnuto' (sekundy)", + "sleep_transition": "sleep_transition: Prodleva pro přepnutí do „režimu spánku“ (sekundy)", + "interval": "interval: Prodleva pro změny osvětlení (v sekundách)", + "max_brightness": "max_brightness: Nejvyšší jas osvětlení během cyklu. (%)", + "max_color_temp": "max_color_temp: Nejchladnější odstín cyklu teploty barev. (Kelvin)", + "min_brightness": "min_brightness: Nejnižší jas osvětlení během cyklu. (%)", + "min_color_temp": "min_color_temp, Nejteplejší odstín cyklu teploty barev. (Kelvin)", + "only_once": "only_once: Přizpůsobení osvětlení pouze při rozsvícení.", + "prefer_rgb_color": "prefer_rgb_color: Upřednostněte použití 'rgb_color' před 'color_temp'.", + "separate_turn_on_commands": "separate_turn_on_commands: Oddělení příkazů pro každý atribut (barva, jas, atd.) v atributu 'light.turn_on' (vyžadováno pro některá světla).", + "send_split_delay": "send_split_delay: prodleva mezi příkazy (milisekundy), když je použit atribut 'separate_turn_on_commands'. Může zajistit správné zpracování obou příkazů.", + "sleep_brightness": "sleep_brightness, Nastavení jasu pro režim spánku. (%)", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, použijte 'rgb_color' nebo 'color_temp'", + "sleep_rgb_color": "sleep_rgb_color, v RGB", + "sleep_color_temp": "sleep_color_temp: Nastavení teploty barev pro režim spánku. (v Kelvinech)", + "sunrise_offset": "sunrise_offset: Jak dlouho před (-) nebo po (+) definovat bod cyklu východu slunce (+/- v sekundách)", + "sunrise_time": "sunrise_time: Manuální přepsání času východu slunce, pokud je „None“, použije se skutečný čas východu slunce ve vaší lokalitě (HH:MM:SS)", + "max_sunrise_time": "max_sunrise_time: Ruční přepsání nejpozdějšího času východu slunce, pokud je „None“, použije se skutečný čas východu slunce vaší lokality (HH:MM:SS)", + "sunset_offset": "sunset_offset: Jak dlouho před (-) nebo po (+) definovat bod cyklu západu slunce (+/- v sekundách)", + "sunset_time": "sunset_time: Ruční přepsání času západu slunce, pokud je „None“, použije se skutečný čas západu slunce vaší lokality (HH:MM:SS)", + "min_sunset_time": "min_sunset_time: Ruční přepsání nejdřívějšího času západu slunce, pokud je „None“, použije se skutečný čas západu slunce vaší lokality (HH:MM:SS)", + "take_over_control": "take_over_control: Je-li volán 'light.turn_on' z jiného zdroje, než Adaptivním osvětlením, když je světlo již rozsvíceno, přestaňte toto světlo ovládat, dokud není vypnuto -> zapnuto (nebo i vypínačem).", + "detect_non_ha_changes": "detect_non_ha_changes: detekuje všechny změny >10% provedených pro osvětlení (také mimo HA), vyžaduje povolení atributu 'take_over_control' (každý 'interval' spouští 'homeassistant.update_entity'!)", + "transition": "transition: doba přechodu při změně osvětlení (sekundy)", + "adapt_delay": "adapt_delay: prodleva mezi zapnutím světla ( sekundy) a projevem změny v Adaptivní osvětlení. Může předcházet blikání." + } + } + }, + "error": { + "option_error": "Neplatná možnost", + "entity_missing": "V aplikaci Home Assistant chybí jedna nebo více vybraných entit osvětlení" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index be556ec0..38fb7b0e 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Choose a name for the Adaptive Lighting instance", - "description": "Pick a name for this instance. You can run several instances of Adaptive lighting, each of these can contain multiple lights!", + "description": "Every instance can contain multiple lights!", "data": { "name": "Name" } @@ -20,32 +20,35 @@ "title": "Adaptive Lighting options", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.", "data": { - "lights": "lights", - "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)", - "sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)", - "interval": "interval: Time between switch updates. (seconds)", - "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", - "max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)", - "min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)", - "min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)", - "only_once": "only_once: Only adapt the lights when turning them on.", - "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.", - "separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).", - "send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.", - "sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)", - "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'", - "sleep_rgb_color": "sleep_rgb_color, in RGB", - "sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)", - "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)", - "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)", - "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", - "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", - "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "transition": "transition: Duration of transition when lights change, in seconds. 🕑", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️", + "sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅", + "sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", + "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️" } } }, diff --git a/custom_components/adaptive_lighting/translations/pl.json b/custom_components/adaptive_lighting/translations/pl.json index 80cc8fdd..99a97e3c 100644 --- a/custom_components/adaptive_lighting/translations/pl.json +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -36,7 +36,7 @@ "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- sekund)", "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- sekund)", - "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", + "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "Transition time when applying a change to the lights (sekund)" From 2bda4e06a3297b6a75973f8714d5f8ac399839f4 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Tue, 11 Apr 2023 23:16:35 +0200 Subject: [PATCH 7/8] Unique_ids for thermal comfort sensors --- sensors.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sensors.yaml b/sensors.yaml index 5614554b..d0580693 100644 --- a/sensors.yaml +++ b/sensors.yaml @@ -139,37 +139,46 @@ sensors: living_room: friendly_name: Living Room + unique_id: living_room_thermal_comfort temperature_sensor: sensor.living_room_temperature humidity_sensor: sensor.living_room_humidity dining_nook: friendly_name: Dining Nook + unique_id: dining_nook_thermal_comfort temperature_sensor: sensor.nook_temperature humidity_sensor: sensor.nook_humidity hall: friendly_name: Hall + unique_id: hall_thermal_comfort temperature_sensor: sensor.hall_temperature humidity_sensor: sensor.hall_humidity bathroom: friendly_name: Bathroom + unique_id: bathroom_thermal_comfort temperature_sensor: sensor.bathroom_temperature humidity_sensor: sensor.bathroom_humidity front_hall: friendly_name: Front Hall + unique_id: front_hall_thermal_comfort temperature_sensor: sensor.front_hall_sensor_temperature humidity_sensor: sensor.front_hall_sensor_humidity craft_room: friendly_name: Craft Room + unique_id: craft_room_thermal_comfort temperature_sensor: sensor.craft_room_temperature humidity_sensor: sensor.craft_room_humidity study: friendly_name: Study + unique_id: study_thermal_comfort temperature_sensor: sensor.study_temperature humidity_sensor: sensor.study_humidity guest_room: friendly_name: Guest Room + unique_id: guest_room_thermal_comfort temperature_sensor: sensor.guest_room_temperature humidity_sensor: sensor.guest_room_humidity master_bedroom: friendly_name: Master Bedroom + unique_id: master_bedroom_thermal_comfort temperature_sensor: sensor.master_bedroom_temperature humidity_sensor: sensor.master_bedroom_humidity From 1e10ecfc9111e75a1c9636dde1122efacd88de1b Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Mon, 24 Apr 2023 15:42:32 +0100 Subject: [PATCH 8/8] Include missing sensors --- packages/outside_motion.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/outside_motion.yaml b/packages/outside_motion.yaml index b499904e..310bf04b 100644 --- a/packages/outside_motion.yaml +++ b/packages/outside_motion.yaml @@ -6,11 +6,12 @@ automation: entity_id: - binary_sensor.outside_front_motion - binary_sensor.outside_driveway_motion + - binary_sensor.back_door_motion - binary_sensor.front_door_motion - binary_sensor.back_door_person_occupancy - binary_sensor.driveway_person_occupancy - - binary_sensor.driveway_person_occupancy - binary_sensor.gates_person_occupancy + - binary_sensor.back_door_all_occupancy to: 'on' condition: - condition: state