diff --git a/binary_sensors.yaml b/binary_sensors.yaml index 779300e6a..fba301ec6 100644 --- a/binary_sensors.yaml +++ b/binary_sensors.yaml @@ -36,29 +36,3 @@ mdi:home-outline {% endif %} value_template: "{{ is_state('person.kyle','home') or is_state('person.charlotte','home') or is_state('person.ronnie','home') }}" - -- platform: mqtt - name: Charlotte Bed Sensor - device_class: occupancy - state_topic: "devices/5ccf7fd8d915/Contact1/state" - payload_on: "1" - payload_off: "0" - -- platform: mqtt - name: Kyle Bed Sensor - device_class: occupancy - state_topic: "devices/5ccf7fd8d915/Contact2/state" - payload_on: "1" - payload_off: "0" - -- platform: mqtt - name: "Kyle Driving" - state_topic: "driving/mode/kyle" - payload_on: "true" - payload_off: "false" - -- platform: mqtt - name: "Charlotte Driving" - state_topic: "driving/mode/charlotte" - payload_on: "true" - payload_off: "false" diff --git a/cameras.yaml b/cameras.yaml index 1448867c2..2f6b8245b 100644 --- a/cameras.yaml +++ b/cameras.yaml @@ -27,19 +27,3 @@ name: FrontDoor still_image_url: http://viewpoint.house:4999/api/front_door/latest.jpg?h=400&motion=1 stream_source: rtmp://viewpoint.house/live/front_door - -- name: Back Door Last Person - platform: mqtt - topic: frigate/back_door/person/snapshot -- name: Front Door Last Person - platform: mqtt - topic: frigate/front_door/person/snapshot -- name: Driveway Last Person - platform: mqtt - topic: frigate/driveway/person/snapshot -- name: Driveway Last Car - platform: mqtt - topic: frigate/driveway/car/snapshot -- name: octoPrint camera - platform: mqtt - topic: octoPrint/camera diff --git a/configuration.yaml b/configuration.yaml index 5ca6fc6e0..f8d424de8 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -142,11 +142,6 @@ deconz: host: 172.24.32.13 port: 8100 -google: - client_id: !secret google_oauth_client_id - client_secret: !secret google_oauth_client_secret - track_new_calendar: false - influxdb: host: 172.24.32.13 port: 8086 @@ -159,10 +154,6 @@ influxdb: # Discover some devices automatically discovery: -spotify: - client_id: !secret spotify_client_id - client_secret: !secret spotify_client_secret - rfxtrx: # Remember this is mapped in the docker-compose file device: /dev/ttyUSB0 @@ -316,6 +307,7 @@ emulated_hue: ############ # automation: !include automation.yaml # script: !include scripts.yaml +mqtt: !include mqtt.yaml sensor: !include sensors.yaml binary_sensor: !include binary_sensors.yaml light: !include lights.yaml @@ -338,25 +330,6 @@ input_select: !include_dir_named input_select input_boolean: !include_dir_named input_boolean script: !include_dir_merge_named scripts/ -mqtt: - broker: 172.24.32.13 - port: 1883 - client_id: home-assistant-1 - discovery: true - discovery_prefix: "homeassistant" - keepalive: 60 - protocol: 3.1 - birth_message: - topic: 'hass/status' - payload: 'online' - qos: 1 - retain: true - will_message: - topic: 'hass/status' - payload: 'offline' - qos: 1 - retain: true - utility_meter: quarter_hourly_energy: source: sensor.energy_spent diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 214dc46b9..a02027ea6 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -50,13 +50,11 @@ from .const import ( ALEXA_COMPONENTS, CONF_ACCOUNTS, - CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, CONF_EXTENDED_ENTITY_DISCOVERY, CONF_INCLUDE_DEVICES, CONF_OAUTH, - CONF_OAUTH_LOGIN, CONF_OTPSECRET, CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, @@ -150,9 +148,12 @@ async def async_setup(hass, config, discovery_info=None): CONF_SCAN_INTERVAL: account[ CONF_SCAN_INTERVAL ].total_seconds(), - CONF_OAUTH: account.get(CONF_OAUTH, {}), - CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), - CONF_OAUTH_LOGIN: account.get(CONF_OAUTH_LOGIN, True), + CONF_OAUTH: account.get( + CONF_OAUTH, entry.data.get(CONF_OAUTH, {}) + ), + CONF_OTPSECRET: account.get( + CONF_OTPSECRET, entry.data.get(CONF_OTPSECRET, "") + ), }, ) entry_found = True @@ -173,7 +174,6 @@ async def async_setup(hass, config, discovery_info=None): CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL].total_seconds(), CONF_OAUTH: account.get(CONF_OAUTH, {}), CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), - CONF_OAUTH_LOGIN: account.get(CONF_OAUTH_LOGIN, True), }, ) ) @@ -218,12 +218,11 @@ async def relogin(event=None) -> None: otp_secret=account.get(CONF_OTPSECRET, ""), oauth=account.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=bool( - account.get(CONF_OAUTH, {}).get("access_token") - or account.get(CONF_OAUTH_LOGIN) - ), + oauth_login=True, ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj + else: + login_obj.oauth_login = True await login_obj.reset() # await login_obj.login() if await test_login_status(hass, config_entry, login_obj): @@ -310,10 +309,7 @@ async def login_success(event=None) -> None: otp_secret=account.get(CONF_OTPSECRET, ""), oauth=account.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=bool( - account.get(CONF_OAUTH, {}).get("access_token") - or account.get(CONF_OAUTH_LOGIN) - ), + oauth_login=True, ), ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login @@ -329,9 +325,7 @@ async def login_success(event=None) -> None: return True return False except AlexapyConnectionError as err: - raise ConfigEntryNotReady( - str(err) or "Connection Error during login" - ) from err + raise ConfigEntryNotReady(str(err) or "Connection Error during login") from err async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): @@ -622,7 +616,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False # prune stale devices - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) for device_entry in dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ): @@ -652,6 +646,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: "access_token": login_obj.access_token, "refresh_token": login_obj.refresh_token, "expires_in": login_obj.expires_in, + "mac_dms": login_obj.mac_dms, }, }, ) @@ -662,7 +657,7 @@ async def process_notifications(login_obj, raw_notifications=None): """Process raw notifications json.""" if not raw_notifications: raw_notifications = await AlexaAPI.get_notifications(login_obj) - email: Text = login_obj.email + email: str = login_obj.email previous = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( "notifications", {} ) @@ -802,6 +797,7 @@ async def ws_connect() -> WebsocketEchoClient: This will only attempt one login before failing. """ websocket: Optional[WebsocketEchoClient] = None + email = login_obj.email try: if login_obj.session.closed: _LOGGER.debug( @@ -818,6 +814,17 @@ async def ws_connect() -> WebsocketEchoClient: ) _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) await websocket.async_run() + except AlexapyLoginError as exception_: + _LOGGER.debug( + "%s: Login Error detected from websocket: %s", + hide_email(email), + exception_, + ) + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) + return except BaseException as exception_: # pylint: disable=broad-except _LOGGER.debug( "%s: Websocket creation failed: %s", hide_email(email), exception_ @@ -989,6 +996,7 @@ async def ws_handler(message_obj): "PUSH_LIST_ITEM_CHANGE", # update shopping list "PUSH_CONTENT_FOCUS_CHANGE", # likely prime related refocus "PUSH_DEVICE_SETUP_STATE_CHANGE", # likely device changes mid setup + "PUSH_MEDIA_PREFERENCE_CHANGE", # disliking or liking songs, https://github.com/custom-components/alexa_media_player/issues/1599 ]: pass else: @@ -1051,7 +1059,7 @@ async def ws_handler(message_obj): async def ws_open_handler(): """Handle websocket open.""" - email: Text = login_obj.email + email: str = login_obj.email _LOGGER.debug("%s: Websocket successfully connected", hide_email(email)) hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "websocketerror" @@ -1066,7 +1074,7 @@ async def ws_close_handler(): This should attempt to reconnect up to 5 times """ - email: Text = login_obj.email + email: str = login_obj.email if login_obj.close_requested: _LOGGER.debug( "%s: Close requested; will not reconnect websocket", hide_email(email) @@ -1078,7 +1086,7 @@ async def ws_close_handler(): ) return errors: int = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] - delay: int = 5 * 2 ** errors + delay: int = 5 * 2**errors last_attempt = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "websocket_lastattempt" ] @@ -1104,7 +1112,7 @@ async def ws_close_handler(): errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] = ( hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] + 1 ) - delay = 5 * 2 ** errors + delay = 5 * 2**errors errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] await asyncio.sleep(delay) if not websocket_enabled: @@ -1125,7 +1133,7 @@ async def ws_error_handler(message): the websocket and determine if a reconnect should be done. By specification, websockets will issue a close after every error. """ - email: Text = login_obj.email + email: str = login_obj.email errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] _LOGGER.debug( "%s: Received websocket error #%i %s: type %s", @@ -1242,7 +1250,7 @@ async def async_unload_entry(hass, entry) -> bool: return True -async def close_connections(hass, email: Text) -> None: +async def close_connections(hass, email: str) -> None: """Clear open aiohttp connections for email.""" if ( email not in hass.data[DATA_ALEXAMEDIA]["accounts"] @@ -1290,7 +1298,7 @@ async def test_login_status(hass, config_entry, login) -> bool: account = config_entry.data _LOGGER.debug("Logging in: %s %s", obfuscate(account), in_progess_instances(hass)) _LOGGER.debug("Login stats: %s", login.stats) - message: Text = f"Reauthenticate {login.email} on the [Integrations](/config/integrations) page. " + message: str = f"Reauthenticate {login.email} on the [Integrations](/config/integrations) page. " if login.stats.get("login_timestamp") != datetime(1, 1, 1): elaspsed_time: str = str(datetime.now() - login.stats.get("login_timestamp")) api_calls: int = login.stats.get("api_calls") @@ -1331,7 +1339,6 @@ async def test_login_status(hass, config_entry, login) -> bool: CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL].total_seconds() if isinstance(account[CONF_SCAN_INTERVAL], timedelta) else account[CONF_SCAN_INTERVAL], - CONF_COOKIES_TXT: account.get(CONF_COOKIES_TXT, ""), CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), }, ) diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py index 9a6148e86..9ce66dbd6 100644 --- a/custom_components/alexa_media/config_flow.py +++ b/custom_components/alexa_media/config_flow.py @@ -12,7 +12,6 @@ from datetime import timedelta from functools import reduce import logging -import re from typing import Any, Dict, List, Optional, Text from aiohttp import ClientConnectionError, ClientSession, web, web_response @@ -35,6 +34,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util import slugify +import httpx import voluptuous as vol from yarl import URL @@ -43,16 +43,13 @@ AUTH_CALLBACK_PATH, AUTH_PROXY_NAME, AUTH_PROXY_PATH, - CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, CONF_EXTENDED_ENTITY_DISCOVERY, CONF_HASS_URL, CONF_INCLUDE_DEVICES, CONF_OAUTH, - CONF_OAUTH_LOGIN, CONF_OTPSECRET, - CONF_PROXY, CONF_QUEUE_DELAY, CONF_SECURITYCODE, CONF_TOTP_REGISTER, @@ -60,7 +57,7 @@ DEFAULT_EXTENDED_ENTITY_DISCOVERY, DEFAULT_QUEUE_DELAY, DOMAIN, - HTTP_COOKIE_HEADER, + ISSUE_URL, STARTUP, ) from .helpers import calculate_uuid @@ -106,13 +103,12 @@ def __init__(self): _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) self.login = None - self.securitycode: Optional[Text] = None + self.securitycode: Optional[str] = None self.automatic_steps: int = 0 self.config = OrderedDict() self.proxy_schema = None self.data_schema = OrderedDict( [ - (vol.Optional(CONF_PROXY, default=False), bool), (vol.Required(CONF_EMAIL), str), (vol.Required(CONF_PASSWORD), str), (vol.Required(CONF_URL, default="amazon.com"), str), @@ -122,58 +118,6 @@ def __init__(self): (vol.Optional(CONF_INCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_EXCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_SCAN_INTERVAL, default=60), int), - (vol.Optional(CONF_COOKIES_TXT, default=""), str), - (vol.Optional(CONF_OAUTH_LOGIN, default=True), bool), - ] - ) - self.captcha_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - (vol.Required(CONF_PASSWORD), str), - ( - vol.Optional( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - (vol.Required("captcha"), str), - ] - ) - self.twofactor_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - ] - ) - self.claimspicker_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required("claimsoption", default=0), - vol.All(cv.positive_int, vol.Clamp(min=0)), - ), - ] - ) - self.authselect_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required("authselectoption", default=0), - vol.All(cv.positive_int, vol.Clamp(min=0)), - ), - ] - ) - self.verificationcode_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - (vol.Required("verificationcode"), str), ] ) self.totp_register = OrderedDict( @@ -188,7 +132,7 @@ async def async_step_user(self, user_input=None): """Provide a proxy for login.""" self._save_user_input_to_config(user_input=user_input) try: - hass_url: Text = get_url(self.hass, prefer_external=True) + hass_url: str = get_url(self.hass, prefer_external=True) except NoURLAvailableError: hass_url = "" self.proxy_schema = OrderedDict( @@ -249,17 +193,6 @@ async def async_step_user(self, user_input=None): ), int, ), - ( - vol.Optional(CONF_PROXY, default=self.config.get(CONF_PROXY, True)), - bool, - ), - ( - vol.Optional( - CONF_OAUTH_LOGIN, - default=self.config.get(CONF_OAUTH_LOGIN, True), - ), - bool, - ), ] ) if not user_input: @@ -268,12 +201,6 @@ async def async_step_user(self, user_input=None): data_schema=vol.Schema(self.proxy_schema), description_placeholders={"message": ""}, ) - if user_input and not user_input.get(CONF_PROXY): - return self.async_show_form( - step_id="user_legacy", - data_schema=vol.Schema(self._update_schema_defaults()), - description_placeholders={"message": ""}, - ) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ @@ -295,8 +222,9 @@ async def async_step_user(self, user_input=None): outputpath=self.hass.config.path, debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), + oauth=self.config.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=self.config.get(CONF_OAUTH_LOGIN, True), + oauth_login=True, ) else: _LOGGER.debug("Using existing login") @@ -312,7 +240,7 @@ async def async_step_user(self, user_input=None): errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) - hass_url: Text = user_input.get(CONF_HASS_URL) + hass_url: str = user_input.get(CONF_HASS_URL) hass_url_valid: bool = False async with ClientSession() as session: try: @@ -340,7 +268,7 @@ async def async_step_user(self, user_input=None): and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -408,7 +336,7 @@ async def async_step_user_legacy(self, user_input=None): if not user_input: self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(self.data_schema), description_placeholders={"message": ""}, ) @@ -423,13 +351,11 @@ async def async_step_user_legacy(self, user_input=None): _LOGGER.debug("Existing account found") self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(self.data_schema), errors={CONF_EMAIL: "identifier_exists"}, description_placeholders={"message": ""}, ) - if user_input and user_input.get(CONF_PROXY): - return await self.async_step_user(user_input=None) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ @@ -452,7 +378,7 @@ async def async_step_user_legacy(self, user_input=None): debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), uuid=uuid, - oauth_login=self.config.get(CONF_OAUTH_LOGIN, True), + oauth_login=True, ) else: _LOGGER.debug("Using existing login") @@ -462,7 +388,7 @@ async def async_step_user_legacy(self, user_input=None): and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -476,7 +402,7 @@ async def async_step_user_legacy(self, user_input=None): }, ) return self.async_show_form( - step_id="user_legacy", + step_id="user", errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) @@ -485,9 +411,6 @@ async def async_step_user_legacy(self, user_input=None): return await self._test_login() _LOGGER.debug("Trying to login %s", self.login.status) await self.login.login( - cookies=await self.login.load_cookie( - cookies_txt=self.config.get(CONF_COOKIES_TXT, "") - ), data=self.config, ) return await self._test_login() @@ -516,20 +439,12 @@ async def async_step_user_legacy(self, user_input=None): description_placeholders={"message": ""}, ) - async def async_step_captcha(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("captcha", user_input) - - async def async_step_twofactor(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("two_factor", user_input) - async def async_step_totp_register(self, user_input=None): """Handle the input processing of the config flow.""" self._save_user_input_to_config(user_input=user_input) if user_input and user_input.get("registered") is False: _LOGGER.debug("Not registered, regenerating") - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -542,25 +457,7 @@ async def async_step_totp_register(self, user_input=None): "message": otp, }, ) - if self.proxy: - return await self.async_step_start_proxy(user_input) - return await self.async_step_process("totp_register", self.config) - - async def async_step_claimspicker(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("claimspicker", user_input) - - async def async_step_authselect(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("authselect", user_input) - - async def async_step_verificationcode(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("verificationcode", user_input) - - async def async_step_action_required(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("action_required", user_input) + return await self.async_step_start_proxy(user_input) async def async_step_process(self, step_id, user_input=None): """Handle the input processing of the config flow.""" @@ -570,28 +467,8 @@ async def async_step_process(self, step_id, user_input=None): obfuscate(user_input), ) self._save_user_input_to_config(user_input=user_input) - if user_input and user_input.get(CONF_PROXY): - return await self.async_step_user(user_input=None) if user_input: - try: - await self.login.login(data=user_input) - except AlexapyConnectionError: - self.automatic_steps = 0 - return self.async_show_form( - step_id=step_id, - errors={"base": "connection_error"}, - description_placeholders={"message": ""}, - ) - except BaseException as ex: # pylint: disable=broad-except - _LOGGER.warning("Unknown error: %s", ex) - if self.config[CONF_DEBUG]: - raise - self.automatic_steps = 0 - return self.async_show_form( - step_id=step_id, - errors={"base": "unknown_error"}, - description_placeholders={"message": ""}, - ) + return await self.async_step_user(user_input=None) return await self._test_login() async def async_step_reauth(self, user_input=None): @@ -622,7 +499,7 @@ async def async_step_reauth(self, user_input=None): seconds_since_login, ) return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(reauth_schema), description_placeholders={"message": "REAUTH"}, ) @@ -641,14 +518,13 @@ async def _test_login(self): self.config.pop("reauth") if self.config.get(CONF_SECURITYCODE): self.config.pop(CONF_SECURITYCODE) - if self.config.get(CONF_PROXY): - self.config.pop(CONF_PROXY) if self.config.get("hass_url"): self.config.pop("hass_url") self.config[CONF_OAUTH] = { "access_token": login.access_token, "refresh_token": login.refresh_token, "expires_in": login.expires_in, + "mac_dms": login.mac_dms } self.hass.data.setdefault( DATA_ALEXAMEDIA, @@ -688,40 +564,12 @@ async def _test_login(self): return self.async_create_entry( title=f"{login.email} - {login.url}", data=self.config ) - if login.status and login.status.get("captcha_required"): - new_schema = self._update_ord_dict( - self.captcha_schema, - { - vol.Required( - CONF_PASSWORD, default=self.config[CONF_PASSWORD] - ): str, - vol.Optional( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ): str, - }, - ) - _LOGGER.debug("Creating config_flow to request captcha") - self.automatic_steps = 0 - return self.async_show_form( - step_id="captcha", - data_schema=vol.Schema(new_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "captcha_image": "[![captcha]({0})]({0})".format( - login.status["captcha_image_url"] - ), - "message": f" \n> {login.status.get('error_message','')}", - }, - ) if login.status and login.status.get("securitycode_required"): _LOGGER.debug( "Creating config_flow to request 2FA. Saved security code %s", self.securitycode, ) - generated_securitycode: Text = login.get_totp_token() + generated_securitycode: str = login.get_totp_token() if ( self.securitycode or generated_securitycode ) and self.automatic_steps < 2: @@ -743,94 +591,7 @@ async def _test_login(self): return await self.async_step_twofactor( user_input={CONF_SECURITYCODE: self.securitycode} ) - self.twofactor_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - ] - ) - self.automatic_steps = 0 - return self.async_show_form( - step_id="twofactor", - data_schema=vol.Schema(self.twofactor_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "message": f" \n> {login.status.get('error_message','')}", - }, - ) - if login.status and login.status.get("claimspicker_required"): - error_message = f" \n> {login.status.get('error_message', '')}" - _LOGGER.debug("Creating config_flow to select verification method") - claimspicker_message = login.status["claimspicker_message"] - self.automatic_steps = 0 - return self.async_show_form( - step_id="claimspicker", - data_schema=vol.Schema(self.claimspicker_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "message": " \n> {} \n> {}".format( - claimspicker_message, error_message - ), - }, - ) - if login.status and login.status.get("authselect_required"): - _LOGGER.debug("Creating config_flow to select OTA method") - error_message = login.status.get("error_message", "") - authselect_message = login.status["authselect_message"] - self.automatic_steps = 0 - return self.async_show_form( - step_id="authselect", - data_schema=vol.Schema(self.authselect_schema), - description_placeholders={ - "email": login.email, - "url": login.url, - "message": " \n> {} \n> {}".format( - authselect_message, error_message - ), - }, - ) - if login.status and login.status.get("verificationcode_required"): - _LOGGER.debug("Creating config_flow to enter verification code") - self.automatic_steps = 0 - return self.async_show_form( - step_id="verificationcode", - data_schema=vol.Schema(self.verificationcode_schema), - ) - if ( - login.status - and login.status.get("force_get") - and not login.status.get("ap_error_href") - ): - _LOGGER.debug("Creating config_flow to wait for user action") - self.automatic_steps = 0 - return self.async_show_form( - step_id="action_required", - data_schema=vol.Schema( - OrderedDict([(vol.Optional(CONF_PROXY, default=False), bool)]) - ), - description_placeholders={ - "email": login.email, - "url": login.url, - "message": f" \n>{login.status.get('message','')} \n", - }, - ) if login.status and (login.status.get("login_failed")): - if login.oauth_login: - _LOGGER.debug("Trying non-oauth login") - await login.reset() - login.oauth_login = False - await login.login() - return await self._test_login() _LOGGER.debug("Login failed: %s", login.status.get("login_failed")) await login.close() self.hass.components.persistent_notification.async_dismiss( @@ -857,17 +618,9 @@ async def _test_login(self): _LOGGER.debug( "Done with automatic resubmission for error_message 'valid email'; returning error message", ) - self.automatic_steps = 0 - return self.async_show_form( - step_id="user_legacy", - data_schema=vol.Schema(new_schema), - description_placeholders={ - "message": f" \n> {login.status.get('error_message','')}" - }, - ) self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(new_schema), description_placeholders={ "message": f" \n> {login.status.get('error_message','')}" @@ -883,10 +636,6 @@ def _save_user_input_to_config(self, user_input=None) -> None: """ if user_input is None: return - if CONF_PROXY in user_input: - self.config[CONF_PROXY] = user_input[CONF_PROXY] - if CONF_OAUTH_LOGIN in user_input: - self.config[CONF_OAUTH_LOGIN] = user_input[CONF_OAUTH_LOGIN] if CONF_HASS_URL in user_input: self.config[CONF_HASS_URL] = user_input[CONF_HASS_URL] self.securitycode = user_input.get(CONF_SECURITYCODE) @@ -933,27 +682,6 @@ def _save_user_input_to_config(self, user_input=None) -> None: ) else: self.config[CONF_EXCLUDE_DEVICES] = user_input[CONF_EXCLUDE_DEVICES] - if ( - user_input.get(CONF_COOKIES_TXT) - and f"{HTTP_COOKIE_HEADER}\n" != user_input[CONF_COOKIES_TXT] - ): - fixed_cookies_txt = re.sub( - r" ", - r"\n", - re.sub( - r"#.*\n", - r"", - re.sub( - r"# ((?:.(?!# ))+)$", - r"\1", - re.sub(r" #", r"\n#", user_input[CONF_COOKIES_TXT]), - ), - ), - ) - if not fixed_cookies_txt.startswith(HTTP_COOKIE_HEADER): - fixed_cookies_txt = f"{HTTP_COOKIE_HEADER}\n{fixed_cookies_txt}" - self.config[CONF_COOKIES_TXT] = fixed_cookies_txt - _LOGGER.debug("Setting cookies to:\n%s", fixed_cookies_txt) def _update_schema_defaults(self) -> Any: new_schema = self._update_ord_dict( @@ -988,12 +716,6 @@ def _update_schema_defaults(self) -> Any: vol.Optional( CONF_SCAN_INTERVAL, default=self.config.get(CONF_SCAN_INTERVAL, 60) ): int, - vol.Optional( - CONF_COOKIES_TXT, default=self.config.get(CONF_COOKIES_TXT, "") - ): str, - vol.Optional( - CONF_OAUTH_LOGIN, default=self.config.get(CONF_OAUTH_LOGIN, True) - ): bool, }, ) return new_schema @@ -1063,12 +785,12 @@ async def get(self, request: web.Request): class AlexaMediaAuthorizationProxyView(HomeAssistantView): """Handle proxy connections.""" - url: Text = AUTH_PROXY_PATH - extra_urls: List[Text] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] - name: Text = AUTH_PROXY_NAME + url: str = AUTH_PROXY_PATH + 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[Text, datetime.datetime] = {} + known_ips: Dict[str, datetime.datetime] = {} auth_seconds: int = 300 def __init__(self, handler: web.RequestHandler): @@ -1110,7 +832,14 @@ async def wrapped(request, **kwargs): if not success: raise Unauthorized() cls.known_ips[request.remote] = datetime.datetime.now() - return await cls.handler(request, **kwargs) + try: + return await cls.handler(request, **kwargs) + except httpx.ConnectError as ex: # pylyint: disable=broad-except + _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}
", + ) return wrapped diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index bcc332e50..f557082c7 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -8,7 +8,7 @@ """ from datetime import timedelta -__version__ = "3.11.3" +__version__ = "4.0.3" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = f"{PROJECT_URL}issues" @@ -33,7 +33,6 @@ HTTP_COOKIE_HEADER = "# HTTP Cookie File" CONF_ACCOUNTS = "accounts" -CONF_COOKIES_TXT = "cookies_txt" CONF_DEBUG = "debug" CONF_HASS_URL = "hass_url" CONF_INCLUDE_DEVICES = "include_devices" @@ -45,7 +44,6 @@ CONF_PROXY = "proxy" CONF_TOTP_REGISTER = "registered" CONF_OAUTH = "oauth" -CONF_OAUTH_LOGIN = "oauth_login" DATA_LISTENER = "listener" EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}" @@ -59,6 +57,7 @@ RECURRING_PATTERN = { None: "Never Repeat", "P1D": "Every day", + "P1M": "Every month", "XXXX-WE": "Weekends", "XXXX-WD": "Weekdays", "XXXX-WXX-1": "Every Monday", @@ -70,6 +69,15 @@ "XXXX-WXX-7": "Every Sunday", } +RECURRING_DAY = { + "MO": 1, + "TU": 2, + "WE": 3, + "TH": 4, + "FR": 5, + "SA": 6, + "SU": 7, +} RECURRING_PATTERN_ISO_SET = { None: {}, "P1D": {1, 2, 3, 4, 5, 6, 7}, diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index bef65b38a..42769b5c2 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -1,12 +1,12 @@ { "domain": "alexa_media", "name": "Alexa Media Player", - "version": "3.11.3", + "version": "4.0.3", "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.25.5", "packaging>=20.3", "wrapt>=1.12.1"], + "requirements": ["alexapy==1.26.1", "packaging>=20.3", "wrapt>=1.12.1"], "iot_class": "cloud_polling" } diff --git a/custom_components/alexa_media/notify.py b/custom_components/alexa_media/notify.py index 51b02a347..c8e76ef5f 100644 --- a/custom_components/alexa_media/notify.py +++ b/custom_components/alexa_media/notify.py @@ -146,22 +146,44 @@ def targets(self): for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items(): if "entities" not in account_dict: return devices + last_called_entity = None for _, entity in account_dict["entities"]["media_player"].items(): entity_name = (entity.entity_id).split(".")[1] devices[entity_name] = entity.unique_id if self.last_called and entity.extra_state_attributes.get( "last_called" ): - entity_name_last_called = ( - f"last_called{'_'+ email if entity_name[-1:].isdigit() else ''}" - ) - _LOGGER.debug( - "%s: Creating last_called target %s using %s", - hide_email(email), - entity_name_last_called, - entity, - ) - devices[entity_name_last_called] = entity.unique_id + if last_called_entity is None: + _LOGGER.debug( + "%s: Found last_called %s called at %s", + hide_email(email), + entity, + entity.extra_state_attributes.get("last_called_timestamp"), + ) + last_called_entity = entity + elif (last_called_entity.extra_state_attributes.get("last_called_timestamp") + < entity.extra_state_attributes.get("last_called_timestamp") + ): + _LOGGER.debug( + "%s: Found newer last_called %s called at %s", + hide_email(email), + entity, + entity.extra_state_attributes.get("last_called_timestamp"), + ) + last_called_entity = entity + if last_called_entity is not None: + entity_name = (last_called_entity.entity_id).split(".")[1] + entity_name_last_called = ( + f"last_called{'_'+ email if entity_name[-1:].isdigit() else ''}" + ) + _LOGGER.debug( + "%s: Creating last_called target %s using %s called at %s", + hide_email(email), + entity_name_last_called, + last_called_entity, + last_called_entity.extra_state_attributes.get("last_called_timestamp"), + ) + devices[entity_name_last_called] = last_called_entity.unique_id return devices @property diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index a5bfd2818..5d05ccc3b 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -10,6 +10,7 @@ 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, @@ -18,7 +19,6 @@ ) from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt @@ -37,6 +37,7 @@ from .alexa_entity import parse_temperature_from_coordinator from .const import ( CONF_EXTENDED_ENTITY_DISCOVERY, + RECURRING_DAY, RECURRING_PATTERN, RECURRING_PATTERN_ISO_SET, ) @@ -181,7 +182,7 @@ def lookup_device_info(account_dict, device_serial): return None -class TemperatureSensor(CoordinatorEntity): +class TemperatureSensor(SensorEntity, CoordinatorEntity): """A temperature sensor reported by an Echo.""" def __init__(self, coordinator, entity_id, name, media_player_device_id): @@ -221,14 +222,14 @@ def unique_id(self): return self.alexa_entity_id + "_temperature" -class AlexaMediaNotificationSensor(Entity): +class AlexaMediaNotificationSensor(SensorEntity): """Representation of Alexa Media sensors.""" def __init__( self, client, n_dict, - sensor_property: Text, + sensor_property: str, account, name="Next Notification", icon=None, @@ -252,9 +253,9 @@ def __init__( self._tracker: Optional[Callable] = None self._state: Optional[datetime.datetime] = None self._dismissed: Optional[datetime.datetime] = None - self._status: Optional[Text] = None - self._amz_id: Optional[Text] = None - self._version: Optional[Text] = None + self._status: Optional[str] = None + self._amz_id: Optional[str] = None + self._version: Optional[str] = None def _process_raw_notifications(self): self._all = ( @@ -356,35 +357,47 @@ def _fix_alarm_date_time(self, value): def _update_recurring_alarm(self, value): _LOGGER.debug("Sensor value %s", value) - alarm = value[1][self._sensor_property] + next_item = value[1] + alarm = next_item[self._sensor_property] reminder = None - if isinstance(value[1][self._sensor_property], (int, float)): + recurrence = [] + if isinstance(next_item[self._sensor_property], (int, float)): reminder = True alarm = dt.as_local( self._round_time( datetime.datetime.fromtimestamp(alarm / 1000, tz=LOCAL_TIMEZONE) ) ) - alarm_on = value[1]["status"] == "ON" - recurring_pattern = value[1].get("recurringPattern") + 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 + next_trigger_times = r_rule_data.get("nextTriggerTimes") + weekdays = r_rule_data.get("byWeekDays") + if next_trigger_times: + alarm = next_trigger_times[0] + elif weekdays: + for day in weekdays: + recurrence.append(RECURRING_DAY[day]) + else: + recurring_pattern = next_item.get("recurringPattern") + recurrence = RECURRING_PATTERN_ISO_SET.get(recurring_pattern) while ( alarm_on - and recurring_pattern - and RECURRING_PATTERN_ISO_SET.get(recurring_pattern) - and alarm.isoweekday not in RECURRING_PATTERN_ISO_SET[recurring_pattern] + and recurrence + and alarm.isoweekday not in recurrence and alarm < dt.now() ): alarm += datetime.timedelta(days=1) if reminder: alarm = dt.as_timestamp(alarm) * 1000 - if alarm != value[1][self._sensor_property]: + if alarm != next_item[self._sensor_property]: _LOGGER.debug( "%s with recurrence %s set to %s", - value[1]["type"], - RECURRING_PATTERN[recurring_pattern], + next_item["type"], + recurrence, alarm, ) - value[1][self._sensor_property] = alarm + next_item[self._sensor_property] = alarm return value @staticmethod @@ -500,7 +513,9 @@ async def async_update(self): account_dict = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._account] self._timestamp = account_dict["notifications"]["process_timestamp"] try: - self._n_dict = account_dict["notifications"][self._client.device_serial_number][self._type] + self._n_dict = account_dict["notifications"][ + self._client.device_serial_number + ][self._type] except KeyError: self._n_dict = None self._process_raw_notifications() @@ -526,7 +541,7 @@ def icon(self): def recurrence(self): """Return the recurrence pattern of the sensor.""" return ( - RECURRING_PATTERN[self._next.get("recurringPattern")] + RECURRING_PATTERN.get(self._next.get("recurringPattern")) if self._next else None ) diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index f4e5f07b0..5a2e95a3f 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -315,7 +315,7 @@ def icon(self): @property def entity_category(self): """Return the entity category of the switch.""" - return EntityCategory.CONFIG + return EntityCategory.CONFIG def _handle_event(self, event): """Handle events.""" @@ -356,7 +356,8 @@ def icon(self): @property def entity_category(self): """Return the entity category of the switch.""" - return EntityCategory.CONFIG + return EntityCategory.CONFIG + class RepeatSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Repeat switch.""" @@ -374,4 +375,4 @@ def icon(self): @property def entity_category(self): """Return the entity category of the switch.""" - return EntityCategory.CONFIG \ No newline at end of file + return EntityCategory.CONFIG diff --git a/custom_components/alexa_media/translations/ar.json b/custom_components/alexa_media/translations/ar.json new file mode 100644 index 000000000..216f8f322 --- /dev/null +++ b/custom_components/alexa_media/translations/ar.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "forgot_password": "The Forgot Password page was detected. This normally is the result of too may failed logins. Amazon may require action before a relogin can be attempted.", + "login_failed": "Alexa Media Player failed to login.", + "reauth_successful": "Alexa Media Player successfully reauthenticated." + }, + "error": { + "2fa_key_invalid": "Invalid Built-In 2FA key", + "connection_error": "Error connecting; check network and retry", + "hass_url_invalid": "Unable to connect to Home Assistant url. Please check the External Url under Configuration -> General", + "identifier_exists": "Email for Alexa URL already registered", + "invalid_credentials": "Invalid credentials", + "unknown_error": "Unknown error, please enable advanced debugging and report log info" + }, + "step": { + "captcha": { + "data": { + "securitycode": "2FA Code (recommended to avoid login issues)" + } + }, + "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" + }, + "user": { + "data": { + "debug": "Advanced debugging", + "email": "Email Address", + "exclude_devices": "Excluded device (comma separated)", + "hass_url": "Url to access Home Assistant", + "include_devices": "Included device (comma separated)", + "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", + "password": "Password", + "scan_interval": "Seconds between scans", + "securitycode": "2FA Code (recommended to avoid login issues)", + "url": "Amazon region domain (e.g., amazon.co.uk)" + }, + "description": "Please confirm the information below.", + "title": "Alexa Media Player - Configuration" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "extended_entity_discovery": "Include devices connected via Echo", + "queue_delay": "Seconds to wait to queue commands together" + } + } + } + } +} diff --git a/custom_components/alexa_media/translations/de.json b/custom_components/alexa_media/translations/de.json index 15a607363..39e2fba12 100644 --- a/custom_components/alexa_media/translations/de.json +++ b/custom_components/alexa_media/translations/de.json @@ -14,38 +14,10 @@ "unknown_error": "Unbekannter Fehler, bitte Log-Info melden" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon sendet eine Push-Benachrichtigung je folgender Nachricht. Bitte antworte sie vollständig, bevor Du fortfährst. \n{message}", - "title": "Alexa Media Player - Aktion erforderlich" - }, - "authselect": { - "data": { - "authselectoption": "Einmal Pin Methode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Einmal Pin Passwort" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Passwort", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "2FA Code (empfohlen, um Anmeldeprobleme zu vermeiden)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Verifizierungs Methode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nBitte Verifizierungs Methode auswählen. (z.B., `0` oder `1`) \n{message}", - "title": "Alexa Media Player - Verifizierungs Methode" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Code" - }, - "description": "**{email} - alexa.{url}** \nDen Zwei Faktor Pin eingeben. \n{message}", - "title": "Alexa Media Player - Zwei Faktor Authentifizierung" - }, "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Erweitertes debugging", "email": "Email Adresse", "exclude_devices": "Ausgeschlossene Geräte (komma getrennnt)", "hass_url": "Url to access Home Assistant", "include_devices": "Eingebundene Geräte (komma getrennnt)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Passwort", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Sekunden zwischen den Scans", "securitycode": "2FA Code (empfohlen, um Anmeldeprobleme zu vermeiden)", "url": "Amazon Region (z.B., amazon.de)" }, "description": "Bitte geben Sie ihre Informationen ein.", "title": "Alexa Media Player - Konfiguration" - }, - "user_legacy": { - "data": { - "cookies_txt": "config::step::user::data::cookies_txt", - "debug": "Erweitertes debugging", - "email": "Email Adresse", - "exclude_devices": "Ausgeschlossene Geräte (komma getrennnt)", - "include_devices": "Eingebundene Geräte (komma getrennnt)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Passwort", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Sekunden zwischen den Scans", - "securitycode": "2FA Code (empfohlen, um Anmeldeprobleme zu vermeiden)", - "url": "Amazon Region (z.B., amazon.de)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/en.json b/custom_components/alexa_media/translations/en.json index f52044a02..216f8f322 100644 --- a/custom_components/alexa_media/translations/en.json +++ b/custom_components/alexa_media/translations/en.json @@ -14,38 +14,10 @@ "unknown_error": "Unknown error, please enable advanced debugging and report log info" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon will send a push notification per the below message. Please completely respond before continuing. \n{message}", - "title": "Alexa Media Player - Action Required" - }, - "authselect": { - "data": { - "authselectoption": "OTP method", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - One Time Password" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "2FA Code (recommended to avoid login issues)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Verification method", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nPlease select verification method by number. (e.g., `0` or `1`) \n{message}", - "title": "Alexa Media Player - Verification Method" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Code" - }, - "description": "**{email} - alexa.{url}** \nEnter the One Time Password (OTP). \n{message}", - "title": "Alexa Media Player - Two Factor Authentication" - }, "user": { "data": { - "cookies_txt": "Cookies.txt data", "debug": "Advanced debugging", "email": "Email Address", "exclude_devices": "Excluded device (comma separated)", "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Seconds between scans", "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "Please confirm the information below. For legacy configuration, disable `Use Login Proxy method` option.", + "description": "Please confirm the information below.", "title": "Alexa Media Player - Configuration" - }, - "user_legacy": { - "data": { - "cookies_txt": "Cookies.txt data", - "debug": "Advanced debugging", - "email": "Email Address", - "exclude_devices": "Excluded device (comma separated)", - "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Seconds between scans", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Amazon region domain (e.g., amazon.co.uk)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/es.json b/custom_components/alexa_media/translations/es.json index d1ddc7a7c..a0e5b8024 100644 --- a/custom_components/alexa_media/translations/es.json +++ b/custom_components/alexa_media/translations/es.json @@ -11,41 +11,13 @@ "hass_url_invalid": "No se puede conectar a la url de Home Assistant. Compruebe la dirección URL externa en Configuración -> General", "identifier_exists": "Correo electrónico para la URL de Alexa ya registrado", "invalid_credentials": "Credenciales no válidas", - "unknown_error": "Error desconocido, por favor revisa los registros en Home Assistant y reporta el error si es necesario." + "unknown_error": "Error desconocido, habilite la depuración avanzada e informe la información de registro" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon enviará una notificación a tu dispositivo vinculado. Completa los pasos descritos antes de continuar.\n{message}", - "title": "Alexa Media Player - Acción requerida" - }, - "authselect": { - "data": { - "authselectoption": "Clave OTP", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Contraseña de un solo uso" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Contraseña", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "Código 2FA (recomendado para evitar problemas de inicio de sesión)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Método de verificación", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nSeleccione el método de verificación por número. (e.g., `0` or `1`) \n{message}", - "title": "Alexa Media Player - Método de verificación" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Code" - }, - "description": "**{email} - alexa.{url}** \nIngrese la contraseña de un solo uso (OTP). \n{message}", - "title": "Alexa Media Player - Autenticación de dos factores" - }, "user": { "data": { - "cookies_txt": "Datos de Cookies.txt", "debug": "Depuración avanzada", "email": "Dirección de correo electrónico", "exclude_devices": "Dispositivo excluido (separado por comas)", "hass_url": "Url to access Home Assistant", "include_devices": "Dispositivo incluido (separado por comas)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Contraseña", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Segundos entre escaneos", "securitycode": "Código 2FA (recomendado para evitar problemas de inicio de sesión)", "url": "Región del dominio de Amazon (por ejemplo, amazon.es)" }, - "description": "Confirme la siguiente información. Para la configuración heredada, desactive la opción `Usar método de proxy de inicio de sesión`.", + "description": "Confirme la siguiente información.", "title": "Alexa Media Player - Configuración" - }, - "user_legacy": { - "data": { - "cookies_txt": "Datos de Cookies.txt", - "debug": "Depuración avanzada", - "email": "Dirección de correo electrónico", - "exclude_devices": "Dispositivo excluido (separado por comas)", - "include_devices": "Dispositivo incluido (separado por comas)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Contraseña", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Segundos entre escaneos", - "securitycode": "Código 2FA (recomendado para evitar problemas de inicio de sesión)", - "url": "Región del dominio de Amazon (por ejemplo, amazon.es)" - }, - "description": "Por favor introduce tu [información](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **El método más rápido es [Importar cookies](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import).** \n**ADVERTENCIA: Amazon informará 'Introduce un correo electrónico o número de teléfono válido' si tu cuenta utiliza [códigos 2FA - Segundo Factor de Autenticación](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/fr.json b/custom_components/alexa_media/translations/fr.json index 6d73c4e90..f466b3356 100644 --- a/custom_components/alexa_media/translations/fr.json +++ b/custom_components/alexa_media/translations/fr.json @@ -14,38 +14,10 @@ "unknown_error": "Erreur inconnue, veuillez signaler les informations du journal" }, "step": { - "action_required": { - "data": { - "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)" - }, - "description": "** {email} - alexa. {url} ** \n Amazon enverra une notification push conformément au message ci-dessous. Veuillez répondre complètement avant de continuer. \n {message}", - "title": "Alexa Media Player - Action requise" - }, - "authselect": { - "data": { - "authselectoption": "OTP méthode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Mot de passe unique" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Mot de passe", - "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)", "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Méthode de vérification", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nVeuillez sélectionner la méthode de vérification par numéro. (exemple., `0` ou `1`) \n{message}", - "title": "Alexa Media Player - Méthode de vérification" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "description": "** {email} - alexa. {url} **\n Avez-vous confirmé avec succès un OTP à partir de la clé d'application 2FA intégrée avec Amazon?\n > Code OTP {message}", "title": "Alexa Media Player - OTP Confirmation" }, - "twofactor": { - "data": { - "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)", - "securitycode": "2FA Code" - }, - "description": "**{email} - alexa.{url}** \nEntrez le mot de passe unique (OTP). \n{message}", - "title": "Alexa Media Player - Authentification à deux facteurs" - }, "user": { "data": { - "cookies_txt": "Données cookies.txt", "debug": "Débogage avancé", "email": "Adresse Email", "exclude_devices": "Appareil exclu (séparé par des virgules)", "hass_url": "Url to access Home Assistant", "include_devices": "Appareil inclus (séparé par des virgules)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Clé d'application 2FA intégrée (génère automatiquement des codes 2FA)", "password": "Mot de passe", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Secondes entre les analyses", "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)", "url": "Domaine de la région Amazon (exemple, amazon.fr)" }, - "description": "Veuillez confirmer les informations ci-dessous. Pour la configuration héritée, désactivez l'option `Utiliser la méthode proxy de connexion`.", + "description": "Veuillez confirmer les informations ci-dessous.", "title": "Alexa Media Player - Configuration" - }, - "user_legacy": { - "data": { - "cookies_txt": "Données cookies.txt", - "debug": "Débogage avancé", - "email": "Adresse Email", - "exclude_devices": "Appareil exclu (séparé par des virgules)", - "include_devices": "Appareil inclus (séparé par des virgules)", - "oauth_login": "Activer la méthode d'application oauth-token", - "otp_secret": "Clé d'application 2FA intégrée (génère automatiquement des codes 2FA)", - "password": "Mot de passe", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Secondes entre les analyses", - "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)", - "url": "Domaine de la région Amazon (exemple, amazon.fr)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/it.json b/custom_components/alexa_media/translations/it.json index 8d0c8998a..318c7b5ad 100644 --- a/custom_components/alexa_media/translations/it.json +++ b/custom_components/alexa_media/translations/it.json @@ -1,103 +1,46 @@ { "config": { "abort": { - "forgot_password": "The Forgot Password page was detected. This normally is the result of too may failed logins. Amazon may require action before a relogin can be attempted.", - "login_failed": "Alexa Media Player failed to login.", - "reauth_successful": "Alexa Media Player successfully reauthenticated." + "forgot_password": "È stata rilevata la pagina di password dimenticata. Normalmente questo è il risultato di troppi accessi falliti. Amazon potrebbe richiedere di eseguire alcune azioni prima di poter tentare un nuovo accesso.", + "login_failed": "Alexa Media Player ha fallito il login.", + "reauth_successful": "Alexa Media Player è stato riautenticato con successo." }, "error": { - "2fa_key_invalid": "Invalid Built-In 2FA key", + "2fa_key_invalid": "Chiave 2FA incorporata non valida", "connection_error": "Errore durante la connessione; controlla la rete e riprova", - "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL esterno nel menu Configurazione -> Generale", - "identifier_exists": "L'Email per l'URL di Alexa è già stata registrata", + "hass_url_invalid": "Impossibile collegarsi all'URL di Home Assistant. Controllare l'URL esterno nel menu Configurazione -> Generale", + "identifier_exists": "L'email per l'URL di Alexa è già stata registrata", "invalid_credentials": "Credenziali non valide", "unknown_error": "Errore sconosciuto, si prega di abilitare il debug avanzato e riportare i log informativi" }, "step": { - "action_required": { - "data": { - "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" - }, - "description": "** {email} - alexa. {url} ** \n Amazon invierà una notifica push per il seguente messaggio. Si prega di rispondere completamente prima di continuare. \n {message}", - "title": "Alexa Media Player - Azione Richiesta" - }, - "authselect": { - "data": { - "authselectoption": "Metodo password usa e getta (OTP)", - "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Password Usa e Getta (One Time Password)" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Password", - "proxy": "Usa metodo Proxy per il login (2FA non richiesto)", - "securitycode": "config::step::captcha::data::securitycode" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Metodi di Verifica", - "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" - }, - "description": "**{email} - alexa.{url}** \nPrego selezionare un metodo di verifica. (e.g., `0` or `1`) \n{message}", - "title": "Alexa Media Player - Metodi di Verifica" + "securitycode": "Codice 2FA (raccomandato per evitare problemi di login)" + } }, "totp_register": { "data": { - "registered": "OTP from the Built-in 2FA App Key confirmed successfully." + "registered": "Password usa e getta (OTP) dalla chiave dell'applicazione 2FA integrata confermata con successo." }, - "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" - }, - "twofactor": { - "data": { - "proxy": "Usa metodo Proxy per il login (2FA non richiesto)", - "securitycode": "Codice autenticazione a 2 fattori (2FA)" - }, - "description": "**{email} - alexa.{url}** \nInserisci la password usa e getta (OTP). \n{message}", - "title": "Alexa Media Player - Autenticazione a Due Fattori" + "description": "**{email} - alexa.{url}**\nHai confermato con successo una chiave usa e getta (OTP) dall'applicazione 2FA integrata con Amazon?\n>Codice OTP {message}", + "title": "Alexa Media Player - Conferma OTP" }, "user": { "data": { - "cookies_txt": "Dati cookies.txt", "debug": "Debug avanzato", "email": "Indirizzo email", "exclude_devices": "Dispositivi da escludere (separati da virgola)", - "hass_url": "Url to access Home Assistant", + "hass_url": "URL per accedere a Home Assistant", "include_devices": "Dispositivi da includere (separati da virgola)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", + "otp_secret": "Chiave dell'app 2FA integrata (generazione automatica di codici 2FA)", "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Tempo in secondi fra le scansioni", "securitycode": "Codice 2FA (raccomandato per evitare problemi di login)", - "url": "Regione del dominio Amazon (e.g., amazon.it)" + "url": "Regione del dominio Amazon (ad es., amazon.it)" }, - "description": "Confermare le informazioni sottostanti. Per le vecchie configurazioni, disabilitare l'opzione `Usa Proxy come metodo di login`.", + "description": "Confermare le informazioni sottostanti.", "title": "Alexa Media Player - Configurazione" - }, - "user_legacy": { - "data": { - "cookies_txt": "Dati cookies.txt", - "debug": "Debug avanzato", - "email": "Indirizzo email", - "exclude_devices": "Dispositivi da escludere (separati da virgola)", - "include_devices": "Dispositivi da includere (separati da virgola)", - "oauth_login": "Abilitare il metodo oauth-token app", - "otp_secret": "Chiave 2FA interna (genera automaticamente i codici 2FA)", - "password": "Password", - "proxy": "Usa il Proxy come metodo di login (2FA non richiesta)", - "scan_interval": "Tempo in secondi fra le scansioni", - "securitycode": "Codice 2FA (raccomandato per evitare problemi di login)", - "url": "Regione del dominio Amazon (e.g. amazon.it)" - }, - "description": "Prego inserisci le tue [informazioni](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) potrebbe essere più semplice!** \n**ATTENZIONE: Amazon risponde incorrettamente 'Inserire una mail valida o un numero di telefono' quando è richiesto il codice 2FA](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/nb.json b/custom_components/alexa_media/translations/nb.json index 333797c9b..87f5b062f 100644 --- a/custom_components/alexa_media/translations/nb.json +++ b/custom_components/alexa_media/translations/nb.json @@ -14,38 +14,10 @@ "unknown_error": "Ukjent feil, vennligst rapporter logginfo" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon vil sende et push-varsel i henhold til meldingen nedenfor. Fullfør svaret før du fortsetter. \n{message}", - "title": "Alexa Media Player - Handling påkrevd" - }, - "authselect": { - "data": { - "authselectoption": "OTP-metode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Engangspassord" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Passord", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "2FA-kode (anbefales for å unngå påloggingsproblemer)" - }, - "description": "**{email} - alexa.{url}** \n>{message} \n{captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Bekreftelsesmetode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nVelg bekreftelsesmetode etter nummer. (f.eks. \"0\" eller \"1\") \n{message}", - "title": "Alexa Media Player - Bekreftelsesmetode" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Kode" - }, - "description": "**{email} - alexa.{url}** \nSkriv inn engangspassordet (OTP). \n>{message}", - "title": "Alexa Media Player - Tofaktorautentisering" - }, "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Avansert feilsøking", "email": "Epostadresse", "exclude_devices": "Ekskludert enhet (kommaseparert)", "hass_url": "Url to access Home Assistant", "include_devices": "Inkluder enhet (kommaseparert)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Passord", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Sekunder mellom skanninger", "securitycode": "2FA-kode (anbefales for å unngå påloggingsproblemer)", "url": "Amazon-regiondomenet (f.eks. Amazon.co.uk)" }, - "description": "Bekreft informasjonen nedenfor. For eldre konfigurasjon, deaktiver alternativet \"Bruk innlogging proxy-metode\".", + "description": "Bekreft informasjonen nedenfor.", "title": "Alexa Media Player - Konfigurasjon" - }, - "user_legacy": { - "data": { - "cookies_txt": "config::step::user::data::cookies_txt", - "debug": "Avansert feilsøking", - "email": "Epostadresse", - "exclude_devices": "Ekskludert enhet (kommaseparert)", - "include_devices": "Inkluder enhet (kommaseparert)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Passord", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Sekunder mellom skanninger", - "securitycode": "2FA-kode (anbefales for å unngå påloggingsproblemer)", - "url": "Amazon-regiondomenet (f.eks. Amazon.co.uk)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/nl.json b/custom_components/alexa_media/translations/nl.json index 9a4afdbec..77558398e 100644 --- a/custom_components/alexa_media/translations/nl.json +++ b/custom_components/alexa_media/translations/nl.json @@ -14,38 +14,10 @@ "unknown_error": "Onbekende fout, meld de loggegevens" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon will send a push notification per the below message. Please completely respond before continuing. \n{message}", - "title": "Alexa Media Player - Action Required" - }, - "authselect": { - "data": { - "authselectoption": "Eenmalige Pincode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Eenmalige Pincode" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Paswoord", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "config::step::captcha::data::securitycode" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Verificatiemethode", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nSelecteer de verificatiemethode (bv.`0` of `1`) \n{message}", - "title": "Alexa Media Player - Verificatiemethode" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "Verificatiecode" - }, - "description": "**{email} - alexa.{url}** \nGeef de verificatiecode in. \n{message}", - "title": "Alexa Media Player - Tweestapsverificatie" - }, "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Geavanceerd debuggen", "email": "E-mailadres", "exclude_devices": "Apparaten uitsluiten (Scheiding: komma)", "hass_url": "Url to access Home Assistant", "include_devices": "Apparaten toevoegen (Scheiding: komma)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Paswoord", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Aantal seconden tussen scans", "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Domeinnaam van Amazon regio (bv.amazon.co.uk)" }, "description": "Vul je gegevens in a.u.b.", "title": "Alexa Media Player - Configuratie" - }, - "user_legacy": { - "data": { - "cookies_txt": "config::step::user::data::cookies_txt", - "debug": "Geavanceerd debuggen", - "email": "E-mailadres", - "exclude_devices": "Apparaten uitsluiten (Scheiding: komma)", - "include_devices": "Apparaten toevoegen (Scheiding: komma)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Paswoord", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Aantal seconden tussen scans", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Domeinnaam van Amazon regio (bv.amazon.co.uk)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/pl.json b/custom_components/alexa_media/translations/pl.json index e01214122..fcb2c462e 100644 --- a/custom_components/alexa_media/translations/pl.json +++ b/custom_components/alexa_media/translations/pl.json @@ -14,38 +14,10 @@ "unknown_error": "Nieznany błąd, włącz zaawansowane debugowanie i zgłoś log z tego zdarzenia" }, "step": { - "action_required": { - "data": { - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" - }, - "description": "**{email} - alexa.{url}** \nAmazon wyśle powiadomienie push zgodnie z poniższą wiadomością. Przed kontynuowaniem odpowiedz na wiadomość. \n{message}", - "title": "Alexa Media Player — wymagane działanie" - }, - "authselect": { - "data": { - "authselectoption": "Metoda OTP", - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player — hasło jednorazowe" - }, "captcha": { "data": { - "captcha": "Kod Captcha", - "password": "Hasło", - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "securitycode": "Kod uwierzytelniania dwuskładnikowego" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player — kod Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Metoda weryfikacji", - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" - }, - "description": "**{email} - alexa.{url}** \nWybierz metodę weryfikacji. (np., `0` lub `1`) \n{message}", - "title": "Alexa Media Player — metoda weryfikacji" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "description": "**{email} - alexa.{url}** \nCzy pomyślnie potwierdziłeś hasło jednorazowe z wbudowanej aplikacji uwierzytelniania dwuskładnikowego z Amazonu? \n >Kod hasła jednorazowego {message}", "title": "Alexa Media Player - Potwierdzanie hasła jednorazowego" }, - "twofactor": { - "data": { - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", - "securitycode": "Kod uwierzytelniania dwuskładnikowego" - }, - "description": "**{email} - alexa.{url}** \nWprowadź hasło jednorazowe (OTP). \n{message}", - "title": "Alexa Media Player — uwierzytelnianie dwuskładnikowe" - }, "user": { "data": { - "cookies_txt": "zawartość pliku cookies.txt", "debug": "Zaawansowane debugowanie", "email": "Adres email", "exclude_devices": "Wykluczone urządzenia (oddzielone przecinkami)", "hass_url": "URL dostępu do Home Assistanta", "include_devices": "Dodawane urządzenia (oddzielone przecinkami)", - "oauth_login": "Włącz metodę tokena OAuth aplikacji", "otp_secret": "Wbudowana aplikacja kluczy uwierzytelniania dwuskładnikowego (automatycznie generuje kody uwierzytelniania dwuskładnikowego)", "password": "Hasło", - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "scan_interval": "Interwał skanowania (sekundy)", "securitycode": "Kod uwierzytelniania dwuskładnikowego (zalecany w celu uniknięcia problemów z logowaniem)", "url": "Region/domena Amazon (np. amazon.co.uk)" }, - "description": "Potwierdź poniższe informacje. W przypadku starszych konfiguracji wyłącz opcję 'Użyj metody logowania proxy'.", + "description": "Potwierdź poniższe informacje.", "title": "Alexa Media Player — konfiguracja" - }, - "user_legacy": { - "data": { - "cookies_txt": "zawartość pliku cookies.txt", - "debug": "Zaawansowane debugowanie", - "email": "Adres e-mail", - "exclude_devices": "Wykluczone urządzenia (oddzielone przecinkami)", - "include_devices": "Dodawane urządzenia (oddzielone przecinkami)", - "oauth_login": "Włącz metodę tokena OAuth aplikacji", - "otp_secret": "Wbudowana aplikacja kluczy uwierzytelniania dwuskładnikowego (automatycznie generuje kody uwierzytelniania dwuskładnikowego)", - "password": "Hasło", - "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", - "scan_interval": "Interwał skanowania (sekundy)", - "securitycode": "Kod uwierzytelniania dwuskładnikowego", - "url": "Region/domena Amazon (np. amazon.co.uk)" - }, - "description": "Wprowadź [dane](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Import pliku Cookie](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) może być najłatwiejszą metodą!** \n**OSTRZEŻENIE: Amazon nieprawidłowo zgłasza 'Wprowadź prawidłowy adres e-mail lub numer telefonu komórkowego', gdy wymagany jest [kod uwierzytelniania dwuskładnikowego](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player — starsza konfiguracja" } } }, diff --git a/custom_components/alexa_media/translations/pt-BR.json b/custom_components/alexa_media/translations/pt-BR.json index 1f3897e29..6d4b48047 100644 --- a/custom_components/alexa_media/translations/pt-BR.json +++ b/custom_components/alexa_media/translations/pt-BR.json @@ -14,38 +14,10 @@ "unknown_error": "Erro desconhecido, favor habilitar depuração avançada e informações de registro de relatório" }, "step": { - "action_required": { - "data": { - "proxy": "Usar método Login Proxy (Não requer 2FA)" - }, - "description": "** {email} - alexa. {url} **\n A Amazon enviará uma notificação por push de acordo com a mensagem abaixo. Por favor, responda completamente antes de continuar.\n {message}", - "title": "Alexa Media Player - Ação necessária" - }, - "authselect": { - "data": { - "authselectoption": "Método OTP", - "proxy": "Usar método Login Proxy (Não requer 2FA)" - }, - "description": "**{email} - alexa.{url}**\n{message}", - "title": "Alexa Media Player - Senha de uso único" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Senha", - "proxy": "Usar método Login Proxy (Não requer 2FA)", "securitycode": "Código 2FA (recomendado para evitar problemas de login)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Método de verificação", - "proxy": "Usar método Login Proxy (Não requer 2FA)" - }, - "description": "** {email} - alexa. {url} **\n Selecione o método de verificação por número. (por exemplo, `0` ou `1`)\n {message}", - "title": "Alexa Media Player - Método de verificação" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "description": "** {email} - alexa. {url} **\n Você confirmou com sucesso um OTP da chave de aplicativo 2FA integrada com a Amazon?\n > Código OTP {message}", "title": "Alexa Media Player - Confirmação OTP" }, - "twofactor": { - "data": { - "proxy": "Usar método Login Proxy (Não requer 2FA)", - "securitycode": "Código 2FA" - }, - "description": "** {email} - alexa. {url} **\n Digite a senha de uso único (OTP).\n {message}", - "title": "Alexa Media Player - Autenticação de dois fatores" - }, "user": { "data": { - "cookies_txt": "Cookies.txt data", "debug": "Depuração avançada", "email": "Endereço de e-mail", "exclude_devices": "Dispositivos excluídos (separado por vírgula)", "hass_url": "Url para acesso ao Home Assistant", "include_devices": "Dispositivos incluídos (separado por vírgula)", - "oauth_login": "Habilitar o aplicativo para método auth-token", "otp_secret": "Chave de aplicativo 2FA integrada (gerar automaticamente códigos 2FA)", "password": "Senha", - "proxy": "Usar método Login Proxy (Não requer 2FA)", "scan_interval": "Segundos entre varreduras", "securitycode": "Código 2FA (recomendado para evitar problemas de login)", "url": "Domínio regional da Amazon (ex: amazon.co.uk)" }, - "description": "Por favor, confirme as informações abaixo. Para configuração legada, desative a opção `Usar método de proxy de login`.", + "description": "Por favor, confirme as informações abaixo.", "title": "Alexa Media Player - Configurações" - }, - "user_legacy": { - "data": { - "cookies_txt": "Cookies.txt data", - "debug": "Depuração avançada", - "email": "Endereço de e-mail", - "exclude_devices": "Dispositivos excluídos (separado por vírgula)", - "include_devices": "Dispositivos incluídos (separado por vírgula)", - "oauth_login": "Habilitar o aplicativo para método auth-token", - "otp_secret": "Chave de aplicativo 2FA integrada (gerar automaticamente códigos 2FA)", - "password": "Senha", - "proxy": "Usar método Login Proxy (Não requer 2FA)", - "scan_interval": "Segundos entre escaneamentos", - "securitycode": "Código 2FA (recomendado para evitar problemas de login)", - "url": "Domínio regional da Amazon (ex: amazon.co.uk)" - }, - "description": "Insira suas [informações](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Importação de cookies](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) pode ser mais fácil!**\n **AVISO: A Amazon informa incorretamente 'Digite um e-mail ou número de celular válido' quando [o código 2FA é necessário](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication- para-sua-conta-amazon).**\n > {message}", - "title": "Alexa Media Player - Configurações legado" } } }, diff --git a/custom_components/alexa_media/translations/pt.json b/custom_components/alexa_media/translations/pt.json index 5cf76c7d2..0ea685b62 100644 --- a/custom_components/alexa_media/translations/pt.json +++ b/custom_components/alexa_media/translations/pt.json @@ -14,38 +14,10 @@ "unknown_error": "Erro desconhecido, por favor habilite depuração avançada e informações de log de relatório" }, "step": { - "action_required": { - "data": { - "proxy": "Usar método de proxy de login (2FA não é necessário)" - }, - "description": "**{email} - alexa.{url}** \nA Amazon enviará uma notificação push de acordo com a mensagem abaixo. Por favor, responda completamente antes de continuar. \n{message}", - "title": "Alexa Media Player - Ação Necessária" - }, - "authselect": { - "data": { - "authselectoption": "Método OTP (senha de uso único)", - "proxy": "Usar método de proxy de login (2FA não é necessário)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Senha de uso único" - }, "captcha": { "data": { - "captcha": "Captcha", - "password": "Senha", - "proxy": "Usar método de proxy de login (2FA não é necessário)", "securitycode": "Código 2FA (recomendado para evitar problemas de login)" - }, - "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", - "title": "Alexa Media Player - Captcha" - }, - "claimspicker": { - "data": { - "authselectoption": "Método de verificação", - "proxy": "Usar método de proxy de login (2FA não é necessário)" - }, - "description": "**{email} - alexa.{url}** \nPor favor, selecione o método de verificação por número. (ex. '0' ou '1') \n{message}", - "title": "Alexa Media Player - Método de verificação" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "description": "** {email} - alexa. {url} **\n Você confirmou com sucesso uma senha de uso único na aplicação 2FA integrada com a Amazon?\n > Código OTP {message}", "title": "Alexa Media Player - Confirmação OTP" }, - "twofactor": { - "data": { - "proxy": "Usar método de proxy de login (2FA não é necessário)", - "securitycode": "Código 2FA" - }, - "description": "**{email} - alexa.{url}** \nDigite a senha de uso único (OTP). \n{message}", - "title": "Alexa Media Player - autenticação de dois fatores" - }, "user": { "data": { - "cookies_txt": "Dados de cookies.txt", "debug": "Depuração avançada", "email": "Endereço de e-mail", "exclude_devices": "Dispositivo excluído (separado por vírgula)", "hass_url": "URL para aceder o Home Assistant", "include_devices": "Dispositivo incluído (separado por vírgula)", - "oauth_login": "Habilitar método de aplicativo oauth-token", "otp_secret": "Chave de aplicativo 2FA integrada (gerar códigos 2FA automaticamente)", "password": "Senha", - "proxy": "Usar método de proxy de login (2FA não é necessário)", "scan_interval": "Segundos entre análises", "securitycode": "Código 2FA (recomendado para evitar problemas de login)", "url": "Região do domínio Amazon (ex. amazon.com.br)" }, - "description": "Por favor, confirme as informações abaixo. Para configuração de compatibilidade, desative a opção 'Usar método de proxy de login'.", + "description": "Por favor, confirme as informações abaixo.", "title": "Alexa Media Player - Configuração" - }, - "user_legacy": { - "data": { - "cookies_txt": "Dados de cookies.txt", - "debug": "Depuração avançada", - "email": "Endereço de e-mail", - "exclude_devices": "Dispositivo excluído (separado por vírgula)", - "include_devices": "Dispositivo incluído (separado por vírgula)", - "oauth_login": "Habilitar método de aplicativo oauth-token", - "otp_secret": "Chave de aplicativo 2FA integrada (gerar códigos 2FA automaticamente)", - "password": "Senha", - "proxy": "Usar método de proxy de login (2FA não é necessário)", - "scan_interval": "Segundos entre análises", - "securitycode": "Código 2FA (recomendado para evitar problemas de login)", - "url": "Região do domínio Amazon (ex. amazon.com.br)" - }, - "description": "Por favor, introduza a sua [informação](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) pode ser mais fácil!** \n**Aviso: a Amazon informa incorretamente 'Insira um e-mail ou número de celular válido' quando [2FA Code é necessário](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Configuração de Compatibilidade" } } }, diff --git a/custom_components/alexa_media/translations/ru.json b/custom_components/alexa_media/translations/ru.json index ee10f8285..e6aa3c69d 100644 --- a/custom_components/alexa_media/translations/ru.json +++ b/custom_components/alexa_media/translations/ru.json @@ -14,38 +14,10 @@ "unknown_error": "Неизвестная ошибка, пожалуйста, сообщите информацию журнала" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa. {url}**\nAmazon отправит push-уведомление в соответствии с нижеприведенным сообщением. Пожалуйста, полностью ответьте, прежде чем продолжить.\n{message}", - "title": "Alexa Media Player - Требуемое действие" - }, - "authselect": { - "data": { - "authselectoption": "Одноразовый пароль", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - Одноразовый Пароль" - }, "captcha": { "data": { - "captcha": "Капча", - "password": "Пароль", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "2FA Code (recommended to avoid login issues)" - }, - "description": "**{email} - alexa.{url}** \n>{message} \n{captcha_image}", - "title": "Alexa Media Player - Капча" - }, - "claimspicker": { - "data": { - "authselectoption": "Метод проверки", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}**\nПожалуйста, выберите способ проверки по номеру. (например, \" 0 \" или \" 1`)\n{message}", - "title": "Alexa Media Player - Метод проверки" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2-факторная авторизация" - }, - "description": "**{email} - alexa.{url}**\nВведите одноразовый пароль.\n{message}", - "title": "Alexa Media Player - Двух факторная идентификация" - }, "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Расширенные возможности отладки", "email": "Адрес электронной почты", "exclude_devices": "Исключенные устройства (через запятую)", "hass_url": "Url to access Home Assistant", "include_devices": "Включенные устройства (разделенное запятыми)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Пароль", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Секунды между сканированиями", "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Домен региона Amazon (например, amazon.co.uk)" }, - "description": "Пожалуйста, подтвердите информацию ниже. Для старой конфигурации отключите опцию `Use Login Proxy method`.", + "description": "Пожалуйста, подтвердите информацию ниже.", "title": "Alexa Media Player - Конфигурация" - }, - "user_legacy": { - "data": { - "cookies_txt": "config::step::user::data::cookies_txt", - "debug": "Расширенные возможности отладки", - "email": "Адрес электронной почты", - "exclude_devices": "Исключенные устройства (через запятую)", - "include_devices": "Включенные устройства (разделенное запятыми)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Пароль", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Секунды между сканированиями", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Домен региона Amazon (например, amazon.co.uk)" - }, - "description": "Пожалуйста, введите свои данные.{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/alexa_media/translations/zh-Hans.json b/custom_components/alexa_media/translations/zh-Hans.json index 4af369e37..2658d0e67 100644 --- a/custom_components/alexa_media/translations/zh-Hans.json +++ b/custom_components/alexa_media/translations/zh-Hans.json @@ -14,38 +14,10 @@ "unknown_error": "Unknown error, please report log info" }, "step": { - "action_required": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nAmazon will send a push notification per the below message. Please completely respond before continuing. \n{message}", - "title": "Alexa Media Player - Action Required" - }, - "authselect": { - "data": { - "authselectoption": "OTP方式", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}**\n{message}", - "title": "Alexa Media Player - 一次性密码" - }, "captcha": { "data": { - "captcha": "验证码", - "password": "密码", - "proxy": "Use Login Proxy method (2FA not required)", "securitycode": "2FA Code (recommended to avoid login issues)" - }, - "description": "**{email} - alexa.{url}** \n>{message} \n{captcha_image}", - "title": "Alexa Media Player-验证码" - }, - "claimspicker": { - "data": { - "authselectoption": "验证方式", - "proxy": "Use Login Proxy method (2FA not required)" - }, - "description": "**{email} - alexa.{url}** \nPlease select verification method by number. (e.g., `0` or `1`) \n{message}", - "title": "Alexa Media Player - 验证方法" + } }, "totp_register": { "data": { @@ -54,50 +26,21 @@ "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" }, - "twofactor": { - "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA代码" - }, - "description": "**{email} - Alexa.{url} ** \n输入一次性密码(OTP)。 \n {message}", - "title": "Alexa Media Player - Two Factor Authentication" - }, "user": { "data": { - "cookies_txt": "Cookie.txt数据", "debug": "高级调试", "email": "电子邮件地址", "exclude_devices": "Excluded device (comma separated)", "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "密码", - "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Seconds between scans", "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "请确认以下信息。对于旧版配置,请禁用“使用登录代理方法”选项。", + "description": "请确认以下信息。", "title": "Alexa Media Player-配置" - }, - "user_legacy": { - "data": { - "cookies_txt": "config::step::user::data::cookies_txt", - "debug": "高级调试", - "email": "电子邮件地址", - "exclude_devices": "Excluded device (comma separated)", - "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "密码", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Seconds between scans", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Amazon region domain (e.g., amazon.co.uk)" - }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" } } }, diff --git a/custom_components/frigate/__init__.py b/custom_components/frigate/__init__.py index 7b1cd301d..ffa4cd8c2 100644 --- a/custom_components/frigate/__init__.py +++ b/custom_components/frigate/__init__.py @@ -16,7 +16,7 @@ from custom_components.frigate.config_flow import get_config_entry_title from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.components.mqtt.subscription import ( - EntitySubscription, + async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, ) @@ -32,36 +32,6 @@ from homeassistant.loader import async_get_integration from homeassistant.util import slugify -# TODO(@dermotduffy): This section can be removed some safe distance from the -# official release of 2022.3 (and the contents of the first version of -# `subscribe_topics` can be moved into async_added_to_hass below). -try: - from homeassistant.components.mqtt.subscription import ( - async_prepare_subscribe_topics, - ) - - async def subscribe_topics( - hass: HomeAssistant, - state: dict[str, EntitySubscription] | None, - topics: dict[str, Any], - ) -> Any: # pragma: no cover - """Subscribe to MQTT topic.""" - state = async_prepare_subscribe_topics(hass, state, topics) - # pylint: disable=no-value-for-parameter - return await async_subscribe_topics(hass, state) - - -except ImportError: - - async def subscribe_topics( - hass: HomeAssistant, - state: dict[str, EntitySubscription] | None, - topics: dict[str, Any], - ) -> Any: # pragma: no cover - """Subscribe to MQTT topic.""" - return await async_subscribe_topics(hass, state, topics) - - from .api import FrigateApiClient, FrigateApiClientError from .const import ( ATTR_CLIENT, @@ -118,12 +88,20 @@ def get_friendly_name(name: str) -> str: return name.replace("_", " ").title() -def get_cameras_and_objects(config: dict[str, Any]) -> set[tuple[str, str]]: +def get_cameras_and_objects( + config: dict[str, Any], include_all: bool = True +) -> set[tuple[str, str]]: """Get cameras and tracking object tuples.""" camera_objects = set() for cam_name, cam_config in config["cameras"].items(): for obj in cam_config["objects"]["track"]: camera_objects.add((cam_name, obj)) + + # add an artificial all label to track + # all objects for this camera + if include_all: + camera_objects.add((cam_name, "all")) + return camera_objects @@ -139,6 +117,10 @@ def get_cameras_zones_and_objects(config: dict[str, Any]) -> set[tuple[str, str] ) if not zone_name_objects or obj in zone_name_objects: zone_objects.add((zone_name, obj)) + + # add an artificial all label to track + # all objects for this zone + zone_objects.add((zone_name, "all")) return camera_objects.union(zone_objects) @@ -152,6 +134,15 @@ def get_cameras_and_zones(config: dict[str, Any]) -> set[str]: return cameras_zones +def get_zones(config: dict[str, Any]) -> set[str]: + """Get zones.""" + cameras_zones = set() + for camera in config.get("cameras", {}).keys(): + for zone in config["cameras"][camera].get("zones", {}).keys(): + cameras_zones.add(zone) + return cameras_zones + + async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up this integration using YAML is not supported.""" integration = await async_get_integration(hass, DOMAIN) @@ -388,7 +379,7 @@ def __init__( async def async_added_to_hass(self) -> None: """Subscribe mqtt events.""" - self._sub_state = await subscribe_topics( + state = async_prepare_subscribe_topics( self.hass, self._sub_state, { @@ -400,10 +391,11 @@ async def async_added_to_hass(self) -> None: }, }, ) + self._sub_state = await async_subscribe_topics(self.hass, state) async def async_will_remove_from_hass(self) -> None: """Cleanup prior to hass removal.""" - await async_unsubscribe_topics(self.hass, self._sub_state) + async_unsubscribe_topics(self.hass, self._sub_state) self._sub_state = None @callback # type: ignore[misc] diff --git a/custom_components/frigate/binary_sensor.py b/custom_components/frigate/binary_sensor.py index c746aa610..ba8367435 100644 --- a/custom_components/frigate/binary_sensor.py +++ b/custom_components/frigate/binary_sensor.py @@ -9,6 +9,7 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,6 +20,7 @@ get_friendly_name, get_frigate_device_identifier, get_frigate_entity_unique_id, + get_zones, ) from .const import ATTR_CONFIG, DOMAIN, NAME @@ -52,6 +54,7 @@ def __init__( self._cam_name = cam_name self._obj_name = obj_name self._is_on = False + self._frigate_config = frigate_config super().__init__( config_entry, @@ -92,6 +95,7 @@ def device_info(self) -> dict[str, Any]: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}", "manufacturer": NAME, } @@ -105,6 +109,11 @@ def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self._is_on + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not the entity is enabled by default.""" + return self._obj_name != "all" + @property def device_class(self) -> str: """Return the device class.""" diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index e6e864972..425d81505 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -43,27 +43,42 @@ async def async_setup_entry( ) -> None: """Camera entry setup.""" - config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] async_add_entities( [ - FrigateCamera(entry, cam_name, camera_config) - for cam_name, camera_config in config["cameras"].items() + FrigateCamera(entry, cam_name, frigate_config, camera_config) + for cam_name, camera_config in frigate_config["cameras"].items() ] + [ - FrigateMqttSnapshots(entry, config, cam_name, obj_name) - for cam_name, obj_name in get_cameras_and_objects(config) + FrigateMqttSnapshots(entry, frigate_config, cam_name, obj_name) + for cam_name, obj_name in get_cameras_and_objects(frigate_config, False) ] ) -class FrigateCamera(FrigateEntity, Camera): # type: ignore[misc] +class FrigateCamera(FrigateMQTTEntity, Camera): # type: ignore[misc] """Representation a Frigate camera.""" def __init__( - self, config_entry: ConfigEntry, cam_name: str, camera_config: dict[str, Any] + self, + config_entry: ConfigEntry, + cam_name: str, + frigate_config: dict[str, Any], + camera_config: dict[str, Any], ) -> None: """Initialize a Frigate camera.""" + super().__init__( + config_entry, + frigate_config, + { + "topic": ( + f"{frigate_config['mqtt']['topic_prefix']}" + f"/{cam_name}/recordings/state" + ), + "encoding": None, + }, + ) FrigateEntity.__init__(self, config_entry) Camera.__init__(self) self._cam_name = cam_name @@ -87,6 +102,12 @@ def __init__( else: self._stream_source = f"rtmp://{URL(self._url).host}/live/{self._cam_name}" + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._attr_is_recording = msg.payload.decode("utf-8") == "ON" + super()._state_message_received(msg) + @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" @@ -111,6 +132,7 @@ def device_info(self) -> dict[str, Any]: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._url}/cameras/{self._cam_name}", "manufacturer": NAME, } @@ -198,6 +220,7 @@ def device_info(self) -> DeviceInfo: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", "manufacturer": NAME, } diff --git a/custom_components/frigate/const.py b/custom_components/frigate/const.py index c03664848..d6f0854ff 100644 --- a/custom_components/frigate/const.py +++ b/custom_components/frigate/const.py @@ -4,10 +4,12 @@ DOMAIN = "frigate" FRIGATE_VERSION_ERROR_CUTOFF = "0.8.4" FRIGATE_RELEASES_URL = "https://github.com/blakeblackshear/frigate/releases" +FRIGATE_RELEASE_TAG_URL = f"{FRIGATE_RELEASES_URL}/tag" # Icons ICON_CAR = "mdi:shield-car" ICON_CAT = "mdi:cat" +ICON_CONTRAST = "mdi:contrast-circle" ICON_DOG = "mdi:dog-side" ICON_FILM_MULTIPLE = "mdi:filmstrip-box-multiple" ICON_IMAGE_MULTIPLE = "mdi:image-multiple" @@ -21,7 +23,8 @@ SENSOR = "sensor" SWITCH = "switch" CAMERA = "camera" -PLATFORMS = [SENSOR, CAMERA, SWITCH, BINARY_SENSOR] +UPDATE = "update" +PLATFORMS = [SENSOR, CAMERA, SWITCH, BINARY_SENSOR, UPDATE] # Unit of measurement FPS = "fps" @@ -37,6 +40,8 @@ # Configuration and options CONF_CAMERA_STATIC_IMAGE_HEIGHT = "camera_image_height" CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable" +CONF_PASSWORD = "password" +CONF_PATH = "path" CONF_RTMP_URL_TEMPLATE = "rtmp_url_template" # Defaults diff --git a/custom_components/frigate/diagnostics.py b/custom_components/frigate/diagnostics.py new file mode 100644 index 000000000..8d6d315a5 --- /dev/null +++ b/custom_components/frigate/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Frigate.""" + +from typing import Any, Dict + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ATTR_CLIENT, ATTR_CONFIG, CONF_PASSWORD, CONF_PATH, DOMAIN + +REDACT_CONFIG = {CONF_PASSWORD, CONF_PATH} + + +def get_redacted_data(data: Dict[str, Any]) -> Any: + """Redact sensitive vales from data.""" + return async_redact_data(data, REDACT_CONFIG) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> Dict[str, Any]: + """Return diagnostics for a config entry.""" + + config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + redacted_config = get_redacted_data(config) + + stats = await hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT].async_get_stats() + redacted_stats = get_redacted_data(stats) + + data = { + "frigate_config": redacted_config, + "frigate_stats": redacted_stats, + } + return data diff --git a/custom_components/frigate/manifest.json b/custom_components/frigate/manifest.json index 8e48163e8..8ba6bf656 100644 --- a/custom_components/frigate/manifest.json +++ b/custom_components/frigate/manifest.json @@ -2,7 +2,7 @@ "domain": "frigate", "documentation": "https://github.com/blakeblackshear/frigate", "name": "Frigate", - "version": "2.2.2", + "version": "2.3", "issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues", "dependencies": [ "http", diff --git a/custom_components/frigate/media_source.py b/custom_components/frigate/media_source.py index cda09971c..29ae73202 100644 --- a/custom_components/frigate/media_source.py +++ b/custom_components/frigate/media_source.py @@ -50,6 +50,46 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: return FrigateMediaSource(hass) +class FrigateBrowseMediaMetadata: + """Metadata for browsable Frigate media files.""" + + event: dict[str, Any] | None + + def __init__(self, event: dict[str, Any]): + """Initialize a FrigateBrowseMediaMetadata object.""" + self.event = { + # Strip out the thumbnail from the Frigate event, as it is already + # included in the BrowseMediaSource. + k: event[k] + for k in event + if k != "thumbnail" + } + + def as_dict(self) -> dict: + """Convert the object to a dictionary.""" + return {"event": self.event} + + +class FrigateBrowseMediaSource(BrowseMediaSource): # type: ignore[misc] + """Represent a browsable Frigate media file.""" + + children: list[FrigateBrowseMediaSource] | None + frigate: FrigateBrowseMediaMetadata + + def as_dict(self, *args: Any, **kwargs: Any) -> dict: + """Convert the object to a dictionary.""" + res: dict = super().as_dict(*args, **kwargs) + res["frigate"] = self.frigate.as_dict() + return res + + def __init__( + self, frigate: FrigateBrowseMediaMetadata, *args: Any, **kwargs: Any + ) -> None: + """Initialize media source browse media.""" + super().__init__(*args, **kwargs) + self.frigate = frigate + + @attr.s(frozen=True) class Identifier: """Base class for Identifiers.""" @@ -797,7 +837,7 @@ def _build_event_response( duration = int(end_time - start_time) children.append( - BrowseMediaSource( + FrigateBrowseMediaSource( domain=DOMAIN, identifier=EventIdentifier( identifier.frigate_instance_id, @@ -811,6 +851,7 @@ def _build_event_response( can_play=identifier.media_type == MEDIA_TYPE_VIDEO, can_expand=False, thumbnail=f"data:image/jpeg;base64,{event['thumbnail']}", + frigate=FrigateBrowseMediaMetadata(event=event), ) ) return children diff --git a/custom_components/frigate/sensor.py b/custom_components/frigate/sensor.py index 83c0a31d1..318215419 100644 --- a/custom_components/frigate/sensor.py +++ b/custom_components/frigate/sensor.py @@ -5,8 +5,9 @@ from typing import Any from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,6 +20,7 @@ get_friendly_name, get_frigate_device_identifier, get_frigate_entity_unique_id, + get_zones, ) from .const import ( ATTR_CONFIG, @@ -75,6 +77,8 @@ async def async_setup_entry( class FrigateFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] """Frigate Sensor class.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry ) -> None: @@ -96,6 +100,7 @@ def device_info(self) -> DeviceInfo: "identifiers": {get_frigate_device_identifier(self._config_entry)}, "name": NAME, "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), "manufacturer": NAME, } @@ -130,6 +135,8 @@ def icon(self) -> str: class DetectorSpeedSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] """Frigate Detector Speed class.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, coordinator: FrigateDataUpdateCoordinator, @@ -155,6 +162,7 @@ def device_info(self) -> DeviceInfo: "identifiers": {get_frigate_device_identifier(self._config_entry)}, "name": NAME, "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), "manufacturer": NAME, } @@ -193,6 +201,8 @@ def icon(self) -> str: class CameraFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] """Frigate Camera Fps class.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, coordinator: FrigateDataUpdateCoordinator, @@ -225,6 +235,7 @@ def device_info(self) -> DeviceInfo: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", "manufacturer": NAME, } @@ -273,6 +284,7 @@ def __init__( self._cam_name = cam_name self._obj_name = obj_name self._state = 0 + self._frigate_config = frigate_config if self._obj_name == "person": self._icon = ICON_PERSON @@ -325,6 +337,7 @@ def device_info(self) -> DeviceInfo: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}", "manufacturer": NAME, } @@ -347,3 +360,8 @@ def unit_of_measurement(self) -> str: def icon(self) -> str: """Return the icon of the sensor.""" return self._icon + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not the entity is enabled by default.""" + return self._obj_name != "all" diff --git a/custom_components/frigate/switch.py b/custom_components/frigate/switch.py index ca1b96789..4d1db59e4 100644 --- a/custom_components/frigate/switch.py +++ b/custom_components/frigate/switch.py @@ -7,8 +7,9 @@ from homeassistant.components.mqtt import async_publish from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( @@ -21,6 +22,7 @@ from .const import ( ATTR_CONFIG, DOMAIN, + ICON_CONTRAST, ICON_FILM_MULTIPLE, ICON_IMAGE_MULTIPLE, ICON_MOTION_SENSOR, @@ -40,9 +42,11 @@ async def async_setup_entry( for camera in frigate_config["cameras"].keys(): entities.extend( [ - FrigateSwitch(entry, frigate_config, camera, "detect"), - FrigateSwitch(entry, frigate_config, camera, "recordings"), - FrigateSwitch(entry, frigate_config, camera, "snapshots"), + FrigateSwitch(entry, frigate_config, camera, "detect", True), + FrigateSwitch(entry, frigate_config, camera, "motion", False), + FrigateSwitch(entry, frigate_config, camera, "recordings", True), + FrigateSwitch(entry, frigate_config, camera, "snapshots", True), + FrigateSwitch(entry, frigate_config, camera, "improve_contrast", False), ] ) async_add_entities(entities) @@ -51,12 +55,15 @@ async def async_setup_entry( class FrigateSwitch(FrigateMQTTEntity, SwitchEntity): # type: ignore[misc] """Frigate Switch class.""" + _attr_entity_category = EntityCategory.CONFIG + def __init__( self, config_entry: ConfigEntry, frigate_config: dict[str, Any], cam_name: str, switch_name: str, + default_enabled: bool, ) -> None: """Construct a FrigateSwitch.""" @@ -68,10 +75,14 @@ def __init__( f"/{self._cam_name}/{self._switch_name}/set" ) + self._attr_entity_registry_enabled_default = default_enabled + if self._switch_name == "snapshots": self._icon = ICON_IMAGE_MULTIPLE elif self._switch_name == "recordings": self._icon = ICON_FILM_MULTIPLE + elif self._switch_name == "improve_contrast": + self._icon = ICON_CONTRAST else: self._icon = ICON_MOTION_SENSOR @@ -111,13 +122,14 @@ def device_info(self) -> DeviceInfo: "via_device": get_frigate_device_identifier(self._config_entry), "name": get_friendly_name(self._cam_name), "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", "manufacturer": NAME, } @property def name(self) -> str: """Return the name of the sensor.""" - return f"{get_friendly_name(self._cam_name)} {self._switch_name}".title() + return f"{get_friendly_name(self._cam_name)} {get_friendly_name(self._switch_name)}".title() @property def is_on(self) -> bool: diff --git a/custom_components/frigate/translations/en.json b/custom_components/frigate/translations/en.json index 561a07b03..8019346b5 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -2,7 +2,6 @@ "config": { "step": { "user": { - "title": "Frigate", "description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 1935 (rtmp) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support (requires RTMP)\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications", "data": { "url": "URL" @@ -30,4 +29,4 @@ "only_advanced_options": "Advanced mode is disabled and there are only advanced options" } } -} \ No newline at end of file +} diff --git a/custom_components/frigate/translations/pt-BR.json b/custom_components/frigate/translations/pt-BR.json new file mode 100644 index 000000000..0adf068ec --- /dev/null +++ b/custom_components/frigate/translations/pt-BR.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_url": "URL inválida" + }, + "abort": { + "already_configured": "O dispositivo já está configurado" + } + }, + "options": { + "step": { + "init": { + "data": { + "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado" + } + } + }, + "abort": { + "only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas" + } + } +} diff --git a/custom_components/frigate/translations/pt_br.json b/custom_components/frigate/translations/pt_br.json new file mode 100644 index 000000000..0adf068ec --- /dev/null +++ b/custom_components/frigate/translations/pt_br.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_url": "URL inválida" + }, + "abort": { + "already_configured": "O dispositivo já está configurado" + } + }, + "options": { + "step": { + "init": { + "data": { + "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado" + } + } + }, + "abort": { + "only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas" + } + } +} diff --git a/custom_components/frigate/update.py b/custom_components/frigate/update.py new file mode 100644 index 000000000..7b1f4a1a4 --- /dev/null +++ b/custom_components/frigate/update.py @@ -0,0 +1,100 @@ +"""Update platform for frigate.""" +from __future__ import annotations + +import logging + +from homeassistant.components.update import UpdateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + FrigateDataUpdateCoordinator, + FrigateEntity, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ATTR_COORDINATOR, DOMAIN, FRIGATE_RELEASE_TAG_URL, NAME + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Sensor entry setup.""" + coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] + + entities = [] + entities.append(FrigateContainerUpdate(coordinator, entry)) + async_add_entities(entities) + + +class FrigateContainerUpdate(FrigateEntity, UpdateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate container update.""" + + _attr_name = "Frigate Server" + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Construct a FrigateContainerUpdate.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "update", "frigate_server" + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + + version_hash = self.coordinator.data.get("service", {}).get("version") + + if not version_hash: + return None + + version = str(version_hash).split("-")[0] + + return version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + + version = self.coordinator.data.get("service", {}).get("latest_version") + + if not version or version == "unknown": + return None + + return str(version) + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + + if (version := self.latest_version) is None: + return None + + return f"{FRIGATE_RELEASE_TAG_URL}/v{version}" diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py new file mode 100644 index 000000000..f1af7f0b0 --- /dev/null +++ b/custom_components/localtuya/__init__.py @@ -0,0 +1,373 @@ +"""The LocalTuya integration.""" +import asyncio +import logging +import time +from datetime import timedelta + +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE_ID, + CONF_DEVICES, + CONF_ENTITIES, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + CONF_REGION, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.event import async_track_time_interval + +from .cloud_api import TuyaCloudApi +from .common import TuyaDevice, async_config_entry_by_device_id +from .config_flow import ENTRIES_VERSION, config_schema +from .const import ( + ATTR_UPDATED_AT, + CONF_NO_CLOUD, + CONF_PRODUCT_KEY, + CONF_USER_ID, + DATA_CLOUD, + DATA_DISCOVERY, + DOMAIN, + TUYA_DEVICES, +) +from .discovery import TuyaDiscovery + +_LOGGER = logging.getLogger(__name__) + +UNSUB_LISTENER = "unsub_listener" + +RECONNECT_INTERVAL = timedelta(seconds=60) + +CONFIG_SCHEMA = config_schema() + +CONF_DP = "dp" +CONF_VALUE = "value" + +SERVICE_SET_DP = "set_dp" +SERVICE_SET_DP_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_DP): int, + vol.Required(CONF_VALUE): object, + } +) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the LocalTuya integration component.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][TUYA_DEVICES] = {} + + device_cache = {} + + async def _handle_reload(service): + """Handle reload service call.""" + _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) + + current_entries = hass.config_entries.async_entries(DOMAIN) + + reload_tasks = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in current_entries + ] + + await asyncio.gather(*reload_tasks) + + async def _handle_set_dp(event): + """Handle set_dp service call.""" + dev_id = event.data[CONF_DEVICE_ID] + if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]: + raise HomeAssistantError("unknown device id") + + device = hass.data[DOMAIN][TUYA_DEVICES][dev_id] + if not device.connected: + raise HomeAssistantError("not connected to device") + + await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP]) + + def _device_discovered(device): + """Update address of device if it has changed.""" + device_ip = device["ip"] + device_id = device["gwId"] + product_key = device["productKey"] + + # If device is not in cache, check if a config entry exists + entry = async_config_entry_by_device_id(hass, device_id) + if entry is None: + return + + if device_id not in device_cache: + if entry and device_id in entry.data[CONF_DEVICES]: + # Save address from config entry in cache to trigger + # potential update below + host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST] + device_cache[device_id] = host_ip + + if device_id not in device_cache: + return + + dev_entry = entry.data[CONF_DEVICES][device_id] + + new_data = entry.data.copy() + updated = False + + if device_cache[device_id] != device_ip: + updated = True + new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip + device_cache[device_id] = device_ip + + if dev_entry.get(CONF_PRODUCT_KEY) != product_key: + updated = True + new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key + + # Update settings if something changed, otherwise try to connect. Updating + # settings triggers a reload of the config entry, which tears down the device + # so no need to connect in that case. + if updated: + _LOGGER.debug( + "Updating keys for device %s: %s %s", device_id, device_ip, product_key + ) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + hass.config_entries.async_update_entry(entry, data=new_data) + device = hass.data[DOMAIN][TUYA_DEVICES][device_id] + if not device.connected: + device.async_connect() + elif device_id in hass.data[DOMAIN][TUYA_DEVICES]: + # _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) + + device = hass.data[DOMAIN][TUYA_DEVICES][device_id] + if not device.connected: + device.async_connect() + + def _shutdown(event): + """Clean up resources when shutting down.""" + discovery.close() + + async def _async_reconnect(now): + """Try connecting to devices not already connected to.""" + for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): + if not device.connected: + device.async_connect() + + async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA + ) + + discovery = TuyaDiscovery(_device_discovered) + try: + await discovery.start() + hass.data[DOMAIN][DATA_DISCOVERY] = discovery + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("failed to set up discovery") + + return True + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entries merging all of them in one.""" + new_version = ENTRIES_VERSION + stored_entries = hass.config_entries.async_entries(DOMAIN) + if config_entry.version == 1: + _LOGGER.debug("Migrating config entry from version %s", config_entry.version) + + if config_entry.entry_id == stored_entries[0].entry_id: + _LOGGER.debug( + "Migrating the first config entry (%s)", config_entry.entry_id + ) + new_data = {} + new_data[CONF_REGION] = "eu" + new_data[CONF_CLIENT_ID] = "" + new_data[CONF_CLIENT_SECRET] = "" + new_data[CONF_USER_ID] = "" + new_data[CONF_USERNAME] = DOMAIN + new_data[CONF_NO_CLOUD] = True + new_data[CONF_DEVICES] = { + config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy() + } + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + config_entry.version = new_version + hass.config_entries.async_update_entry( + config_entry, title=DOMAIN, data=new_data + ) + else: + _LOGGER.debug( + "Merging the config entry %s into the main one", config_entry.entry_id + ) + new_data = stored_entries[0].data.copy() + new_data[CONF_DEVICES].update( + {config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()} + ) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + hass.config_entries.async_update_entry(stored_entries[0], data=new_data) + await hass.config_entries.async_remove(config_entry.entry_id) + + _LOGGER.info( + "Entry %s successfully migrated to version %s.", + config_entry.entry_id, + new_version, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up LocalTuya integration from a config entry.""" + if entry.version < ENTRIES_VERSION: + _LOGGER.debug( + "Skipping setup for entry %s since its version (%s) is old", + entry.entry_id, + entry.version, + ) + return + + region = entry.data[CONF_REGION] + client_id = entry.data[CONF_CLIENT_ID] + secret = entry.data[CONF_CLIENT_SECRET] + user_id = entry.data[CONF_USER_ID] + tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id) + no_cloud = True + if CONF_NO_CLOUD in entry.data: + no_cloud = entry.data.get(CONF_NO_CLOUD) + if no_cloud: + _LOGGER.info("Cloud API account not configured.") + # wait 1 second to make sure possible migration has finished + await asyncio.sleep(1) + else: + res = await tuya_api.async_get_access_token() + if res != "ok": + _LOGGER.error("Cloud API connection failed: %s", res) + _LOGGER.info("Cloud API connection succeeded.") + res = await tuya_api.async_get_devices_list() + hass.data[DOMAIN][DATA_CLOUD] = tuya_api + + async def setup_entities(dev_id): + dev_entry = entry.data[CONF_DEVICES][dev_id] + device = TuyaDevice(hass, entry, dev_id) + hass.data[DOMAIN][TUYA_DEVICES][dev_id] = device + + platforms = set(entity[CONF_PLATFORM] for entity in dev_entry[CONF_ENTITIES]) + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in platforms + ] + ) + device.async_connect() + + await async_remove_orphan_entities(hass, entry) + + for dev_id in entry.data[CONF_DEVICES]: + hass.async_create_task(setup_entities(dev_id)) + + unsub_listener = entry.add_update_listener(update_listener) + hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener} + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + platforms = {} + + for dev_id, dev_entry in entry.data[CONF_DEVICES].items(): + for entity in dev_entry[CONF_ENTITIES]: + platforms[entity[CONF_PLATFORM]] = True + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in platforms + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() + for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): + if device.connected: + await device.close() + + if unload_ok: + hass.data[DOMAIN][TUYA_DEVICES] = {} + + return True + + +async def update_listener(hass, config_entry): + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + dev_id = list(device_entry.identifiers)[0][1].split("_")[-1] + + ent_reg = er.async_get(hass) + entities = { + ent.unique_id: ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) + if dev_id in ent.unique_id + } + for entity_id in entities.values(): + ent_reg.async_remove(entity_id) + + if dev_id not in config_entry.data[CONF_DEVICES]: + _LOGGER.info( + "Device %s not found in config entry: finalizing device removal", dev_id + ) + return True + + await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close() + + new_data = config_entry.data.copy() + new_data[CONF_DEVICES].pop(dev_id) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + hass.config_entries.async_update_entry( + config_entry, + data=new_data, + ) + + _LOGGER.info("Device %s removed.", dev_id) + + return True + + +async def async_remove_orphan_entities(hass, entry): + """Remove entities associated with config entry that has been removed.""" + return + ent_reg = er.async_get(hass) + entities = { + ent.unique_id: ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + } + _LOGGER.info("ENTITIES ORPHAN %s", entities) + return + + for entity in entry.data[CONF_ENTITIES]: + if entity[CONF_ID] in entities: + del entities[entity[CONF_ID]] + + for entity_id in entities.values(): + ent_reg.async_remove(entity_id) diff --git a/custom_components/localtuya/binary_sensor.py b/custom_components/localtuya/binary_sensor.py new file mode 100644 index 000000000..1a3d28abe --- /dev/null +++ b/custom_components/localtuya/binary_sensor.py @@ -0,0 +1,69 @@ +"""Platform to present any Tuya DP as a binary sensor.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN, + BinarySensorEntity, +) +from homeassistant.const import CONF_DEVICE_CLASS + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_STATE_ON = "state_on" +CONF_STATE_OFF = "state_off" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_STATE_ON, default="True"): str, + vol.Required(CONF_STATE_OFF, default="False"): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } + + +class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): + """Representation of a Tuya binary sensor.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya binary sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._is_on = False + + @property + def is_on(self): + """Return sensor state.""" + return self._is_on + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + def status_updated(self): + """Device status was updated.""" + state = str(self.dps(self._dp_id)).lower() + if state == self._config[CONF_STATE_ON].lower(): + self._is_on = True + elif state == self._config[CONF_STATE_OFF].lower(): + self._is_on = False + else: + self.warning( + "State for entity %s did not match state patterns", self.entity_id + ) + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema +) diff --git a/custom_components/localtuya/climate.py b/custom_components/localtuya/climate.py new file mode 100644 index 000000000..1400ec7ec --- /dev/null +++ b/custom_components/localtuya/climate.py @@ -0,0 +1,394 @@ +"""Platform to locally control Tuya-based climate devices.""" +import asyncio +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.climate import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_CURRENT_TEMPERATURE_DP, + CONF_ECO_DP, + CONF_ECO_VALUE, + CONF_HEURISTIC_ACTION, + CONF_HVAC_ACTION_DP, + CONF_HVAC_ACTION_SET, + CONF_HVAC_MODE_DP, + CONF_HVAC_MODE_SET, + CONF_MAX_TEMP_DP, + CONF_MIN_TEMP_DP, + CONF_PRECISION, + CONF_PRESET_DP, + CONF_PRESET_SET, + CONF_TARGET_PRECISION, + CONF_TARGET_TEMPERATURE_DP, + CONF_TEMPERATURE_STEP, +) + +_LOGGER = logging.getLogger(__name__) + +HVAC_MODE_SETS = { + "manual/auto": { + HVAC_MODE_HEAT: "manual", + HVAC_MODE_AUTO: "auto", + }, + "Manual/Auto": { + HVAC_MODE_HEAT: "Manual", + HVAC_MODE_AUTO: "Auto", + }, + "Manual/Program": { + HVAC_MODE_HEAT: "Manual", + HVAC_MODE_AUTO: "Program", + }, + "True/False": { + HVAC_MODE_HEAT: True, + }, +} +HVAC_ACTION_SETS = { + "True/False": { + CURRENT_HVAC_HEAT: True, + CURRENT_HVAC_IDLE: False, + }, + "open/close": { + CURRENT_HVAC_HEAT: "open", + CURRENT_HVAC_IDLE: "close", + }, + "heating/no_heating": { + CURRENT_HVAC_HEAT: "heating", + CURRENT_HVAC_IDLE: "no_heating", + }, + "Heat/Warming": { + CURRENT_HVAC_HEAT: "Heat", + CURRENT_HVAC_IDLE: "Warming", + }, +} +PRESET_SETS = { + "Manual/Holiday/Program": { + PRESET_AWAY: "Holiday", + PRESET_HOME: "Program", + PRESET_NONE: "Manual", + }, +} + +TEMPERATURE_CELSIUS = "celsius" +TEMPERATURE_FAHRENHEIT = "fahrenheit" +DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS +DEFAULT_PRECISION = PRECISION_TENTHS +DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES +# Empirically tested to work for AVATTO thermostat +MODE_WAIT = 0.1 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps), + vol.Optional(CONF_TEMPERATURE_STEP): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps), + vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps), + vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())), + vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps), + vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())), + vol.Optional(CONF_ECO_DP): vol.In(dps), + vol.Optional(CONF_ECO_VALUE): str, + vol.Optional(CONF_PRESET_DP): vol.In(dps), + vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())), + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( + [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] + ), + vol.Optional(CONF_TARGET_PRECISION): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_HEURISTIC_ACTION): bool, + } + + +class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): + """Tuya climate device.""" + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize a new LocaltuyaClimate.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._target_temperature = None + self._current_temperature = None + self._hvac_mode = None + self._preset_mode = None + self._hvac_action = None + self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION) + self._target_precision = self._config.get( + CONF_TARGET_PRECISION, self._precision + ) + self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) + self._conf_hvac_mode_set = HVAC_MODE_SETS.get( + self._config.get(CONF_HVAC_MODE_SET), {} + ) + self._conf_preset_dp = self._config.get(CONF_PRESET_DP) + self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {}) + self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP) + self._conf_hvac_action_set = HVAC_ACTION_SETS.get( + self._config.get(CONF_HVAC_ACTION_SET), {} + ) + self._conf_eco_dp = self._config.get(CONF_ECO_DP) + self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO") + self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config( + CONF_PRESET_DP + ) + _LOGGER.debug("Initialized climate [%s]", self.name) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = 0 + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE + if self.has_config(CONF_MAX_TEMP_DP): + supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE_RANGE + if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP): + supported_features = supported_features | SUPPORT_PRESET_MODE + return supported_features + + @property + def precision(self): + """Return the precision of the system.""" + return self._precision + + @property + def target_precision(self): + """Return the precision of the target.""" + return self._target_precision + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + if ( + self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT) + == TEMPERATURE_FAHRENHEIT + ): + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + if not self.has_config(CONF_HVAC_MODE_DP): + return None + return list(self._conf_hvac_mode_set) + [HVAC_MODE_OFF] + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._config.get(CONF_HEURISTIC_ACTION, False): + if self._hvac_mode == HVAC_MODE_HEAT: + if self._current_temperature < ( + self._target_temperature - self._precision + ): + self._hvac_action = CURRENT_HVAC_HEAT + if self._current_temperature == ( + self._target_temperature - self._precision + ): + if self._hvac_action == CURRENT_HVAC_HEAT: + self._hvac_action = CURRENT_HVAC_HEAT + if self._hvac_action == CURRENT_HVAC_IDLE: + self._hvac_action = CURRENT_HVAC_IDLE + if ( + self._current_temperature + self._precision + ) > self._target_temperature: + self._hvac_action = CURRENT_HVAC_IDLE + return self._hvac_action + return self._hvac_action + + @property + def preset_mode(self): + """Return current preset.""" + return self._preset_mode + + @property + def preset_modes(self): + """Return the list of available presets modes.""" + if not self._has_presets: + return None + presets = list(self._conf_preset_set) + if self._conf_eco_dp: + presets.append(PRESET_ECO) + return presets + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) + + @property + def fan_mode(self): + """Return the fan setting.""" + return NotImplementedError() + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return NotImplementedError() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP): + temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision) + await self._device.set_dp( + temperature, self._config[CONF_TARGET_TEMPERATURE_DP] + ) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + return NotImplementedError() + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._device.set_dp(False, self._dp_id) + return + if not self._state and self._conf_hvac_mode_dp != self._dp_id: + await self._device.set_dp(True, self._dp_id) + # Some thermostats need a small wait before sending another update + await asyncio.sleep(MODE_WAIT) + await self._device.set_dp( + self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set_dp(False, self._dp_id) + + async def async_set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + if preset_mode == PRESET_ECO: + await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp) + return + await self._device.set_dp( + self._conf_preset_set[preset_mode], self._conf_preset_dp + ) + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self.has_config(CONF_MIN_TEMP_DP): + return self.dps_conf(CONF_MIN_TEMP_DP) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self.has_config(CONF_MAX_TEMP_DP): + return self.dps_conf(CONF_MAX_TEMP_DP) + return DEFAULT_MAX_TEMP + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + self._target_temperature = ( + self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision + ) + + if self.has_config(CONF_CURRENT_TEMPERATURE_DP): + self._current_temperature = ( + self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision + ) + + if self._has_presets: + if ( + self.has_config(CONF_ECO_DP) + and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value + ): + self._preset_mode = PRESET_ECO + else: + for preset, value in self._conf_preset_set.items(): # todo remove + if self.dps_conf(CONF_PRESET_DP) == value: + self._preset_mode = preset + break + else: + self._preset_mode = PRESET_NONE + + # Update the HVAC status + if self.has_config(CONF_HVAC_MODE_DP): + if not self._state: + self._hvac_mode = HVAC_MODE_OFF + else: + for mode, value in self._conf_hvac_mode_set.items(): + if self.dps_conf(CONF_HVAC_MODE_DP) == value: + self._hvac_mode = mode + break + else: + # in case hvac mode and preset share the same dp + self._hvac_mode = HVAC_MODE_AUTO + + # Update the current action + for action, value in self._conf_hvac_action_set.items(): + if self.dps_conf(CONF_HVAC_ACTION_DP) == value: + self._hvac_action = action + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema) diff --git a/custom_components/localtuya/cloud_api.py b/custom_components/localtuya/cloud_api.py new file mode 100644 index 000000000..680217187 --- /dev/null +++ b/custom_components/localtuya/cloud_api.py @@ -0,0 +1,136 @@ +"""Class to perform requests to Tuya Cloud APIs.""" +import functools +import hashlib +import hmac +import json +import logging +import time + +import requests + +_LOGGER = logging.getLogger(__name__) + + +# Signature algorithm. +def calc_sign(msg, key): + """Calculate signature for request.""" + sign = ( + hmac.new( + msg=bytes(msg, "latin-1"), + key=bytes(key, "latin-1"), + digestmod=hashlib.sha256, + ) + .hexdigest() + .upper() + ) + return sign + + +class TuyaCloudApi: + """Class to send API calls.""" + + def __init__(self, hass, region_code, client_id, secret, user_id): + """Initialize the class.""" + self._hass = hass + self._base_url = f"https://openapi.tuya{region_code}.com" + self._client_id = client_id + self._secret = secret + self._user_id = user_id + self._access_token = "" + self.device_list = {} + + def generate_payload(self, method, timestamp, url, headers, body=None): + """Generate signed payload for requests.""" + payload = self._client_id + self._access_token + timestamp + + payload += method + "\n" + # Content-SHA256 + payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest() + payload += ( + "\n" + + "".join( + [ + "%s:%s\n" % (key, headers[key]) # Headers + for key in headers.get("Signature-Headers", "").split(":") + if key in headers + ] + ) + + "\n/" + + url.split("//", 1)[-1].split("/", 1)[-1] # Url + ) + # _LOGGER.debug("PAYLOAD: %s", payload) + return payload + + async def async_make_request(self, method, url, body=None, headers={}): + """Perform requests.""" + timestamp = str(int(time.time() * 1000)) + payload = self.generate_payload(method, timestamp, url, headers, body) + default_par = { + "client_id": self._client_id, + "access_token": self._access_token, + "sign": calc_sign(payload, self._secret), + "t": timestamp, + "sign_method": "HMAC-SHA256", + } + full_url = self._base_url + url + # _LOGGER.debug("\n" + method + ": [%s]", full_url) + + if method == "GET": + func = functools.partial( + requests.get, full_url, headers=dict(default_par, **headers) + ) + elif method == "POST": + func = functools.partial( + requests.post, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) + # _LOGGER.debug("BODY: [%s]", body) + elif method == "PUT": + func = functools.partial( + requests.put, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) + + resp = await self._hass.async_add_executor_job(func) + # r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format + return resp + + async def async_get_access_token(self): + """Obtain a valid access token.""" + resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1") + + if not resp.ok: + return "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + return f"Error {r_json['code']}: {r_json['msg']}" + + self._access_token = resp.json()["result"]["access_token"] + return "ok" + + async def async_get_devices_list(self): + """Obtain the list of devices associated to a user.""" + resp = await self.async_make_request( + "GET", url=f"/v1.0/users/{self._user_id}/devices" + ) + + if not resp.ok: + return "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + # _LOGGER.debug( + # "Request failed, reply is %s", + # json.dumps(r_json, indent=2, ensure_ascii=False) + # ) + return f"Error {r_json['code']}: {r_json['msg']}" + + self.device_list = {dev["id"]: dev for dev in r_json["result"]} + # _LOGGER.debug("DEV_LIST: %s", self.device_list) + + return "ok" diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py new file mode 100644 index 000000000..79eadc9b1 --- /dev/null +++ b/custom_components/localtuya/common.py @@ -0,0 +1,416 @@ +"""Code shared between all platforms.""" +import asyncio +import logging +import time +from datetime import timedelta + +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DEVICES, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + CONF_SCAN_INTERVAL, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity + +from . import pytuya +from .const import ( + ATTR_UPDATED_AT, + CONF_LOCAL_KEY, + CONF_MODEL, + CONF_PROTOCOL_VERSION, + DATA_CLOUD, + DOMAIN, + TUYA_DEVICES, +) + +_LOGGER = logging.getLogger(__name__) + + +def prepare_setup_entities(hass, config_entry, platform): + """Prepare ro setup entities for a platform.""" + entities_to_setup = [ + entity + for entity in config_entry.data[CONF_ENTITIES] + if entity[CONF_PLATFORM] == platform + ] + if not entities_to_setup: + return None, None + + tuyainterface = [] + + return tuyainterface, entities_to_setup + + +async def async_setup_entry( + domain, entity_class, flow_schema, hass, config_entry, async_add_entities +): + """Set up a Tuya platform based on a config entry. + + This is a generic method and each platform should lock domain and + entity_class with functools.partial. + """ + entities = [] + + for dev_id in config_entry.data[CONF_DEVICES]: + # entities_to_setup = prepare_setup_entities( + # hass, config_entry.data[dev_id], domain + # ) + dev_entry = config_entry.data[CONF_DEVICES][dev_id] + entities_to_setup = [ + entity + for entity in dev_entry[CONF_ENTITIES] + if entity[CONF_PLATFORM] == domain + ] + + if entities_to_setup: + + tuyainterface = hass.data[DOMAIN][TUYA_DEVICES][dev_id] + + dps_config_fields = list(get_dps_for_platform(flow_schema)) + + for entity_config in entities_to_setup: + # Add DPS used by this platform to the request list + for dp_conf in dps_config_fields: + if dp_conf in entity_config: + tuyainterface.dps_to_request[entity_config[dp_conf]] = None + + entities.append( + entity_class( + tuyainterface, + dev_entry, + entity_config[CONF_ID], + ) + ) + async_add_entities(entities) + + +def get_dps_for_platform(flow_schema): + """Return config keys for all platform keys that depends on a datapoint.""" + for key, value in flow_schema(None).items(): + if hasattr(value, "container") and value.container is None: + yield key.schema + + +def get_entity_config(config_entry, dp_id): + """Return entity config for a given DPS id.""" + for entity in config_entry[CONF_ENTITIES]: + if entity[CONF_ID] == dp_id: + return entity + raise Exception(f"missing entity config for id {dp_id}") + + +@callback +def async_config_entry_by_device_id(hass, device_id): + """Look up config entry by device id.""" + current_entries = hass.config_entries.async_entries(DOMAIN) + for entry in current_entries: + if device_id in entry.data[CONF_DEVICES]: + return entry + return None + + +class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): + """Cache wrapper for pytuya.TuyaInterface.""" + + def __init__(self, hass, config_entry, dev_id): + """Initialize the cache.""" + super().__init__() + self._hass = hass + self._config_entry = config_entry + self._dev_config_entry = config_entry.data[CONF_DEVICES][dev_id].copy() + self._interface = None + self._status = {} + self.dps_to_request = {} + self._is_closing = False + self._connect_task = None + self._disconnect_task = None + self._unsub_interval = None + self._local_key = self._dev_config_entry[CONF_LOCAL_KEY] + self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID]) + + # This has to be done in case the device type is type_0d + for entity in self._dev_config_entry[CONF_ENTITIES]: + self.dps_to_request[entity[CONF_ID]] = None + + @property + def connected(self): + """Return if connected to device.""" + return self._interface is not None + + def async_connect(self): + """Connect to device if not already connected.""" + 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]) + + try: + self._interface = await pytuya.connect( + self._dev_config_entry[CONF_HOST], + self._dev_config_entry[CONF_DEVICE_ID], + self._local_key, + float(self._dev_config_entry[CONF_PROTOCOL_VERSION]), + self, + ) + self._interface.add_dps_to_request(self.dps_to_request) + + self.debug("Retrieving initial state") + status = await self._interface.status() + if status is None: + raise Exception("Failed to retrieve status") + + self.status_updated(status) + + def _new_entity_handler(entity_id): + self.debug( + "New entity %s was added to %s", + entity_id, + self._dev_config_entry[CONF_HOST], + ) + self._dispatch_status() + + signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}" + self._disconnect_task = async_dispatcher_connect( + self._hass, signal, _new_entity_handler + ) + + if ( + CONF_SCAN_INTERVAL in self._dev_config_entry + and 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]), + ) + 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() + + if self._interface is not None: + await self._interface.close() + self._interface = None + self._connect_task = None + + async def update_local_key(self): + """Retrieve updated local_key from Cloud API and update the config_entry.""" + dev_id = self._dev_config_entry[CONF_DEVICE_ID] + await self._hass.data[DOMAIN][DATA_CLOUD].async_get_devices_list() + cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD].device_list + if dev_id in cloud_devs: + self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + new_data = self._config_entry.data.copy() + new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + self._hass.config_entries.async_update_entry( + self._config_entry, + data=new_data, + ) + self.info("local_key updated for device %s.", dev_id) + + async def _async_refresh(self, _now): + if self._interface is not None: + await self._interface.update_dps() + + async def close(self): + """Close connection and stop re-connect loop.""" + self._is_closing = True + if self._connect_task is not None: + self._connect_task.cancel() + await self._connect_task + if self._interface is not None: + await self._interface.close() + if self._disconnect_task is not None: + self._disconnect_task() + self.debug( + "Closed connection with device %s.", + self._dev_config_entry[CONF_FRIENDLY_NAME], + ) + + async def set_dp(self, state, dp_index): + """Change value of a DP of the Tuya device.""" + if self._interface is not None: + try: + await self._interface.set_dp(state, dp_index) + except Exception: # pylint: disable=broad-except + self.exception("Failed to set DP %d to %d", dp_index, state) + else: + self.error( + "Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME] + ) + + async def set_dps(self, states): + """Change value of a DPs of the Tuya device.""" + if self._interface is not None: + try: + await self._interface.set_dps(states) + except Exception: # pylint: disable=broad-except + self.exception("Failed to set DPs %r", states) + else: + self.error( + "Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME] + ) + + @callback + def status_updated(self, status): + """Device updated status.""" + self._status.update(status) + self._dispatch_status() + + def _dispatch_status(self): + signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}" + async_dispatcher_send(self._hass, signal, self._status) + + @callback + def disconnected(self): + """Device disconnected.""" + signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}" + async_dispatcher_send(self._hass, signal, None) + if self._unsub_interval is not None: + self._unsub_interval() + self._unsub_interval = None + self._interface = None + self.debug("Disconnected - waiting for discovery broadcast") + + +class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): + """Representation of a Tuya entity.""" + + def __init__(self, device, config_entry, dp_id, logger, **kwargs): + """Initialize the Tuya entity.""" + super().__init__() + self._device = device + self._dev_config_entry = config_entry + self._config = get_entity_config(config_entry, dp_id) + self._dp_id = dp_id + self._status = {} + self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID]) + + async def async_added_to_hass(self): + """Subscribe localtuya events.""" + await super().async_added_to_hass() + + self.debug("Adding %s with configuration: %s", self.entity_id, self._config) + + state = await self.async_get_last_state() + if state: + self.status_restored(state) + + def _update_handler(status): + """Update entity state when status was updated.""" + if status is None: + status = {} + if self._status != status: + self._status = status.copy() + if status: + self.status_updated() + self.schedule_update_ha_state() + + signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}" + + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, _update_handler) + ) + + signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}" + async_dispatcher_send(self.hass, signal, self.entity_id) + + @property + def device_info(self): + """Return device information for the device registry.""" + model = self._dev_config_entry.get(CONF_MODEL, "Tuya generic") + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"local_{self._dev_config_entry[CONF_DEVICE_ID]}") + }, + "name": self._dev_config_entry[CONF_FRIENDLY_NAME], + "manufacturer": "Tuya", + "model": f"{model} ({self._dev_config_entry[CONF_DEVICE_ID]})", + "sw_version": self._dev_config_entry[CONF_PROTOCOL_VERSION], + } + + @property + def name(self): + """Get name of Tuya entity.""" + return self._config[CONF_FRIENDLY_NAME] + + @property + def should_poll(self): + """Return if platform should poll for updates.""" + return False + + @property + def unique_id(self): + """Return unique device identifier.""" + return f"local_{self._dev_config_entry[CONF_DEVICE_ID]}_{self._dp_id}" + + def has_config(self, attr): + """Return if a config parameter has a valid value.""" + value = self._config.get(attr, "-1") + return value is not None and value != "-1" + + @property + def available(self): + """Return if device is available or not.""" + return str(self._dp_id) in self._status + + def dps(self, dp_index): + """Return cached value for DPS index.""" + value = self._status.get(str(dp_index)) + if value is None: + self.warning( + "Entity %s is requesting unknown DPS index %s", + self.entity_id, + dp_index, + ) + + return value + + def dps_conf(self, conf_item): + """Return value of datapoint for user specified config item. + + This method looks up which DP a certain config item uses based on + user configuration and returns its value. + """ + dp_index = self._config.get(conf_item) + if dp_index is None: + self.warning( + "Entity %s is requesting unset index for option %s", + self.entity_id, + conf_item, + ) + return self.dps(dp_index) + + def status_updated(self): + """Device status was updated. + + Override in subclasses and update entity specific state. + """ + + def status_restored(self, stored_state): + """Device status was restored. + + Override in subclasses and update entity specific state. + """ diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py new file mode 100644 index 000000000..695baa91c --- /dev/null +++ b/custom_components/localtuya/config_flow.py @@ -0,0 +1,755 @@ +"""Config flow for LocalTuya integration integration.""" +import errno +import logging +import time +from importlib import import_module + +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er +import voluptuous as vol +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE_ID, + CONF_DEVICES, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_REGION, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback + +from .cloud_api import TuyaCloudApi +from .common import pytuya +from .const import ( + ATTR_UPDATED_AT, + CONF_ACTION, + CONF_ADD_DEVICE, + CONF_DPS_STRINGS, + CONF_EDIT_DEVICE, + CONF_LOCAL_KEY, + CONF_MODEL, + CONF_NO_CLOUD, + CONF_PRODUCT_NAME, + CONF_PROTOCOL_VERSION, + CONF_SETUP_CLOUD, + CONF_USER_ID, + DATA_CLOUD, + DATA_DISCOVERY, + DOMAIN, + PLATFORMS, +) +from .discovery import discover + +_LOGGER = logging.getLogger(__name__) + +ENTRIES_VERSION = 2 + +PLATFORM_TO_ADD = "platform_to_add" +NO_ADDITIONAL_ENTITIES = "no_additional_entities" +SELECTED_DEVICE = "selected_device" + +CUSTOM_DEVICE = "..." + +CONF_ACTIONS = { + CONF_ADD_DEVICE: "Add a new device", + CONF_EDIT_DEVICE: "Edit a device", + CONF_SETUP_CLOUD: "Reconfigure Cloud API account", +} + +CONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), + } +) + +CLOUD_SETUP_SCHEMA = vol.Schema( + { + vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]), + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_USER_ID): cv.string, + vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string, + vol.Required(CONF_NO_CLOUD, default=False): bool, + } +) + +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, + } +) + +DEVICE_SCHEMA = vol.Schema( + { + 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.Optional(CONF_SCAN_INTERVAL): int, + } +) + +PICK_ENTITY_SCHEMA = vol.Schema( + {vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)} +) + + +def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True): + """Create schema for devices step.""" + devices = {} + for dev_id, dev_host in discovered_devices.items(): + dev_name = dev_id + if dev_id in cloud_devices_list.keys(): + dev_name = cloud_devices_list[dev_id][CONF_NAME] + devices[dev_id] = f"{dev_name} ({dev_host})" + + if add_custom_device: + devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE}) + + # devices.update( + # { + # ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] + # for ent in entries + # } + # ) + return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)}) + + +def options_schema(entities): + """Create schema for options.""" + entity_names = [ + f"{entity[CONF_ID]}: {entity[CONF_FRIENDLY_NAME]}" for entity in 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.Optional(CONF_SCAN_INTERVAL): int, + vol.Required( + CONF_ENTITIES, description={"suggested_value": entity_names} + ): cv.multi_select(entity_names), + } + ) + + +def schema_defaults(schema, dps_list=None, **defaults): + """Create a new schema with default values filled in.""" + copy = schema.extend({}) + for field, field_type in copy.schema.items(): + if isinstance(field_type, vol.In): + value = None + for dps in dps_list or []: + if dps.startswith(f"{defaults.get(field)} "): + value = dps + break + + if value in field_type.container: + field.default = vol.default_factory(value) + continue + + if field.schema in defaults: + field.default = vol.default_factory(defaults[field]) + return copy + + +def dps_string_list(dps_data): + """Return list of friendly DPS values.""" + return [f"{id} (value: {value})" for id, value in dps_data.items()] + + +def gen_dps_strings(): + """Generate list of DPS values.""" + return [f"{dp} (value: ?)" for dp in range(1, 256)] + + +def platform_schema(platform, dps_strings, allow_id=True, yaml=False): + """Generate input validation schema for a platform.""" + schema = {} + if yaml: + # In YAML mode we force the specified platform to match flow schema + schema[vol.Required(CONF_PLATFORM)] = vol.In([platform]) + if allow_id: + schema[vol.Required(CONF_ID)] = vol.In(dps_strings) + schema[vol.Required(CONF_FRIENDLY_NAME)] = str + return vol.Schema(schema).extend(flow_schema(platform, dps_strings)) + + +def flow_schema(platform, dps_strings): + """Return flow schema for a specific platform.""" + integration_module = ".".join(__name__.split(".")[:-1]) + return import_module("." + platform, integration_module).flow_schema(dps_strings) + + +def strip_dps_values(user_input, dps_strings): + """Remove values and keep only index for DPS config items.""" + stripped = {} + for field, value in user_input.items(): + if value in dps_strings: + stripped[field] = int(user_input[field].split(" ")[0]) + else: + stripped[field] = user_input[field] + return stripped + + +def config_schema(): + """Build schema used for setting up component.""" + entity_schemas = [ + platform_schema(platform, range(1, 256), yaml=True) for platform in PLATFORMS + ] + return vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + DEVICE_SCHEMA.extend( + {vol.Required(CONF_ENTITIES): [vol.Any(*entity_schemas)]} + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + detected_dps = {} + + interface = None + try: + interface = await pytuya.connect( + data[CONF_HOST], + data[CONF_DEVICE_ID], + data[CONF_LOCAL_KEY], + float(data[CONF_PROTOCOL_VERSION]), + ) + + detected_dps = await interface.detect_available_dps() + except (ConnectionRefusedError, ConnectionResetError) as ex: + raise CannotConnect from ex + except ValueError as ex: + raise InvalidAuth from ex + finally: + if interface: + await interface.close() + + # Indicate an error if no datapoints found as the rest of the flow + # won't work in this case + if not detected_dps: + raise EmptyDpsList + + return dps_string_list(detected_dps) + + +async def attempt_cloud_connection(hass, user_input): + """Create device.""" + cloud_api = TuyaCloudApi( + hass, + user_input.get(CONF_REGION), + user_input.get(CONF_CLIENT_ID), + user_input.get(CONF_CLIENT_SECRET), + user_input.get(CONF_USER_ID), + ) + + res = await cloud_api.async_get_access_token() + if res != "ok": + _LOGGER.error("Cloud API connection failed: %s", res) + return cloud_api, {"reason": "authentication_failed", "msg": res} + + res = await cloud_api.async_get_devices_list() + if res != "ok": + _LOGGER.error("Cloud API get_devices_list failed: %s", res) + return cloud_api, {"reason": "device_list_failed", "msg": res} + _LOGGER.info("Cloud API connection succeeded.") + + return cloud_api, {} + + +class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for LocalTuya integration.""" + + VERSION = ENTRIES_VERSION + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow for this handler.""" + return LocalTuyaOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize a new LocaltuyaConfigFlow.""" + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + placeholders = {} + if user_input is not None: + if user_input.get(CONF_NO_CLOUD): + for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + user_input[i] = "" + return await self._create_entry(user_input) + + cloud_api, res = await attempt_cloud_connection(self.hass, user_input) + + if not res: + return await self._create_entry(user_input) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = {} + defaults.update(user_input or {}) + + return self.async_show_form( + step_id="user", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders, + ) + + async def _create_entry(self, user_input): + """Register new entry.""" + # if self._async_current_entries(): + # return self.async_abort(reason="already_configured") + + await self.async_set_unique_id(user_input.get(CONF_USER_ID)) + user_input[CONF_DEVICES] = {} + + return self.async_create_entry( + title=user_input.get(CONF_USERNAME), + data=user_input, + ) + + async def async_step_import(self, user_input): + """Handle import from YAML.""" + _LOGGER.error( + "Configuration via YAML file is no longer supported by this integration." + ) + + +class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for LocalTuya integration.""" + + def __init__(self, config_entry): + """Initialize localtuya options flow.""" + self.config_entry = config_entry + # self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) + # self.entities = config_entry.data[CONF_ENTITIES] + self.selected_device = None + self.editing_device = False + self.device_data = None + self.dps_strings = [] + self.selected_platform = None + self.discovered_devices = {} + self.entities = [] + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + # device_id = self.config_entry.data[CONF_DEVICE_ID] + if user_input is not None: + if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD: + return await self.async_step_cloud_setup() + if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE: + return await self.async_step_add_device() + if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE: + return await self.async_step_edit_device() + + return self.async_show_form( + step_id="init", + data_schema=CONFIGURE_SCHEMA, + ) + + async def async_step_cloud_setup(self, user_input=None): + """Handle the initial step.""" + errors = {} + placeholders = {} + if user_input is not None: + if user_input.get(CONF_NO_CLOUD): + new_data = self.config_entry.data.copy() + new_data.update(user_input) + for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + new_data[i] = "" + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry( + title=new_data.get(CONF_USERNAME), data={} + ) + + cloud_api, res = await attempt_cloud_connection(self.hass, user_input) + + if not res: + new_data = self.config_entry.data.copy() + new_data.update(user_input) + cloud_devs = cloud_api.device_list + for dev_id, dev in new_data[CONF_DEVICES].items(): + if CONF_MODEL not in dev and dev_id in cloud_devs: + model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME) + new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry( + title=new_data.get(CONF_USERNAME), data={} + ) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = self.config_entry.data.copy() + defaults.update(user_input or {}) + defaults[CONF_NO_CLOUD] = False + + return self.async_show_form( + step_id="cloud_setup", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_add_device(self, user_input=None): + """Handle adding a new device.""" + # Use cache if available or fallback to manual discovery + self.editing_device = False + self.selected_device = None + errors = {} + if user_input is not None: + if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE: + self.selected_device = user_input[SELECTED_DEVICE] + + return await self.async_step_configure_device() + + self.discovered_devices = {} + data = self.hass.data.get(DOMAIN) + + if data and DATA_DISCOVERY in data: + self.discovered_devices = data[DATA_DISCOVERY].devices + else: + try: + self.discovered_devices = await discover() + except OSError as ex: + if ex.errno == errno.EADDRINUSE: + errors["base"] = "address_in_use" + else: + errors["base"] = "discovery_failed" + except Exception: # pylint: disable= broad-except + _LOGGER.exception("discovery failed") + errors["base"] = "discovery_failed" + + devices = { + dev_id: dev["ip"] + for dev_id, dev in self.discovered_devices.items() + if dev["gwId"] not in self.config_entry.data[CONF_DEVICES] + } + + return self.async_show_form( + step_id="add_device", + data_schema=devices_schema( + devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list + ), + errors=errors, + ) + + async def async_step_edit_device(self, user_input=None): + """Handle editing a device.""" + self.editing_device = True + # Use cache if available or fallback to manual discovery + errors = {} + if user_input is not None: + self.selected_device = user_input[SELECTED_DEVICE] + dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device] + self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings()) + self.entities = dev_conf[CONF_ENTITIES] + + return await self.async_step_configure_device() + + devices = {} + for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items(): + devices[dev_id] = configured_dev[CONF_HOST] + + return self.async_show_form( + step_id="edit_device", + data_schema=devices_schema( + devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False + ), + errors=errors, + ) + + async def async_step_configure_device(self, user_input=None): + """Handle input of basic info.""" + errors = {} + dev_id = self.selected_device + if user_input is not None: + try: + self.device_data = user_input.copy() + if dev_id is not None: + # self.device_data[CONF_PRODUCT_KEY] = self.devices[ + # self.selected_device + # ]["productKey"] + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list + if dev_id in cloud_devs: + self.device_data[CONF_MODEL] = cloud_devs[dev_id].get( + CONF_PRODUCT_NAME + ) + if self.editing_device: + self.device_data.update( + { + CONF_DEVICE_ID: dev_id, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: [], + } + ) + if user_input[CONF_ENTITIES]: + entity_ids = [ + int(entity.split(":")[0]) + for entity in user_input[CONF_ENTITIES] + ] + device_config = self.config_entry.data[CONF_DEVICES][dev_id] + self.entities = [ + entity + for entity in device_config[CONF_ENTITIES] + if entity[CONF_ID] in entity_ids + ] + return await self.async_step_configure_entity() + + self.dps_strings = await validate_input(self.hass, user_input) + return await self.async_step_pick_entity_type() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except EmptyDpsList: + errors["base"] = "empty_dps" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + 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) + placeholders = {"for_device": f" for device `{dev_id}`"} + else: + defaults[CONF_PROTOCOL_VERSION] = "3.3" + defaults[CONF_HOST] = "" + defaults[CONF_DEVICE_ID] = "" + defaults[CONF_LOCAL_KEY] = "" + defaults[CONF_FRIENDLY_NAME] = "" + if dev_id is not None: + # Insert default values from discovery and cloud if present + device = self.discovered_devices[dev_id] + defaults[CONF_HOST] = device.get("ip") + defaults[CONF_DEVICE_ID] = device.get("gwId") + defaults[CONF_PROTOCOL_VERSION] = device.get("version") + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list + 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) + + placeholders = {"for_device": ""} + + return self.async_show_form( + step_id="configure_device", + data_schema=schema, + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_pick_entity_type(self, user_input=None): + """Handle asking if user wants to add another entity.""" + if user_input is not None: + if user_input.get(NO_ADDITIONAL_ENTITIES): + config = { + **self.device_data, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: self.entities, + } + + dev_id = self.device_data.get(CONF_DEVICE_ID) + if dev_id in self.config_entry.data[CONF_DEVICES]: + self.hass.config_entries.async_update_entry( + self.config_entry, data=config + ) + return self.async_abort( + reason="device_success", + description_placeholders={ + "dev_name": config.get(CONF_FRIENDLY_NAME), + "action": "updated", + }, + ) + + new_data = self.config_entry.data.copy() + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + new_data[CONF_DEVICES].update({dev_id: config}) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry(title="", data={}) + + self.selected_platform = user_input[PLATFORM_TO_ADD] + return await self.async_step_configure_entity() + + # Add a checkbox that allows bailing out from config flow if at least one + # entity has been added + schema = PICK_ENTITY_SCHEMA + if self.selected_platform is not None: + schema = schema.extend( + {vol.Required(NO_ADDITIONAL_ENTITIES, default=True): bool} + ) + + return self.async_show_form(step_id="pick_entity_type", data_schema=schema) + + def available_dps_strings(self): + """Return list of DPs use by the device's entities.""" + available_dps = [] + used_dps = [str(entity[CONF_ID]) for entity in self.entities] + for dp_string in self.dps_strings: + dp = dp_string.split(" ")[0] + if dp not in used_dps: + available_dps.append(dp_string) + return available_dps + + async def async_step_entity(self, user_input=None): + """Manage entity settings.""" + errors = {} + if user_input is not None: + entity = strip_dps_values(user_input, self.dps_strings) + entity[CONF_ID] = self.current_entity[CONF_ID] + entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] + self.device_data[CONF_ENTITIES].append(entity) + + if len(self.entities) == len(self.device_data[CONF_ENTITIES]): + self.hass.config_entries.async_update_entry( + self.config_entry, + title=self.device_data[CONF_FRIENDLY_NAME], + data=self.device_data, + ) + return self.async_create_entry(title="", data={}) + + schema = platform_schema( + self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + ) + return self.async_show_form( + step_id="entity", + errors=errors, + data_schema=schema_defaults( + schema, self.dps_strings, **self.current_entity + ), + description_placeholders={ + "id": self.current_entity[CONF_ID], + "platform": self.current_entity[CONF_PLATFORM], + }, + ) + + async def async_step_configure_entity(self, user_input=None): + """Manage entity settings.""" + errors = {} + if user_input is not None: + if self.editing_device: + entity = strip_dps_values(user_input, self.dps_strings) + entity[CONF_ID] = self.current_entity[CONF_ID] + entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] + self.device_data[CONF_ENTITIES].append(entity) + + if len(self.entities) == len(self.device_data[CONF_ENTITIES]): + # finished editing device. Let's store the new config entry.... + dev_id = self.device_data[CONF_DEVICE_ID] + new_data = self.config_entry.data.copy() + entry_id = self.config_entry.entry_id + # removing entities from registry (they will be recreated) + ent_reg = await er.async_get_registry(self.hass) + reg_entities = { + ent.unique_id: ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, entry_id) + if dev_id in ent.unique_id + } + for entity_id in reg_entities.values(): + ent_reg.async_remove(entity_id) + + new_data[CONF_DEVICES][dev_id] = self.device_data + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry(title="", data={}) + else: + user_input[CONF_PLATFORM] = self.selected_platform + self.entities.append(strip_dps_values(user_input, self.dps_strings)) + # new entity added. Let's check if there are more left... + user_input = None + if len(self.available_dps_strings()) == 0: + user_input = {NO_ADDITIONAL_ENTITIES: True} + return await self.async_step_pick_entity_type(user_input) + + if self.editing_device: + schema = platform_schema( + self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + ) + schema = schema_defaults(schema, self.dps_strings, **self.current_entity) + placeholders = { + "entity": f"entity with DP {self.current_entity[CONF_ID]}", + "platform": self.current_entity[CONF_PLATFORM], + } + else: + available_dps = self.available_dps_strings() + schema = platform_schema(self.selected_platform, available_dps) + placeholders = { + "entity": "an entity", + "platform": self.selected_platform, + } + + return self.async_show_form( + step_id="configure_entity", + data_schema=schema, + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_yaml_import(self, user_input=None): + """Manage YAML imports.""" + _LOGGER.error( + "Configuration via YAML file is no longer supported by this integration." + ) + # if user_input is not None: + # return self.async_create_entry(title="", data={}) + # return self.async_show_form(step_id="yaml_import") + + @property + def current_entity(self): + """Existing configuration for entity currently being edited.""" + return self.entities[len(self.device_data[CONF_ENTITIES])] + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class EmptyDpsList(exceptions.HomeAssistantError): + """Error to indicate no datapoints found.""" diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py new file mode 100644 index 000000000..c94030432 --- /dev/null +++ b/custom_components/localtuya/const.py @@ -0,0 +1,115 @@ +"""Constants for localtuya integration.""" + +DOMAIN = "localtuya" + +DATA_DISCOVERY = "discovery" +DATA_CLOUD = "cloud_data" + +# Platforms in this list must support config flows +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "number", + "select", + "sensor", + "switch", + "vacuum", +] + +TUYA_DEVICES = "tuya_devices" + +ATTR_CURRENT = "current" +ATTR_CURRENT_CONSUMPTION = "current_consumption" +ATTR_VOLTAGE = "voltage" +ATTR_UPDATED_AT = "updated_at" + +# config flow +CONF_LOCAL_KEY = "local_key" +CONF_PROTOCOL_VERSION = "protocol_version" +CONF_DPS_STRINGS = "dps_strings" +CONF_MODEL = "model" +CONF_PRODUCT_KEY = "product_key" +CONF_PRODUCT_NAME = "product_name" +CONF_USER_ID = "user_id" + +CONF_ACTION = "action" +CONF_ADD_DEVICE = "add_device" +CONF_EDIT_DEVICE = "edit_device" +CONF_SETUP_CLOUD = "setup_cloud" +CONF_NO_CLOUD = "no_cloud" + +# light +CONF_BRIGHTNESS_LOWER = "brightness_lower" +CONF_BRIGHTNESS_UPPER = "brightness_upper" +CONF_COLOR = "color" +CONF_COLOR_MODE = "color_mode" +CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin" +CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin" +CONF_COLOR_TEMP_REVERSE = "color_temp_reverse" +CONF_MUSIC_MODE = "music_mode" + +# switch +CONF_CURRENT = "current" +CONF_CURRENT_CONSUMPTION = "current_consumption" +CONF_VOLTAGE = "voltage" + +# cover +CONF_COMMANDS_SET = "commands_set" +CONF_POSITIONING_MODE = "positioning_mode" +CONF_CURRENT_POSITION_DP = "current_position_dp" +CONF_SET_POSITION_DP = "set_position_dp" +CONF_POSITION_INVERTED = "position_inverted" +CONF_SPAN_TIME = "span_time" + +# fan +CONF_FAN_SPEED_CONTROL = "fan_speed_control" +CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" +CONF_FAN_SPEED_MIN = "fan_speed_min" +CONF_FAN_SPEED_MAX = "fan_speed_max" +CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" +CONF_FAN_DIRECTION = "fan_direction" +CONF_FAN_DIRECTION_FWD = "fan_direction_forward" +CONF_FAN_DIRECTION_REV = "fan_direction_reverse" + +# sensor +CONF_SCALING = "scaling" + +# climate +CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp" +CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp" +CONF_TEMPERATURE_STEP = "temperature_step" +CONF_MAX_TEMP_DP = "max_temperature_dp" +CONF_MIN_TEMP_DP = "min_temperature_dp" +CONF_PRECISION = "precision" +CONF_TARGET_PRECISION = "target_precision" +CONF_HVAC_MODE_DP = "hvac_mode_dp" +CONF_HVAC_MODE_SET = "hvac_mode_set" +CONF_PRESET_DP = "preset_dp" +CONF_PRESET_SET = "preset_set" +CONF_HEURISTIC_ACTION = "heuristic_action" +CONF_HVAC_ACTION_DP = "hvac_action_dp" +CONF_HVAC_ACTION_SET = "hvac_action_set" +CONF_ECO_DP = "eco_dp" +CONF_ECO_VALUE = "eco_value" + +# vacuum +CONF_POWERGO_DP = "powergo_dp" +CONF_IDLE_STATUS_VALUE = "idle_status_value" +CONF_RETURNING_STATUS_VALUE = "returning_status_value" +CONF_DOCKED_STATUS_VALUE = "docked_status_value" +CONF_BATTERY_DP = "battery_dp" +CONF_MODE_DP = "mode_dp" +CONF_MODES = "modes" +CONF_FAN_SPEED_DP = "fan_speed_dp" +CONF_FAN_SPEEDS = "fan_speeds" +CONF_CLEAN_TIME_DP = "clean_time_dp" +CONF_CLEAN_AREA_DP = "clean_area_dp" +CONF_CLEAN_RECORD_DP = "clean_record_dp" +CONF_LOCATE_DP = "locate_dp" +CONF_FAULT_DP = "fault_dp" +CONF_PAUSED_STATE = "paused_state" +CONF_RETURN_MODE = "return_mode" +CONF_STOP_STATUS = "stop_status" diff --git a/custom_components/localtuya/cover.py b/custom_components/localtuya/cover.py new file mode 100644 index 000000000..2a3eb8bdc --- /dev/null +++ b/custom_components/localtuya/cover.py @@ -0,0 +1,232 @@ +"""Platform to locally control Tuya-based cover devices.""" +import asyncio +import logging +import time +from functools import partial + +import voluptuous as vol +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_COMMANDS_SET, + CONF_CURRENT_POSITION_DP, + CONF_POSITION_INVERTED, + CONF_POSITIONING_MODE, + CONF_SET_POSITION_DP, + CONF_SPAN_TIME, +) + +_LOGGER = logging.getLogger(__name__) + +COVER_ONOFF_CMDS = "on_off_stop" +COVER_OPENCLOSE_CMDS = "open_close_stop" +COVER_FZZZ_CMDS = "fz_zz_stop" +COVER_12_CMDS = "1_2_3" +COVER_MODE_NONE = "none" +COVER_MODE_POSITION = "position" +COVER_MODE_TIMED = "timed" +COVER_TIMEOUT_TOLERANCE = 3.0 + +DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS +DEFAULT_POSITIONING_MODE = COVER_MODE_NONE +DEFAULT_SPAN_TIME = 25.0 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_COMMANDS_SET): vol.In( + [COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS] + ), + vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In( + [COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED] + ), + vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps), + vol.Optional(CONF_SET_POSITION_DP): vol.In(dps), + vol.Optional(CONF_POSITION_INVERTED, default=False): bool, + vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All( + vol.Coerce(float), vol.Range(min=1.0, max=300.0) + ), + } + + +class LocaltuyaCover(LocalTuyaEntity, CoverEntity): + """Tuya cover device.""" + + def __init__(self, device, config_entry, switchid, **kwargs): + """Initialize a new LocaltuyaCover.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + commands_set = DEFAULT_COMMANDS_SET + if self.has_config(CONF_COMMANDS_SET): + commands_set = self._config[CONF_COMMANDS_SET] + self._open_cmd = commands_set.split("_")[0] + self._close_cmd = commands_set.split("_")[1] + self._stop_cmd = commands_set.split("_")[2] + self._timer_start = time.time() + self._state = self._stop_cmd + self._previous_state = self._state + self._current_cover_position = 0 + _LOGGER.debug("Initialized cover [%s]", self.name) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE: + supported_features = supported_features | SUPPORT_SET_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return current cover position in percent.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: + return None + return self._current_cover_position + + @property + def is_opening(self): + """Return if cover is opening.""" + state = self._state + return state == self._open_cmd + + @property + def is_closing(self): + """Return if cover is closing.""" + state = self._state + return state == self._close_cmd + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: + return None + + if self._current_cover_position == 0: + return True + if self._current_cover_position == 100: + return False + return None + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.debug("Setting cover position: %r", kwargs[ATTR_POSITION]) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + newpos = float(kwargs[ATTR_POSITION]) + + currpos = self.current_cover_position + posdiff = abs(newpos - currpos) + mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME] + if newpos > currpos: + self.debug("Opening to %f: delay %f", newpos, mydelay) + await self.async_open_cover() + else: + self.debug("Closing to %f: delay %f", newpos, mydelay) + await self.async_close_cover() + self.hass.async_create_task(self.async_stop_after_timeout(mydelay)) + self.debug("Done") + + elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION: + converted_position = int(kwargs[ATTR_POSITION]) + if self._config[CONF_POSITION_INVERTED]: + converted_position = 100 - converted_position + + if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP): + await self._device.set_dp( + converted_position, self._config[CONF_SET_POSITION_DP] + ) + + async def async_stop_after_timeout(self, delay_sec): + """Stop the cover if timeout (max movement span) occurred.""" + await asyncio.sleep(delay_sec) + await self.async_stop_cover() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + self.debug("Launching command %s to cover ", self._open_cmd) + await self._device.set_dp(self._open_cmd, self._dp_id) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self.hass.async_create_task( + self.async_stop_after_timeout( + self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + self.debug("Launching command %s to cover ", self._close_cmd) + await self._device.set_dp(self._close_cmd, self._dp_id) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self.hass.async_create_task( + self.async_stop_after_timeout( + self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + self.debug("Launching command %s to cover ", self._stop_cmd) + await self._device.set_dp(self._stop_cmd, self._dp_id) + + def status_restored(self, stored_state): + """Restore the last stored cover status.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + stored_pos = stored_state.attributes.get("current_position") + if stored_pos is not None: + self._current_cover_position = stored_pos + self.debug("Restored cover position %s", self._current_cover_position) + + def status_updated(self): + """Device status was updated.""" + self._previous_state = self._state + self._state = self.dps(self._dp_id) + if self._state.isupper(): + self._open_cmd = self._open_cmd.upper() + self._close_cmd = self._close_cmd.upper() + self._stop_cmd = self._stop_cmd.upper() + + if self.has_config(CONF_CURRENT_POSITION_DP): + curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP) + if self._config[CONF_POSITION_INVERTED]: + self._current_cover_position = 100 - curr_pos + else: + self._current_cover_position = curr_pos + if ( + self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED + and self._state != self._previous_state + ): + if self._previous_state != self._stop_cmd: + # the state has changed, and the cover was moving + time_diff = time.time() - self._timer_start + pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0) + if self._previous_state == self._close_cmd: + pos_diff = -pos_diff + self._current_cover_position = min( + 100, max(0, self._current_cover_position + pos_diff) + ) + + change = "stopped" if self._state == self._stop_cmd else "inverted" + self.debug( + "Movement %s after %s sec., position difference %s", + change, + time_diff, + pos_diff, + ) + + # store the time of the last movement change + self._timer_start = time.time() + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) diff --git a/custom_components/localtuya/diagnostics.py b/custom_components/localtuya/diagnostics.py new file mode 100644 index 000000000..9c84a931e --- /dev/null +++ b/custom_components/localtuya/diagnostics.py @@ -0,0 +1,65 @@ +"""Diagnostics support for LocalTuya.""" +from __future__ import annotations + +import copy +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN + +CLOUD_DEVICES = "cloud_devices" +DEVICE_CONFIG = "device_config" +DEVICE_CLOUD_INFO = "device_cloud_info" + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = {} + data = dict(entry.data) + tuya_api = hass.data[DOMAIN][DATA_CLOUD] + # censoring private information on integration diagnostic data + for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + data[field] = f"{data[field][0:3]}...{data[field][-3:]}" + data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES]) + for dev_id, dev in data[CONF_DEVICES].items(): + local_key = dev[CONF_LOCAL_KEY] + local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" + dev[CONF_LOCAL_KEY] = local_key_obfuscated + data[CLOUD_DEVICES] = tuya_api.device_list + for dev_id, dev in data[CLOUD_DEVICES].items(): + local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] + local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" + data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + data = {} + dev_id = list(device.identifiers)[0][1].split("_")[-1] + data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy() + # NOT censoring private information on device diagnostic data + # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY] + # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}" + + tuya_api = hass.data[DOMAIN][DATA_CLOUD] + if dev_id in tuya_api.device_list: + data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id] + # NOT censoring private information on device diagnostic data + # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] + # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}" + # data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated + + # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log() + return data diff --git a/custom_components/localtuya/discovery.py b/custom_components/localtuya/discovery.py new file mode 100644 index 000000000..d18e376f2 --- /dev/null +++ b/custom_components/localtuya/discovery.py @@ -0,0 +1,90 @@ +"""Discovery module for Tuya devices. + +Entirely based on tuya-convert.py from tuya-convert: + +https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py +""" +import asyncio +import json +import logging +from hashlib import md5 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +_LOGGER = logging.getLogger(__name__) + +UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() + +DEFAULT_TIMEOUT = 6.0 + + +def decrypt_udp(message): + """Decrypt encrypted UDP broadcasts.""" + + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend()) + decryptor = cipher.decryptor() + return _unpad(decryptor.update(message) + decryptor.finalize()).decode() + + +class TuyaDiscovery(asyncio.DatagramProtocol): + """Datagram handler listening for Tuya broadcast messages.""" + + def __init__(self, callback=None): + """Initialize a new BaseDiscovery.""" + self.devices = {} + self._listeners = [] + self._callback = callback + + async def start(self): + """Start discovery by listening to broadcasts.""" + loop = asyncio.get_running_loop() + listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6666) + ) + encrypted_listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6667) + ) + + self._listeners = await asyncio.gather(listener, encrypted_listener) + _LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667") + + def close(self): + """Stop discovery.""" + self._callback = None + for transport, _ in self._listeners: + transport.close() + + def datagram_received(self, data, addr): + """Handle received broadcast message.""" + data = data[20:-8] + try: + data = decrypt_udp(data) + except Exception: # pylint: disable=broad-except + data = data.decode() + + decoded = json.loads(data) + self.device_found(decoded) + + def device_found(self, device): + """Discover a new device.""" + if device.get("gwId") not in self.devices: + self.devices[device.get("gwId")] = device + _LOGGER.debug("Discovered device: %s", device) + + if self._callback: + self._callback(device) + + +async def discover(): + """Discover and return devices on local network.""" + discovery = TuyaDiscovery() + try: + await discovery.start() + await asyncio.sleep(DEFAULT_TIMEOUT) + finally: + discovery.close() + return discovery.devices diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py new file mode 100644 index 000000000..d2b458351 --- /dev/null +++ b/custom_components/localtuya/fan.py @@ -0,0 +1,255 @@ +"""Platform to locally control Tuya-based fan devices.""" +import logging +import math +from functools import partial + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.util.percentage import ( + int_states_in_range, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_FAN_DIRECTION, + CONF_FAN_DIRECTION_FWD, + CONF_FAN_DIRECTION_REV, + CONF_FAN_ORDERED_LIST, + CONF_FAN_OSCILLATING_CONTROL, + CONF_FAN_SPEED_CONTROL, + CONF_FAN_SPEED_MAX, + CONF_FAN_SPEED_MIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps), + vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps), + vol.Optional(CONF_FAN_DIRECTION): vol.In(dps), + vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string, + vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string, + vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int, + vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int, + vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string, + } + + +class LocaltuyaFan(LocalTuyaEntity, FanEntity): + """Representation of a Tuya fan.""" + + def __init__( + self, + device, + config_entry, + fanid, + **kwargs, + ): + """Initialize the entity.""" + super().__init__(device, config_entry, fanid, _LOGGER, **kwargs) + self._is_on = False + self._oscillating = None + self._direction = None + self._percentage = None + self._speed_range = ( + self._config.get(CONF_FAN_SPEED_MIN), + self._config.get(CONF_FAN_SPEED_MAX), + ) + self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") + self._ordered_list_mode = None + + if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: + self._use_ordered_list = True + _LOGGER.debug( + "Fan _use_ordered_list: %s > %s", + self._use_ordered_list, + self._ordered_list, + ) + else: + self._use_ordered_list = False + _LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list) + + @property + def oscillating(self): + """Return current oscillating status.""" + return self._oscillating + + @property + def current_direction(self): + """Return the current direction of the fan.""" + return self._direction + + @property + def is_on(self): + """Check if Tuya fan is on.""" + return self._is_on + + @property + def percentage(self): + """Return the current percentage.""" + return self._percentage + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + _LOGGER.debug("Fan async_turn_on") + await self._device.set_dp(True, self._dp_id) + if percentage is not None: + await self.async_set_percentage(percentage) + else: + self.schedule_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + _LOGGER.debug("Fan async_turn_off") + + await self._device.set_dp(False, self._dp_id) + self.schedule_update_ha_state() + + async def async_set_percentage(self, percentage): + """Set the speed of the fan.""" + _LOGGER.debug("Fan async_set_percentage: %s", percentage) + + if percentage is not None: + if percentage == 0: + return await self.async_turn_off() + if not self.is_on: + await self.async_turn_on() + if self._use_ordered_list: + await self._device.set_dp( + str( + percentage_to_ordered_list_item(self._ordered_list, percentage) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ordered_list_item(self._ordered_list, percentage), + ) + + else: + await self._device.set_dp( + str( + math.ceil( + percentage_to_ranged_value(self._speed_range, percentage) + ) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ranged_value(self._speed_range, percentage), + ) + self.schedule_update_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + _LOGGER.debug("Fan async_oscillate: %s", oscillating) + await self._device.set_dp( + oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL) + ) + self.schedule_update_ha_state() + + async def async_set_direction(self, direction): + """Set the direction of the fan.""" + _LOGGER.debug("Fan async_set_direction: %s", direction) + + if direction == DIRECTION_FORWARD: + value = self._config.get(CONF_FAN_DIRECTION_FWD) + + if direction == DIRECTION_REVERSE: + value = self._config.get(CONF_FAN_DIRECTION_REV) + await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION)) + self.schedule_update_ha_state() + + @property + def supported_features(self) -> int: + """Flag supported features.""" + features = 0 + + if self.has_config(CONF_FAN_OSCILLATING_CONTROL): + features |= SUPPORT_OSCILLATE + + if self.has_config(CONF_FAN_SPEED_CONTROL): + features |= SUPPORT_SET_SPEED + + if self.has_config(CONF_FAN_DIRECTION): + features |= SUPPORT_DIRECTION + + return features + + @property + def speed_count(self) -> int: + """Speed count for the fan.""" + speed_count = int_states_in_range(self._speed_range) + _LOGGER.debug("Fan speed_count: %s", speed_count) + return speed_count + + def status_updated(self): + """Get state of Tuya fan.""" + self._is_on = self.dps(self._dp_id) + + current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL) + if self._use_ordered_list: + _LOGGER.debug( + "Fan current_speed ordered_list_item_to_percentage: %s from %s", + current_speed, + self._ordered_list, + ) + if current_speed is not None: + self._percentage = ordered_list_item_to_percentage( + self._ordered_list, current_speed + ) + + else: + _LOGGER.debug( + "Fan current_speed ranged_value_to_percentage: %s from %s", + current_speed, + self._speed_range, + ) + if current_speed is not None: + self._percentage = ranged_value_to_percentage( + self._speed_range, int(current_speed) + ) + + _LOGGER.debug("Fan current_percentage: %s", self._percentage) + + if self.has_config(CONF_FAN_OSCILLATING_CONTROL): + self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL) + _LOGGER.debug("Fan current_oscillating : %s", self._oscillating) + + if self.has_config(CONF_FAN_DIRECTION): + value = self.dps_conf(CONF_FAN_DIRECTION) + if value is not None: + if value == self._config.get(CONF_FAN_DIRECTION_FWD): + self._direction = DIRECTION_FORWARD + + if value == self._config.get(CONF_FAN_DIRECTION_REV): + self._direction = DIRECTION_REVERSE + _LOGGER.debug("Fan current_direction : %s > %s", value, self._direction) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py new file mode 100644 index 000000000..7c74e49f5 --- /dev/null +++ b/custom_components/localtuya/light.py @@ -0,0 +1,447 @@ +"""Platform to locally control Tuya-based light devices.""" +import logging +import textwrap +from functools import partial + +import homeassistant.util.color as color_util +import voluptuous as vol +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + LightEntity, +) +from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_BRIGHTNESS_LOWER, + CONF_BRIGHTNESS_UPPER, + CONF_COLOR, + CONF_COLOR_MODE, + CONF_COLOR_TEMP_MAX_KELVIN, + CONF_COLOR_TEMP_MIN_KELVIN, + CONF_COLOR_TEMP_REVERSE, + CONF_MUSIC_MODE, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_MIN_KELVIN = 2700 # MIRED 370 +DEFAULT_MAX_KELVIN = 6500 # MIRED 153 + +DEFAULT_COLOR_TEMP_REVERSE = False + +DEFAULT_LOWER_BRIGHTNESS = 29 +DEFAULT_UPPER_BRIGHTNESS = 1000 + +MODE_COLOR = "colour" +MODE_MUSIC = "music" +MODE_SCENE = "scene" +MODE_WHITE = "white" + +SCENE_CUSTOM = "Custom" +SCENE_MUSIC = "Music" + +SCENE_LIST_RGBW_1000 = { + "Night": "000e0d0000000000000000c80000", + "Read": "010e0d0000000000000003e801f4", + "Meeting": "020e0d0000000000000003e803e8", + "Leasure": "030e0d0000000000000001f401f4", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" + + "e800000000", + "Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8" + + "00000000", + "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + + "3e800000000", +} + +SCENE_LIST_RGBW_255 = { + "Night": "bd76000168ffff", + "Read": "fffcf70168ffff", + "Meeting": "cf38000168ffff", + "Leasure": "3855b40168ffff", + "Scenario 1": "scene_1", + "Scenario 2": "scene_2", + "Scenario 3": "scene_3", + "Scenario 4": "scene_4", +} + +SCENE_LIST_RGB_1000 = { + "Night": "000e0d00002e03e802cc00000000", + "Read": "010e0d000084000003e800000000", + "Working": "020e0d00001403e803e800000000", + "Leisure": "030e0d0000e80383031c00000000", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + + "e800000000", + "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000", + "Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" + + "0000000", +} + + +def map_range(value, from_lower, from_upper, to_lower, to_upper): + """Map a value in one range to another.""" + mapped = (value - from_lower) * (to_upper - to_lower) / ( + from_upper - from_lower + ) + to_lower + return round(min(max(mapped, to_lower), to_upper)) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_BRIGHTNESS): vol.In(dps), + vol.Optional(CONF_COLOR_TEMP): vol.In(dps), + vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10000) + ), + vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10000) + ), + vol.Optional(CONF_COLOR_MODE): vol.In(dps), + vol.Optional(CONF_COLOR): vol.In(dps), + vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1500, max=8000) + ), + vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1500, max=8000) + ), + vol.Optional( + CONF_COLOR_TEMP_REVERSE, + default=DEFAULT_COLOR_TEMP_REVERSE, + description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE}, + ): bool, + vol.Optional(CONF_SCENE): vol.In(dps), + vol.Optional( + CONF_MUSIC_MODE, default=False, description={"suggested_value": False} + ): bool, + } + + +class LocaltuyaLight(LocalTuyaEntity, LightEntity): + """Representation of a Tuya light.""" + + def __init__( + self, + device, + config_entry, + lightid, + **kwargs, + ): + """Initialize the Tuya light.""" + super().__init__(device, config_entry, lightid, _LOGGER, **kwargs) + self._state = False + self._brightness = None + self._color_temp = None + self._lower_brightness = self._config.get( + CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS + ) + self._upper_brightness = self._config.get( + CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS + ) + self._upper_color_temp = self._upper_brightness + self._max_mired = color_util.color_temperature_kelvin_to_mired( + self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._min_mired = color_util.color_temperature_kelvin_to_mired( + self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) + self._color_temp_reverse = self._config.get( + CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE + ) + self._hs = None + self._effect = None + self._effect_list = [] + self._scenes = None + if self.has_config(CONF_SCENE): + if self._config.get(CONF_SCENE) < 20: + self._scenes = SCENE_LIST_RGBW_255 + elif self._config.get(CONF_BRIGHTNESS) is None: + self._scenes = SCENE_LIST_RGB_1000 + else: + self._scenes = SCENE_LIST_RGBW_1000 + self._effect_list = list(self._scenes.keys()) + if self._config.get(CONF_MUSIC_MODE): + self._effect_list.append(SCENE_MUSIC) + + @property + def is_on(self): + """Check if Tuya light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of the light.""" + if self.is_color_mode or self.is_white_mode: + return map_range( + self._brightness, self._lower_brightness, self._upper_brightness, 0, 255 + ) + return None + + @property + def hs_color(self): + """Return the hs color value.""" + if self.is_color_mode: + return self._hs + if ( + self.supported_features & SUPPORT_COLOR + and not self.supported_features & SUPPORT_COLOR_TEMP + ): + return [0, 0] + return None + + @property + def color_temp(self): + """Return the color_temp of the light.""" + if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode: + color_temp_value = ( + self._upper_color_temp - self._color_temp + if self._color_temp_reverse + else self._color_temp + ) + return int( + self._max_mired + - ( + ((self._max_mired - self._min_mired) / self._upper_color_temp) + * color_temp_value + ) + ) + return None + + @property + def min_mireds(self): + """Return color temperature min mireds.""" + return self._min_mired + + @property + def max_mireds(self): + """Return color temperature max mireds.""" + return self._max_mired + + @property + def effect(self): + """Return the current effect for this light.""" + if self.is_scene_mode or self.is_music_mode: + return self._effect + return None + + @property + def effect_list(self): + """Return the list of supported effects for this light.""" + return self._effect_list + + @property + def supported_features(self): + """Flag supported features.""" + supports = 0 + if self.has_config(CONF_BRIGHTNESS): + supports |= SUPPORT_BRIGHTNESS + if self.has_config(CONF_COLOR_TEMP): + supports |= SUPPORT_COLOR_TEMP + if self.has_config(CONF_COLOR): + supports |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS + if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): + supports |= SUPPORT_EFFECT + return supports + + @property + def is_white_mode(self): + """Return true if the light is in white mode.""" + color_mode = self.__get_color_mode() + return color_mode is None or color_mode == MODE_WHITE + + @property + def is_color_mode(self): + """Return true if the light is in color mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode == MODE_COLOR + + @property + def is_scene_mode(self): + """Return true if the light is in scene mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode.startswith(MODE_SCENE) + + @property + def is_music_mode(self): + """Return true if the light is in music mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode == MODE_MUSIC + + def __is_color_rgb_encoded(self): + return len(self.dps_conf(CONF_COLOR)) > 12 + + def __find_scene_by_scene_data(self, data): + return next( + (item for item in self._effect_list if self._scenes.get(item) == data), + SCENE_CUSTOM, + ) + + def __get_color_mode(self): + return ( + self.dps_conf(CONF_COLOR_MODE) + if self.has_config(CONF_COLOR_MODE) + else MODE_WHITE + ) + + async def async_turn_on(self, **kwargs): + """Turn on or control the light.""" + states = {} + if not self.is_on: + states[self._dp_id] = True + features = self.supported_features + brightness = None + if ATTR_EFFECT in kwargs and (features & SUPPORT_EFFECT): + scene = self._scenes.get(kwargs[ATTR_EFFECT]) + if scene is not None: + if scene.startswith(MODE_SCENE): + states[self._config.get(CONF_COLOR_MODE)] = scene + else: + states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE + states[self._config.get(CONF_SCENE)] = scene + elif kwargs[ATTR_EFFECT] == SCENE_MUSIC: + states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC + + if ATTR_BRIGHTNESS in kwargs and (features & SUPPORT_BRIGHTNESS): + brightness = map_range( + int(kwargs[ATTR_BRIGHTNESS]), + 0, + 255, + self._lower_brightness, + self._upper_brightness, + ) + if self.is_white_mode: + states[self._config.get(CONF_BRIGHTNESS)] = brightness + else: + if self.__is_color_rgb_encoded(): + rgb = color_util.color_hsv_to_RGB( + self._hs[0], + self._hs[1], + int(brightness * 100 / self._upper_brightness), + ) + color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( + round(rgb[0]), + round(rgb[1]), + round(rgb[2]), + round(self._hs[0]), + round(self._hs[1] * 255 / 100), + brightness, + ) + else: + color = "{:04x}{:04x}{:04x}".format( + round(self._hs[0]), round(self._hs[1] * 10.0), brightness + ) + states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR + + if ATTR_HS_COLOR in kwargs and (features & SUPPORT_COLOR): + if brightness is None: + brightness = self._brightness + hs = kwargs[ATTR_HS_COLOR] + if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS): + states[self._config.get(CONF_BRIGHTNESS)] = brightness + states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE + else: + if self.__is_color_rgb_encoded(): + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], int(brightness * 100 / self._upper_brightness) + ) + color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( + round(rgb[0]), + round(rgb[1]), + round(rgb[2]), + round(hs[0]), + round(hs[1] * 255 / 100), + brightness, + ) + else: + color = "{:04x}{:04x}{:04x}".format( + round(hs[0]), round(hs[1] * 10.0), brightness + ) + states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR + + if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP): + if brightness is None: + brightness = self._brightness + mired = int(kwargs[ATTR_COLOR_TEMP]) + if self._color_temp_reverse: + mired = self._max_mired - (mired - self._min_mired) + if mired < self._min_mired: + mired = self._min_mired + elif mired > self._max_mired: + mired = self._max_mired + color_temp = int( + self._upper_color_temp + - (self._upper_color_temp / (self._max_mired - self._min_mired)) + * (mired - self._min_mired) + ) + states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE + states[self._config.get(CONF_BRIGHTNESS)] = brightness + states[self._config.get(CONF_COLOR_TEMP)] = color_temp + await self._device.set_dps(states) + + async def async_turn_off(self, **kwargs): + """Turn Tuya light off.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + supported = self.supported_features + self._effect = None + if supported & SUPPORT_BRIGHTNESS and self.has_config(CONF_BRIGHTNESS): + self._brightness = self.dps_conf(CONF_BRIGHTNESS) + + if supported & SUPPORT_COLOR: + color = self.dps_conf(CONF_COLOR) + if color is not None and not self.is_white_mode: + if self.__is_color_rgb_encoded(): + hue = int(color[6:10], 16) + sat = int(color[10:12], 16) + value = int(color[12:14], 16) + self._hs = [hue, (sat * 100 / 255)] + self._brightness = value + else: + hue, sat, value = [ + int(value, 16) for value in textwrap.wrap(color, 4) + ] + self._hs = [hue, sat / 10.0] + self._brightness = value + + if supported & SUPPORT_COLOR_TEMP: + self._color_temp = self.dps_conf(CONF_COLOR_TEMP) + + if self.is_scene_mode and supported & SUPPORT_EFFECT: + if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE: + self._effect = self.__find_scene_by_scene_data( + self.dps_conf(CONF_COLOR_MODE) + ) + else: + self._effect = self.__find_scene_by_scene_data( + self.dps_conf(CONF_SCENE) + ) + if self._effect == SCENE_CUSTOM: + if SCENE_CUSTOM not in self._effect_list: + self._effect_list.append(SCENE_CUSTOM) + elif SCENE_CUSTOM in self._effect_list: + self._effect_list.remove(SCENE_CUSTOM) + + if self.is_music_mode and supported & SUPPORT_EFFECT: + self._effect = SCENE_MUSIC + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema) diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json new file mode 100644 index 000000000..bb6f2ea40 --- /dev/null +++ b/custom_components/localtuya/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "localtuya", + "name": "LocalTuya integration", + "version": "4.0.0", + "documentation": "https://github.com/rospogrigio/localtuya/", + "dependencies": [], + "codeowners": [ + "@rospogrigio", "@postlund" + ], + "issue_tracker": "https://github.com/rospogrigio/localtuya/issues", + "requirements": [], + "config_flow": true, + "iot_class": "local_push" +} diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py new file mode 100644 index 000000000..596eb01eb --- /dev/null +++ b/custom_components/localtuya/number.py @@ -0,0 +1,84 @@ +"""Platform to present any Tuya DP as a number.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.number import DOMAIN, NumberEntity +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_MIN_VALUE = "min_value" +CONF_MAX_VALUE = "max_value" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 100000 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All( + vol.Coerce(float), + vol.Range(min=-1000000.0, max=1000000.0), + ), + vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All( + vol.Coerce(float), + vol.Range(min=-1000000.0, max=1000000.0), + ), + } + + +class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): + """Representation of a Tuya Number.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + + self._min_value = DEFAULT_MIN + if CONF_MIN_VALUE in self._config: + self._min_value = self._config.get(CONF_MIN_VALUE) + + self._max_value = self._config.get(CONF_MAX_VALUE) + + @property + def value(self) -> float: + """Return sensor state.""" + return self._state + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._min_value + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._max_value + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + async def async_set_value(self, value: float) -> None: + """Update the current value.""" + await self._device.set_dp(value, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py new file mode 100644 index 000000000..b7645ec25 --- /dev/null +++ b/custom_components/localtuya/pytuya/__init__.py @@ -0,0 +1,682 @@ +# PyTuya Module +# -*- coding: utf-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 + +For more information see https://github.com/clach04/python-tuya + +Classes + TuyaInterface(dev_id, address, local_key=None) + dev_id (str): Device ID e.g. 01234567891234567890 + address (str): Device Network IP Address e.g. 10.0.1.99 + local_key (str, optional): The encryption key. Defaults to None. + +Functions + json = status() # returns json payload + set_version(version) # 3.1 [default] or 3.3 + 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 + # device (to be queried in the payload) + 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 +""" + +import asyncio +import base64 +import binascii +import json +import logging +import struct +import time +import weakref +from abc import ABC, abstractmethod +from collections import namedtuple +from hashlib import md5 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +version_tuple = (9, 0, 0) +version = version_string = __version__ = "%d.%d.%d" % version_tuple +__author__ = "postlund" + +_LOGGER = logging.getLogger(__name__) + +TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") + +SET = "set" +STATUS = "status" +HEARTBEAT = "heartbeat" +UPDATEDPS = "updatedps" # Request refresh of DPS + +PROTOCOL_VERSION_BYTES_31 = b"3.1" +PROTOCOL_VERSION_BYTES_33 = b"3.3" + +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + 12 * b"\x00" + +MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length +MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode +MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix + +PREFIX_VALUE = 0x000055AA +SUFFIX_VALUE = 0x0000AA55 + +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) + +# 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) + +# 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 = { + "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]}}, + }, + "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]}}, + }, +} + + +class TuyaLoggingAdapter(logging.LoggerAdapter): + """Adapter that adds device id to all log points.""" + + def process(self, msg, kwargs): + """Process log point and return output.""" + dev_id = self.extra["device_id"] + return f"[{dev_id[0:3]}...{dev_id[-3:]}] {msg}", kwargs + + +class ContextualLogger: + """Contextual logger adding device id to log points.""" + + def __init__(self): + """Initialize a new ContextualLogger.""" + self._logger = None + + def set_logger(self, logger, device_id): + """Set base logger to use.""" + self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id}) + + def debug(self, msg, *args): + """Debug level log.""" + return self._logger.log(logging.DEBUG, msg, *args) + + def info(self, msg, *args): + """Info level log.""" + return self._logger.log(logging.INFO, msg, *args) + + def warning(self, msg, *args): + """Warning method log.""" + return self._logger.log(logging.WARNING, msg, *args) + + def error(self, msg, *args): + """Error level log.""" + return self._logger.log(logging.ERROR, msg, *args) + + def exception(self, msg, *args): + """Exception level log.""" + return self._logger.exception(msg, *args) + + +def pack_message(msg): + """Pack a TuyaMessage into bytes.""" + # Create full message excluding CRC and suffix + buffer = ( + struct.pack( + MESSAGE_HEADER_FMT, + PREFIX_VALUE, + msg.seqno, + msg.cmd, + len(msg.payload) + struct.calcsize(MESSAGE_END_FMT), + ) + + msg.payload + ) + + # Calculate CRC, add it together with suffix + buffer += struct.pack(MESSAGE_END_FMT, binascii.crc32(buffer), SUFFIX_VALUE) + + return buffer + + +def unpack_message(data): + """Unpack bytes into a TuyaMessage.""" + header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) + end_len = struct.calcsize(MESSAGE_END_FMT) + + _, seqno, cmd, _, retcode = struct.unpack( + MESSAGE_RECV_HEADER_FMT, data[:header_len] + ) + payload = data[header_len:-end_len] + crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:]) + return TuyaMessage(seqno, cmd, retcode, payload, crc) + + +class AESCipher: + """Cipher module for Tuya communication.""" + + def __init__(self, key): + """Initialize a new AESCipher.""" + self.block_size = 16 + self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + + def encrypt(self, raw, use_base64=True): + """Encrypt data to be sent to device.""" + encryptor = self.cipher.encryptor() + crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize() + return base64.b64encode(crypted_text) if use_base64 else crypted_text + + def decrypt(self, enc, use_base64=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() + + def _pad(self, data): + padnum = self.block_size - len(data) % self.block_size + return data + padnum * chr(padnum).encode() + + @staticmethod + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + +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. + HEARTBEAT_SEQNO = -100 + + def __init__(self, dev_id, listener): + """Initialize a new MessageBuffer.""" + super().__init__() + self.buffer = b"" + self.listeners = {} + self.listener = listener + self.set_logger(_LOGGER, dev_id) + + def abort(self): + """Abort all waiting clients.""" + for key in self.listeners: + sem = self.listeners[key] + self.listeners[key] = None + + # TODO: Received data and semahore should be stored separately + if isinstance(sem, asyncio.Semaphore): + sem.release() + + async def wait_for(self, seqno, 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.listeners[seqno] = asyncio.Semaphore(0) + try: + await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) + except asyncio.TimeoutError: + del self.listeners[seqno] + raise + + return self.listeners.pop(seqno) + + def add_data(self, data): + """Add new data to the buffer and try to parse messages.""" + self.buffer += data + header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) + + while self.buffer: + # Check if enough data for measage header + 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], + ) + + self.buffer = self.buffer[header_len - 4 + length :] + self._dispatch(TuyaMessage(seqno, cmd, retcode, payload, crc)) + + def _dispatch(self, msg): + """Dispatch a message to someone that is listening.""" + self.debug("Dispatching message %s", msg) + if msg.seqno in self.listeners: + self.debug("Dispatching sequence number %d", msg.seqno) + sem = self.listeners[msg.seqno] + self.listeners[msg.seqno] = msg + sem.release() + elif msg.cmd == 0x09: + 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: + self.debug("Got normal updatedps response") + elif msg.cmd == 0x08: + 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, + ) + + +class TuyaListener(ABC): + """Listener interface for Tuya device changes.""" + + @abstractmethod + def status_updated(self, status): + """Device updated status.""" + + @abstractmethod + def disconnected(self): + """Device disconnected.""" + + +class EmptyListener(TuyaListener): + """Listener doing nothing.""" + + def status_updated(self, status): + """Device updated status.""" + + def disconnected(self): + """Device disconnected.""" + + +class TuyaProtocol(asyncio.Protocol, ContextualLogger): + """Implementation of the Tuya protocol.""" + + def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): + """ + Initialize a new TuyaInterface. + + Args: + dev_id (str): The device id. + address (str): The network address. + local_key (str, optional): The encryption key. Defaults to None. + + Attributes: + port (int): The port to connect to. + """ + super().__init__() + self.loop = asyncio.get_running_loop() + self.set_logger(_LOGGER, dev_id) + self.id = dev_id + self.local_key = local_key.encode("latin1") + self.version = protocol_version + self.dev_type = "type_0a" + self.dps_to_request = {} + self.cipher = AESCipher(self.local_key) + self.seqno = 0 + self.transport = None + self.listener = weakref.ref(listener) + self.dispatcher = self._setup_dispatcher() + self.on_connected = on_connected + self.heartbeater = None + self.dps_cache = {} + + def _setup_dispatcher(self): + def _status_update(msg): + decoded_message = self._decode_payload(msg.payload) + if "dps" in decoded_message: + self.dps_cache.update(decoded_message["dps"]) + + listener = self.listener and self.listener() + if listener is not None: + listener.status_updated(self.dps_cache) + + return MessageDispatcher(self.id, _status_update) + + def connection_made(self, transport): + """Did connect to the device.""" + + async def heartbeat_loop(): + """Continuously send heart beat updates.""" + self.debug("Started heartbeat loop") + while True: + try: + await self.heartbeat() + await asyncio.sleep(HEARTBEAT_INTERVAL) + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + raise + except asyncio.TimeoutError: + self.debug("Heartbeat failed due to timeout, disconnecting") + break + except Exception as ex: # pylint: disable=broad-except + self.exception("Heartbeat failed (%s), disconnecting", ex) + break + + transport = self.transport + self.transport = None + transport.close() + + self.transport = transport + self.on_connected.set_result(True) + self.heartbeater = self.loop.create_task(heartbeat_loop()) + + def data_received(self, data): + """Received data from device.""" + self.dispatcher.add_data(data) + + def connection_lost(self, exc): + """Disconnected from device.""" + self.debug("Connection lost: %s", exc) + try: + listener = self.listener and self.listener() + if listener is not None: + listener.disconnected() + except Exception: # pylint: disable=broad-except + self.exception("Failed to call disconnected callback") + + async def close(self): + """Close connection and abort all outstanding listeners.""" + self.debug("Closing connection") + if self.heartbeater is not None: + self.heartbeater.cancel() + try: + await self.heartbeater + except asyncio.CancelledError: + pass + self.heartbeater = None + if self.dispatcher is not None: + self.dispatcher.abort() + self.dispatcher = None + if self.transport is not None: + transport = self.transport + self.transport = None + transport.close() + + async def exchange(self, command, dps=None): + """Send and receive a message, returning response from device.""" + self.debug( + "Sending command %s (device type: %s)", + command, + self.dev_type, + ) + payload = self._generate_payload(command, dps) + dev_type = self.dev_type + + # Wait for special sequence number if heartbeat + seqno = ( + MessageDispatcher.HEARTBEAT_SEQNO + if command == HEARTBEAT + else (self.seqno - 1) + ) + + self.transport.write(payload) + msg = await self.dispatcher.wait_for(seqno) + if msg is None: + self.debug("Wait was aborted for seqno %d", seqno) + return None + + # TODO: Verify stuff, e.g. CRC sequence number? + payload = self._decode_payload(msg.payload) + + # Perform a new exchange (once) if we switched device type + if dev_type != self.dev_type: + self.debug( + "Re-send %s due to device type change (%s -> %s)", + command, + dev_type, + self.dev_type, + ) + return await self.exchange(command, dps) + return payload + + async def status(self): + """Return device status.""" + status = await self.exchange(STATUS) + 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) + + async def update_dps(self, dps=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version == 3.3: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps() + if self.dps_cache: + dps = [int(dp) for dp in self.dps_cache] + # filter non whitelisted dps + 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) + return True + + async def set_dp(self, value, dp_index): + """ + Set value (may be any type: bool, int or string) of any dps index. + + Args: + dp_index(int): dps index to set + value: new value for the dps index + """ + return await self.exchange(SET, {str(dp_index): value}) + + async def set_dps(self, dps): + """Set values for a set of datapoints.""" + return await self.exchange(SET, dps) + + async def detect_available_dps(self): + """Return which datapoints are supported by the device.""" + # type_0d devices need a sort of bruteforce querying in order to detect the + # list of available dps experience shows that the dps available are usually + # in the ranges [1-25] and [100-110] need to split the bruteforcing in + # different steps due to request payload limitation (max. length = 255) + self.dps_cache = {} + ranges = [(2, 11), (11, 21), (21, 31), (100, 111)] + + for dps_range in ranges: + # dps 1 must always be sent, otherwise it might fail in case no dps is found + # in the requested range + self.dps_to_request = {"1": None} + self.add_dps_to_request(range(*dps_range)) + try: + data = await self.status() + except Exception as ex: + self.exception("Failed to get status: %s", ex) + raise + if "dps" in data: + self.dps_cache.update(data["dps"]) + + if self.dev_type == "type_0a": + return self.dps_cache + self.debug("Detected dps: %s", self.dps_cache) + return self.dps_cache + + def add_dps_to_request(self, dp_indicies): + """Add a datapoint (DP) to be included in requests.""" + if isinstance(dp_indicies, int): + self.dps_to_request[str(dp_indicies)] = None + else: + 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) + + if "data unvalid" in payload: + self.dev_type = "type_0d" + self.debug( + "switching to dev_type %s", + self.dev_type, + ) + return None + else: + raise Exception(f"Unexpected payload={payload}") + + if not isinstance(payload, str): + payload = payload.decode() + self.debug("Decrypted payload: %s", payload) + return json.loads(payload) + + def _generate_payload(self, command, data=None): + """ + Generate the payload to send. + + Args: + command(str): The type of command. + 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 + """ + cmd_data = PAYLOAD_DICT[self.dev_type][command] + json_data = cmd_data["command"] + command_hb = cmd_data["hexByte"] + + if "gwId" in json_data: + json_data["gwId"] = self.id + if "devId" in json_data: + json_data["devId"] = self.id + if "uid" in json_data: + json_data["uid"] = self.id # still use id, no separate uid + if "t" in json_data: + json_data["t"] = str(int(time.time())) + + if data is not None: + if "dpId" in json_data: + json_data["dpId"] = data + else: + json_data["dps"] = data + elif command_hb == 0x0D: + 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 + ) + + msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0) + self.seqno += 1 + return pack_message(msg) + + def __repr__(self): + """Return internal string representation of object.""" + return self.id + + +async def connect( + address, + device_id, + local_key, + protocol_version, + listener=None, + port=6668, + timeout=5, +): + """Connect to a device.""" + loop = asyncio.get_running_loop() + on_connected = loop.create_future() + _, protocol = await loop.create_connection( + lambda: TuyaProtocol( + device_id, + local_key, + protocol_version, + on_connected, + listener or EmptyListener(), + ), + address, + port, + ) + + await asyncio.wait_for(on_connected, timeout=timeout) + return protocol diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py new file mode 100644 index 000000000..29d11c916 --- /dev/null +++ b/custom_components/localtuya/select.py @@ -0,0 +1,100 @@ +"""Platform to present any Tuya DP as an enumeration.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.select import DOMAIN, SelectEntity +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_OPTIONS = "select_options" +CONF_OPTIONS_FRIENDLY = "select_options_friendly" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_OPTIONS): str, + vol.Optional(CONF_OPTIONS_FRIENDLY): str, + } + + +class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): + """Representation of a Tuya Enumeration.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + self._state_friendly = "" + self._valid_options = self._config.get(CONF_OPTIONS).split(";") + + # Set Display options + self._display_options = [] + display_options_str = "" + if CONF_OPTIONS_FRIENDLY in self._config: + display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip() + _LOGGER.debug("Display Options Configured: %s", display_options_str) + + if display_options_str.find(";") >= 0: + self._display_options = display_options_str.split(";") + elif len(display_options_str.strip()) > 0: + self._display_options.append(display_options_str) + else: + # Default display string to raw string + _LOGGER.debug("No Display options configured - defaulting to raw values") + self._display_options = self._valid_options + + _LOGGER.debug( + "Total Raw Options: %s - Total Display Options: %s", + str(len(self._valid_options)), + str(len(self._display_options)), + ) + if len(self._valid_options) > len(self._display_options): + # If list of display items smaller than list of valid items, + # then default remaining items to be the raw value + _LOGGER.debug( + "Valid options is larger than display options - \ + filling up with raw values" + ) + for i in range(len(self._display_options), len(self._valid_options)): + self._display_options.append(self._valid_options[i]) + + @property + def current_option(self) -> str: + """Return the current value.""" + return self._state_friendly + + @property + def options(self) -> list: + """Return the list of values.""" + return self._display_options + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + async def async_select_option(self, option: str) -> None: + """Update the current value.""" + option_value = self._valid_options[self._display_options.index(option)] + _LOGGER.debug("Sending Option: " + option + " -> " + option_value) + await self._device.set_dp(option_value, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + self._state_friendly = self._display_options[self._valid_options.index(state)] + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) diff --git a/custom_components/localtuya/sensor.py b/custom_components/localtuya/sensor.py new file mode 100644 index 000000000..c8b2ddb3e --- /dev/null +++ b/custom_components/localtuya/sensor.py @@ -0,0 +1,70 @@ +"""Platform to present any Tuya DP as a sensor.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import CONF_SCALING + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRECISION = 2 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, + vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES), + vol.Optional(CONF_SCALING): vol.All( + vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) + ), + } + + +class LocaltuyaSensor(LocalTuyaEntity): + """Representation of a Tuya sensor.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + + @property + def state(self): + """Return sensor state.""" + return self._state + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + scale_factor = self._config.get(CONF_SCALING) + if scale_factor is not None and isinstance(state, (int, float)): + state = round(state * scale_factor, DEFAULT_PRECISION) + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema) diff --git a/custom_components/localtuya/services.yaml b/custom_components/localtuya/services.yaml new file mode 100644 index 000000000..f10af4a63 --- /dev/null +++ b/custom_components/localtuya/services.yaml @@ -0,0 +1,15 @@ +reload: + description: Reload localtuya and reconnect to all devices. + +set_dp: + description: Change the value of a datapoint (DP) + fields: + device_id: + description: Device ID of device to change datapoint value for + example: 11100118278aab4de001 + dp: + description: Datapoint index + example: 1 + value: + description: New value to set + example: False diff --git a/custom_components/localtuya/strings.json b/custom_components/localtuya/strings.json new file mode 100644 index 000000000..4db7e7078 --- /dev/null +++ b/custom_components/localtuya/strings.json @@ -0,0 +1,133 @@ +{ + "config": { + "abort": { + "already_configured": "Device has already been configured.", + "unsupported_device_type": "Unsupported device type!" + }, + "error": { + "cannot_connect": "Cannot connect to device. Verify that address is correct.", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "switch_already_configured": "Switch with this ID has already been configured." + }, + "step": { + "user": { + "title": "Main Configuration", + "description": "Input the credentials for Tuya Cloud API.", + "data": { + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID" + } + }, + "power_outlet": { + "title": "Add subswitch", + "description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.", + "data": { + "id": "ID", + "name": "Name", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "add_another_switch": "Add another switch" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "LocalTuya Configuration", + "description": "Please select the desired actionSSSS.", + "data": { + "add_device": "Add a new device", + "edit_device": "Edit a device", + "delete_device": "Delete a device", + "setup_cloud": "Reconfigure Cloud API account" + } + }, + "entity": { + "title": "Entity Configuration", + "description": "Editing entity with DPS `{id}` and platform `{platform}`.", + "data": { + "id": "ID", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "commands_set": "Open_Close_Stop Commands Set", + "positioning_mode": "Positioning mode", + "current_position_dp": "Current Position (for *position* mode only)", + "set_position_dp": "Set Position (for *position* mode only)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "scaling": "Scaling Factor", + "state_on": "On Value", + "state_off": "Off Value", + "powergo_dp": "Power DP (Usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (Usually 11)", + "battery_dp": "Battery status DP (Usually 14)", + "mode_dp": "Mode DP (Usually 27)", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speeds": "Fan speeds list (comma-separated)", + "clean_time_dp": "Clean Time DP (Usually 33)", + "clean_area_dp": "Clean Area DP (Usually 32)", + "clean_record_dp": "Clean Record DP (Usually 34)", + "locate_dp": "Locate DP (Usually 31)", + "paused_state": "Pause state (pause, paused, etc)", + "stop_status": "Stop status", + "brightness": "Brightness (only for white color)", + "brightness_lower": "Brightness Lower Value", + "brightness_upper": "Brightness Upper Value", + "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", + "color": "Color", + "color_mode": "Color Mode", + "color_temp_min_kelvin": "Minimum Color Temperature in K", + "color_temp_max_kelvin": "Maximum Color Temperature in K", + "music_mode": "Music mode available", + "scene": "Scene", + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "Temperature Step (optional)", + "max_temperature_dp": "Max Temperature (optional)", + "min_temperature_dp": "Min Temperature (optional)", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DPs values)", + "temperature_unit": "Temperature Unit (optional)", + "hvac_mode_dp": "HVAC Mode DP (optional)", + "hvac_mode_set": "HVAC Mode Set (optional)", + "hvac_action_dp": "HVAC Current Action DP (optional)", + "hvac_action_set": "HVAC Current Action Set (optional)", + "preset_dp": "Presets DP (optional)", + "preset_set": "Presets Set (optional)", + "eco_dp": "Eco DP (optional)", + "eco_value": "Eco value (optional)", + "heuristic_action": "Enable heuristic action (optional)" + } + }, + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + } + } + }, + "title": "LocalTuya" +} diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py new file mode 100644 index 000000000..e884095b7 --- /dev/null +++ b/custom_components/localtuya/switch.py @@ -0,0 +1,77 @@ +"""Platform to locally control Tuya-based switch devices.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.switch import DOMAIN, SwitchEntity + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + ATTR_CURRENT, + ATTR_CURRENT_CONSUMPTION, + ATTR_VOLTAGE, + CONF_CURRENT, + CONF_CURRENT_CONSUMPTION, + CONF_VOLTAGE, +) + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_CURRENT): vol.In(dps), + vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps), + vol.Optional(CONF_VOLTAGE): vol.In(dps), + } + + +class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): + """Representation of a Tuya switch.""" + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize the Tuya switch.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + _LOGGER.debug("Initialized switch [%s]", self.name) + + @property + def is_on(self): + """Check if Tuya switch is on.""" + return self._state + + @property + def extra_state_attributes(self): + """Return device state attributes.""" + attrs = {} + if self.has_config(CONF_CURRENT): + attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT]) + if self.has_config(CONF_CURRENT_CONSUMPTION): + attrs[ATTR_CURRENT_CONSUMPTION] = ( + self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10 + ) + if self.has_config(CONF_VOLTAGE): + attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 + return attrs + + async def async_turn_on(self, **kwargs): + """Turn Tuya switch on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn Tuya switch off.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json new file mode 100644 index 000000000..82f406446 --- /dev/null +++ b/custom_components/localtuya/translations/en.json @@ -0,0 +1,190 @@ +{ + "config": { + "abort": { + "already_configured": "Device has already been configured.", + "device_updated": "Device configuration has been updated!" + }, + "error": { + "authentication_failed": "Failed to authenticate.\n{msg}", + "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "entity_already_configured": "Entity with this ID has already been configured.", + "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", + "discovery_failed": "Something failed when discovering devices. See log for details.", + "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." + }, + "step": { + "user": { + "title": "Cloud API account configuration", + "description": "Input the credentials for Tuya Cloud API.", + "data": { + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID", + "user_name": "Username", + "no_cloud": "Do not configure a Cloud API account" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Device has already been configured.", + "device_success": "Device {dev_name} successfully {action}." + }, + "error": { + "authentication_failed": "Failed to authenticate.\n{msg}", + "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "entity_already_configured": "Entity with this ID has already been configured.", + "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", + "discovery_failed": "Something failed when discovering devices. See log for details.", + "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." + }, + "step": { + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + }, + "init": { + "title": "LocalTuya Configuration", + "description": "Please select the desired action.", + "data": { + "add_device": "Add a new device", + "edit_device": "Edit a device", + "setup_cloud": "Reconfigure Cloud API account" + } + }, + "add_device": { + "title": "Add a new device", + "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", + "data": { + "selected_device": "Discovered Devices" + } + }, + "edit_device": { + "title": "Edit a new device", + "description": "Pick the configured device you wish to edit.", + "data": { + "selected_device": "Configured Devices" + } + }, + "cloud_setup": { + "title": "Cloud API account configuration", + "description": "Input the credentials for Tuya Cloud API.", + "data": { + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID", + "user_name": "Username", + "no_cloud": "Do not configure Cloud API account" + } + }, + "configure_device": { + "title": "Configure Tuya device", + "description": "Fill in the device details{for_device}.", + "data": { + "friendly_name": "Name", + "host": "Host", + "device_id": "Device ID", + "local_key": "Local key", + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", + "entities": "Entities (uncheck an entity to remove it)" + } + }, + "pick_entity_type": { + "title": "Entity type selection", + "description": "Please pick the type of entity you want to add.", + "data": { + "platform_to_add": "Platform", + "no_additional_entities": "Do not add any more entities" + } + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", + "data": { + "id": "ID", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "commands_set": "Open_Close_Stop Commands Set", + "positioning_mode": "Positioning mode", + "current_position_dp": "Current Position (for *position* mode only)", + "set_position_dp": "Set Position (for *position* mode only)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "scaling": "Scaling Factor", + "state_on": "On Value", + "state_off": "Off Value", + "powergo_dp": "Power DP (Usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (Usually 11)", + "battery_dp": "Battery status DP (Usually 14)", + "mode_dp": "Mode DP (Usually 27)", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speeds": "Fan speeds list (comma-separated)", + "clean_time_dp": "Clean Time DP (Usually 33)", + "clean_area_dp": "Clean Area DP (Usually 32)", + "clean_record_dp": "Clean Record DP (Usually 34)", + "locate_dp": "Locate DP (Usually 31)", + "paused_state": "Pause state (pause, paused, etc)", + "stop_status": "Stop status", + "brightness": "Brightness (only for white color)", + "brightness_lower": "Brightness Lower Value", + "brightness_upper": "Brightness Upper Value", + "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", + "color": "Color", + "color_mode": "Color Mode", + "color_temp_min_kelvin": "Minimum Color Temperature in K", + "color_temp_max_kelvin": "Maximum Color Temperature in K", + "music_mode": "Music mode available", + "scene": "Scene", + "select_options": "Valid entries, separate entries by a ;", + "select_options_friendly": "User Friendly options, separate entries by a ;", + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "Temperature Step (optional)", + "max_temperature_dp": "Max Temperature (optional)", + "min_temperature_dp": "Min Temperature (optional)", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DPs values)", + "temperature_unit": "Temperature Unit (optional)", + "hvac_mode_dp": "HVAC Mode DP (optional)", + "hvac_mode_set": "HVAC Mode Set (optional)", + "hvac_action_dp": "HVAC Current Action DP (optional)", + "hvac_action_set": "HVAC Current Action Set (optional)", + "preset_dp": "Presets DP (optional)", + "preset_set": "Presets Set (optional)", + "eco_dp": "Eco DP (optional)", + "eco_value": "Eco value (optional)", + "heuristic_action": "Enable heuristic action (optional)" + } + } + } + }, + "title": "LocalTuya" +} diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json new file mode 100644 index 000000000..c27dc510d --- /dev/null +++ b/custom_components/localtuya/translations/pt-BR.json @@ -0,0 +1,217 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo já foi configurado.", + "device_updated": "A configuração do dispositivo foi atualizada!" + }, + "error": { + "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente.", + "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", + "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", + "entity_already_configured": "A entidade com este ID já foi configurada.", + "address_in_use": "O endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir." + }, + "step": { + "user": { + "title": "Descoberta de dispositivo", + "description": "Escolha um dos dispositivos descobertos automaticamente ou clique em `...` para adicionar um dispositivo manualmente.", + "data": { + "discovered_device": "Dispositivo descoberto" + } + }, + "basic_info": { + "title": "Adicionar dispositivo Tuya", + "description": "Preencha os detalhes básicos do dispositivo. O nome inserido aqui será usado para identificar a própria integração (como visto na página `Integrations`). Você adicionará entidades e dará nomes a elas nas etapas a seguir.", + "data": { + "friendly_name": "Nome", + "host": "Host", + "device_id": "ID do dispositivo", + "local_key": "Local key", + "protocol_version": "Versão do protocolo", + "scan_interval": "Intervalo do escaneamento (segundos, somente quando não estiver atualizando automaticamente)" + } + }, + "pick_entity_type": { + "title": "Seleção do tipo de entidade", + "description": "Escolha o tipo de entidade que deseja adicionar.", + "data": { + "platform_to_add": "Platforma", + "no_additional_platforms": "Não adicione mais entidades" + } + }, + "add_entity": { + "title": "Adicionar nova entidade", + "description": "Por favor, preencha os detalhes de uma entidade com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", + "data": { + "id": "ID", + "friendly_name": "Name fantasia", + "current": "Atual", + "current_consumption": "Consumo atual", + "voltage": "Voltagem", + "commands_set": "Conjunto de comandos Open_Close_Stop", + "positioning_mode": "Modo de posicão", + "current_position_dp": "Posição atual (somente para o modo de posição)", + "set_position_dp": "Definir posição (somente para o modo de posição)", + "position_inverted": "Inverter posição 0-100 (somente para o modo de posição)", + "span_time": "Tempo de abertura completo, em segundos. (somente para o modo temporizado)", + "unit_of_measurement": "Unidade de medida", + "device_class": "Classe do dispositivo", + "scaling": "Fator de escala", + "state_on": "Valor On", + "state_off": "Valor Off", + "powergo_dp": "Potência DP (Geralmente 25 ou 2)", + "idle_status_value": "Status ocioso (separado por vírgula)", + "returning_status_value": "Status de retorno", + "docked_status_value": "Status docked (separado por vírgula)", + "fault_dp": "Falha DP (Geralmente 11)", + "battery_dp": "Status da bateria DP (normalmente 14)", + "mode_dp": "Modo DP (Geralmente 27)", + "modes": "Lista de modos", + "return_mode": "Modo de retorno para base", + "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", + "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", + "clean_time_dp": "Tempo de Limpeza DP (Geralmente 33)", + "clean_area_dp": "Área Limpa DP (Geralmente 32)", + "clean_record_dp": "Limpar Registro DP (Geralmente 34)", + "locate_dp": "Localize DP (Geralmente 31)", + "paused_state": "Estado de pausa (pausa, pausado, etc)", + "stop_status": "Status de parada", + "brightness": "Brilho (somente para cor branca)", + "brightness_lower": "Valor mais baixo do brilho", + "brightness_upper": "Valor mais alto do brilho", + "color_temp": "Temperatura da cor", + "color_temp_reverse": "Temperatura da cor reversa", + "color": "Cor", + "color_mode": "Modo de cor", + "color_temp_min_kelvin": "Minima temperatura de cor em K", + "color_temp_max_kelvin": "Máxima temperatura de cor em K", + "music_mode": "Modo de música disponível", + "scene": "Cena", + "fan_speed_control": "dps de controle de velocidade do ventilador", + "fan_oscillating_control": "dps de controle oscilante do ventilador", + "fan_speed_min": "velocidade mínima do ventilador inteiro", + "fan_speed_max": "velocidade máxima do ventilador inteiro", + "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", + "fan_direction":"direção do ventilador dps", + "fan_direction_forward": "string de dps para frente", + "fan_direction_reverse": "string dps reversa", + "current_temperature_dp": "Temperatura atual", + "target_temperature_dp": "Temperatura alvo", + "temperature_step": "Etapa de temperatura (opcional)", + "max_temperature_dp": "Max Temperatura (opcional)", + "min_temperature_dp": "Min Temperatura (opcional)", + "precision": "Precisão (opcional, para valores de DPs)", + "target_precision": "Precisão do alvo (opcional, para valores de DPs)", + "temperature_unit": "Unidade de Temperatura (opcional)", + "hvac_mode_dp": "Modo HVAC DP (opcional)", + "hvac_mode_set": "Conjunto de modo HVAC (opcional)", + "hvac_action_dp": "Ação atual de HVAC DP (opcional)", + "hvac_action_set": "Conjunto de ação atual de HVAC (opcional)", + "preset_dp": "Predefinições DP (opcional)", + "preset_set": "Conjunto de predefinições (opcional)", + "eco_dp": "Eco DP (opcional)", + "eco_value": "Valor ECO (opcional)", + "heuristic_action": "Ativar ação heurística (opcional)" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Configurar dispositivo Tuya", + "description": "Configuração básica para o ID do dispositivo `{device_id}`.", + "data": { + "friendly_name": "Nome fantasia", + "host": "Host", + "local_key": "Local key", + "protocol_version": "Versão do protocolo", + "scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)", + "entities": "Entidades (desmarque uma entidade para removê-la)" + } + }, + "entity": { + "title": "Adicionar nova entidade", + "description": "Por favor, preencha os detalhes de uma entidade com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", + "data": { + "id": "ID", + "friendly_name": "Name fantasia", + "current": "Atual", + "current_consumption": "Consumo atual", + "voltage": "Voltagem", + "commands_set": "Conjunto de comandos Open_Close_Stop", + "positioning_mode": "Modo de posicão", + "current_position_dp": "Posição atual (somente para o modo de posição)", + "set_position_dp": "Definir posição (somente para o modo de posição)", + "position_inverted": "Inverter posição 0-100 (somente para o modo de posição)", + "span_time": "Tempo de abertura completo, em segundos. (somente para o modo temporizado)", + "unit_of_measurement": "Unidade de medida", + "device_class": "Classe do dispositivo", + "scaling": "Fator de escala", + "state_on": "Valor On", + "state_off": "Valor Off", + "powergo_dp": "Potência DP (Geralmente 25 ou 2)", + "idle_status_value": "Status ocioso (separado por vírgula)", + "returning_status_value": "Status de retorno", + "docked_status_value": "Status docked (separado por vírgula)", + "fault_dp": "Falha DP (Geralmente 11)", + "battery_dp": "Status da bateria DP (normalmente 14)", + "mode_dp": "Modo DP (Geralmente 27)", + "modes": "Lista de modos", + "return_mode": "Modo de retorno para base", + "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", + "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", + "clean_time_dp": "Tempo de Limpeza DP (Geralmente 33)", + "clean_area_dp": "Área Limpa DP (Geralmente 32)", + "clean_record_dp": "Limpar Registro DP (Geralmente 34)", + "locate_dp": "Localize DP (Geralmente 31)", + "paused_state": "Estado de pausa (pausa, pausado, etc)", + "stop_status": "Status de parada", + "brightness": "Brilho (somente para cor branca)", + "brightness_lower": "Valor mais baixo do brilho", + "brightness_upper": "Valor mais alto do brilho", + "color_temp": "Temperatura da cor", + "color_temp_reverse": "Temperatura da cor reversa", + "color": "Cor", + "color_mode": "Modo de cor", + "color_temp_min_kelvin": "Minima temperatura de cor em K", + "color_temp_max_kelvin": "Máxima temperatura de cor em K", + "music_mode": "Modo de música disponível", + "scene": "Cena", + "fan_speed_control": "dps de controle de velocidade do ventilador", + "fan_oscillating_control": "dps de controle oscilante do ventilador", + "fan_speed_min": "velocidade mínima do ventilador inteiro", + "fan_speed_max": "velocidade máxima do ventilador inteiro", + "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", + "fan_direction":"direção do ventilador dps", + "fan_direction_forward": "string de dps para frente", + "fan_direction_reverse": "string dps reversa", + "current_temperature_dp": "Temperatura atual", + "target_temperature_dp": "Temperatura alvo", + "temperature_step": "Etapa de temperatura (opcional)", + "max_temperature_dp": "Max Temperatura (opcional)", + "min_temperature_dp": "Min Temperatura (opcional)", + "precision": "Precisão (opcional, para valores de DPs)", + "target_precision": "Precisão do alvo (opcional, para valores de DPs)", + "temperature_unit": "Unidade de Temperatura (opcional)", + "hvac_mode_dp": "Modo HVAC DP (opcional)", + "hvac_mode_set": "Conjunto de modo HVAC (opcional)", + "hvac_action_dp": "Ação atual de HVAC DP (opcional)", + "hvac_action_set": "Conjunto de ação atual de HVAC (opcional)", + "preset_dp": "Predefinições DP (opcional)", + "preset_set": "Conjunto de predefinições (opcional)", + "eco_dp": "Eco DP (opcional)", + "eco_value": "Valor ECO (opcional)", + "heuristic_action": "Ativar ação heurística (opcional)" + } + }, + "yaml_import": { + "title": "Não suportado", + "description": "As opções não podem ser editadas quando configuradas via YAML." + } + } + }, + "title": "LocalTuya" +} diff --git a/custom_components/localtuya/vacuum.py b/custom_components/localtuya/vacuum.py new file mode 100644 index 000000000..7bf4ed636 --- /dev/null +++ b/custom_components/localtuya/vacuum.py @@ -0,0 +1,256 @@ +"""Platform to locally control Tuya-based vacuum devices.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.vacuum import ( + DOMAIN, + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + SUPPORT_BATTERY, + SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STATUS, + SUPPORT_STOP, + StateVacuumEntity, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_BATTERY_DP, + CONF_CLEAN_AREA_DP, + CONF_CLEAN_RECORD_DP, + CONF_CLEAN_TIME_DP, + CONF_DOCKED_STATUS_VALUE, + CONF_FAN_SPEED_DP, + CONF_FAN_SPEEDS, + CONF_FAULT_DP, + CONF_IDLE_STATUS_VALUE, + CONF_LOCATE_DP, + CONF_MODE_DP, + CONF_MODES, + CONF_PAUSED_STATE, + CONF_POWERGO_DP, + CONF_RETURN_MODE, + CONF_RETURNING_STATUS_VALUE, + CONF_STOP_STATUS, +) + +_LOGGER = logging.getLogger(__name__) + +CLEAN_TIME = "clean_time" +CLEAN_AREA = "clean_area" +CLEAN_RECORD = "clean_record" +MODES_LIST = "cleaning_mode_list" +MODE = "cleaning_mode" +FAULT = "fault" + +DEFAULT_IDLE_STATUS = "standby,sleep" +DEFAULT_RETURNING_STATUS = "docking" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted" +DEFAULT_MODES = "smart,wall_follow,spiral,single" +DEFAULT_FAN_SPEEDS = "low,normal,high" +DEFAULT_PAUSED_STATE = "paused" +DEFAULT_RETURN_MODE = "chargego" +DEFAULT_STOP_STATUS = "standby" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str, + vol.Required(CONF_POWERGO_DP): vol.In(dps), + vol.Required(CONF_DOCKED_STATUS_VALUE, default=DEFAULT_DOCKED_STATUS): str, + vol.Optional( + CONF_RETURNING_STATUS_VALUE, default=DEFAULT_RETURNING_STATUS + ): str, + vol.Optional(CONF_BATTERY_DP): vol.In(dps), + vol.Optional(CONF_MODE_DP): vol.In(dps), + vol.Optional(CONF_MODES, default=DEFAULT_MODES): str, + vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str, + vol.Optional(CONF_FAN_SPEED_DP): vol.In(dps), + vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str, + vol.Optional(CONF_CLEAN_TIME_DP): vol.In(dps), + vol.Optional(CONF_CLEAN_AREA_DP): vol.In(dps), + vol.Optional(CONF_CLEAN_RECORD_DP): vol.In(dps), + vol.Optional(CONF_LOCATE_DP): vol.In(dps), + vol.Optional(CONF_FAULT_DP): vol.In(dps), + vol.Optional(CONF_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str, + vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str, + } + + +class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): + """Tuya vacuum device.""" + + def __init__(self, device, config_entry, switchid, **kwargs): + """Initialize a new LocaltuyaVacuum.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._battery_level = None + self._attrs = {} + + self._idle_status_list = [] + if self.has_config(CONF_IDLE_STATUS_VALUE): + self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",") + + self._modes_list = [] + if self.has_config(CONF_MODES): + self._modes_list = self._config[CONF_MODES].split(",") + self._attrs[MODES_LIST] = self._modes_list + + self._docked_status_list = [] + if self.has_config(CONF_DOCKED_STATUS_VALUE): + self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",") + + self._fan_speed_list = [] + if self.has_config(CONF_FAN_SPEEDS): + self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",") + + self._fan_speed = "" + self._cleaning_mode = "" + _LOGGER.debug("Initialized vacuum [%s]", self.name) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ( + SUPPORT_START + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_STATUS + | SUPPORT_STATE + ) + + if self.has_config(CONF_RETURN_MODE): + supported_features = supported_features | SUPPORT_RETURN_HOME + if self.has_config(CONF_FAN_SPEED_DP): + supported_features = supported_features | SUPPORT_FAN_SPEED + if self.has_config(CONF_BATTERY_DP): + supported_features = supported_features | SUPPORT_BATTERY + if self.has_config(CONF_LOCATE_DP): + supported_features = supported_features | SUPPORT_LOCATE + + return supported_features + + @property + def state(self): + """Return the vacuum state.""" + return self._state + + @property + def battery_level(self): + """Return the current battery level.""" + return self._battery_level + + @property + def extra_state_attributes(self): + """Return the specific state attributes of this vacuum cleaner.""" + return self._attrs + + @property + def fan_speed(self): + """Return the current fan speed.""" + return self._fan_speed + + @property + def fan_speed_list(self) -> list: + """Return the list of available fan speeds.""" + return self._fan_speed_list + + async def async_start(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + await self._device.set_dp(True, self._config[CONF_POWERGO_DP]) + + async def async_pause(self, **kwargs): + """Stop the vacuum cleaner, do not return to base.""" + await self._device.set_dp(False, self._config[CONF_POWERGO_DP]) + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + if self.has_config(CONF_RETURN_MODE): + await self._device.set_dp( + self._config[CONF_RETURN_MODE], self._config[CONF_MODE_DP] + ) + else: + _LOGGER.error("Missing command for return home in commands set.") + + async def async_stop(self, **kwargs): + """Turn the vacuum off stopping the cleaning.""" + if self.has_config(CONF_STOP_STATUS): + await self._device.set_dp( + self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP] + ) + else: + _LOGGER.error("Missing command for stop in commands set.") + + async def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + return None + + async def async_locate(self, **kwargs): + """Locate the vacuum cleaner.""" + if self.has_config(CONF_LOCATE_DP): + await self._device.set_dp("", self._config[CONF_LOCATE_DP]) + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set the fan speed.""" + await self._device.set_dp(fan_speed, self._config[CONF_FAN_SPEED_DP]) + + async def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + if command == "set_mode" and "mode" in params: + mode = params["mode"] + await self._device.set_dp(mode, self._config[CONF_MODE_DP]) + + def status_updated(self): + """Device status was updated.""" + state_value = str(self.dps(self._dp_id)) + + if state_value in self._idle_status_list: + self._state = STATE_IDLE + elif state_value in self._docked_status_list: + self._state = STATE_DOCKED + elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]: + self._state = STATE_RETURNING + elif state_value == self._config[CONF_PAUSED_STATE]: + self._state = STATE_PAUSED + else: + self._state = STATE_CLEANING + + if self.has_config(CONF_BATTERY_DP): + self._battery_level = self.dps_conf(CONF_BATTERY_DP) + + self._cleaning_mode = "" + if self.has_config(CONF_MODES): + self._cleaning_mode = self.dps_conf(CONF_MODE_DP) + self._attrs[MODE] = self._cleaning_mode + + self._fan_speed = "" + if self.has_config(CONF_FAN_SPEEDS): + self._fan_speed = self.dps_conf(CONF_FAN_SPEED_DP) + + if self.has_config(CONF_CLEAN_TIME_DP): + self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP) + + if self.has_config(CONF_CLEAN_AREA_DP): + self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP) + + if self.has_config(CONF_CLEAN_RECORD_DP): + self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP) + + if self.has_config(CONF_FAULT_DP): + self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP) + if self._attrs[FAULT] != 0: + self._state = STATE_ERROR + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema) diff --git a/custom_components/smartir/__init__.py b/custom_components/smartir/__init__.py index 110955fd5..b4a04957b 100644 --- a/custom_components/smartir/__init__.py +++ b/custom_components/smartir/__init__.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'smartir' -VERSION = '1.17.4' +VERSION = '1.17.6' MANIFEST_URL = ( "https://raw.githubusercontent.com/" "smartHomeHub/SmartIR/{}/" diff --git a/custom_components/smartir/fan.py b/custom_components/smartir/fan.py index 75e757d6c..ce8393b94 100644 --- a/custom_components/smartir/fan.py +++ b/custom_components/smartir/fan.py @@ -6,16 +6,20 @@ import voluptuous as vol from homeassistant.components.fan import ( - FanEntity, PLATFORM_SCHEMA, ATTR_SPEED, - SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + FanEntity, PLATFORM_SCHEMA, DIRECTION_REVERSE, DIRECTION_FORWARD, - SUPPORT_SET_SPEED, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, ATTR_OSCILLATING ) + SUPPORT_SET_SPEED, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + ATTR_OSCILLATING ) from homeassistant.const import ( CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) from homeassistant.core import callback from homeassistant.helpers.event import async_track_state_change import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item +) from . import COMPONENT_ABS_DIR, Helper from .controller import get_controller @@ -30,6 +34,8 @@ CONF_DELAY = "delay" CONF_POWER_SENSOR = 'power_sensor' +SPEED_OFF = "off" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -93,7 +99,7 @@ def __init__(self, hass, config, device_data): self._supported_models = device_data['supportedModels'] self._supported_controller = device_data['supportedController'] self._commands_encoding = device_data['commandsEncoding'] - self._speed_list = [SPEED_OFF] + device_data['speed'] + self._speed_list = device_data['speed'] self._commands = device_data['commands'] self._speed = SPEED_OFF @@ -161,19 +167,22 @@ def name(self): def state(self): """Return the current state.""" if (self._on_by_remote or \ - self.speed != SPEED_OFF): + self._speed != SPEED_OFF): return STATE_ON return SPEED_OFF @property - def speed_list(self): - """Get the list of available speeds.""" - return self._speed_list + def percentage(self): + """Return speed percentage of the fan.""" + if (self._speed == SPEED_OFF): + return 0 + + return ordered_list_item_to_percentage(self._speed_list, self._speed) @property - def speed(self): - """Return the current speed.""" - return self._speed + def speed_count(self): + """Return the number of speeds the fan supports.""" + return len(self._speed_list) @property def oscillating(self): @@ -181,8 +190,8 @@ def oscillating(self): return self._oscillating @property - def direction(self): - """Return the oscillation state.""" + def current_direction(self): + """Return the direction state.""" return self._direction @property @@ -207,12 +216,16 @@ def extra_state_attributes(self): 'commands_encoding': self._commands_encoding, } - async def async_set_speed(self, speed: str): - """Set the speed of the fan.""" - self._speed = speed + async def async_set_percentage(self, percentage: int): + """Set the desired speed for the fan.""" + if (percentage == 0): + self._speed = SPEED_OFF + else: + self._speed = percentage_to_ordered_list_item( + self._speed_list, percentage) - if not speed == SPEED_OFF: - self._last_on_speed = speed + if not self._speed == SPEED_OFF: + self._last_on_speed = self._speed await self.send_command() await self.async_update_ha_state() @@ -233,16 +246,17 @@ async def async_set_direction(self, direction: str): await self.async_update_ha_state() - async def async_turn_on(self, speed: str = None, **kwargs): + async def async_turn_on(self, percentage: int = None, **kwargs): """Turn on the fan.""" - if speed is None: - speed = self._last_on_speed or self._speed_list[1] + if percentage is None: + percentage = ordered_list_item_to_percentage( + self._speed_list, self._last_on_speed or self._speed_list[0]) - await self.async_set_speed(speed) + await self.async_set_percentage(percentage) async def async_turn_off(self): """Turn off the fan.""" - await self.async_set_speed(SPEED_OFF) + await self.async_set_percentage(0) async def send_command(self): async with self._temp_lock: @@ -280,4 +294,4 @@ async def _async_power_sensor_changed(self, entity_id, old_state, new_state): self._on_by_remote = False if self._speed != SPEED_OFF: self._speed = SPEED_OFF - await self.async_update_ha_state() + await self.async_update_ha_state() \ No newline at end of file diff --git a/custom_components/smartir/manifest.json b/custom_components/smartir/manifest.json index 5536795d0..60e6dadca 100644 --- a/custom_components/smartir/manifest.json +++ b/custom_components/smartir/manifest.json @@ -5,11 +5,11 @@ "dependencies": [], "codeowners": ["@smartHomeHub"], "requirements": ["aiofiles==0.6.0"], - "homeassistant": "0.115.0", - "version": "1.17.4", + "homeassistant": "2022.4.0", + "version": "1.17.6", "updater": { - "version": "1.17.4", - "releaseNotes": "-- Fixed typo #764", + "version": "1.17.6", + "releaseNotes": "-- Removes legacy imports to the fan component", "files": [ "__init__.py", "climate.py", diff --git a/custom_components/thermal_comfort/__init__.py b/custom_components/thermal_comfort/__init__.py new file mode 100644 index 000000000..8be1c248a --- /dev/null +++ b/custom_components/thermal_comfort/__init__.py @@ -0,0 +1,130 @@ +""" +Custom integration to integrate thermal_comfort with Home Assistant. + +For more details about this integration, please refer to +https://github.com/dolezsa/thermal_comfort +""" +from __future__ import annotations + +import logging + +from homeassistant.config import async_hass_config_yaml, async_process_component_config +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, SERVICE_RELOAD +from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import discovery +from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration + +from .config import OPTIONS_SCHEMA +from .config_flow import get_value +from .const import DOMAIN, PLATFORMS, UPDATE_LISTENER +from .sensor import ( + CONF_CUSTOM_ICONS, + CONF_ENABLED_SENSORS, + CONF_HUMIDITY_SENSOR, + CONF_POLL, + CONF_SCAN_INTERVAL, + CONF_TEMPERATURE_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up entry configured from user interface.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + CONF_NAME: get_value(entry, CONF_NAME), + CONF_TEMPERATURE_SENSOR: get_value(entry, CONF_TEMPERATURE_SENSOR), + CONF_HUMIDITY_SENSOR: get_value(entry, CONF_HUMIDITY_SENSOR), + CONF_POLL: get_value(entry, CONF_POLL), + CONF_SCAN_INTERVAL: get_value(entry, CONF_SCAN_INTERVAL), + CONF_CUSTOM_ICONS: get_value(entry, CONF_CUSTOM_ICONS), + } + if get_value(entry, CONF_ENABLED_SENSORS): + hass.data[DOMAIN][entry.entry_id][CONF_ENABLED_SENSORS] = get_value( + entry, CONF_ENABLED_SENSORS + ) + data = dict(entry.data) + data.pop(CONF_ENABLED_SENSORS) + hass.config_entries.async_update_entry(entry, data=data) + + if entry.unique_id is None: + # We have no unique_id yet, let's use backup. + hass.config_entries.async_update_entry(entry, unique_id=entry.entry_id) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + update_listener = entry.add_update_listener(async_update_options) + hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener + return True + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options from user interface.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Remove entry via user interface.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] + update_listener() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the thermal_comfort integration.""" + if DOMAIN in config: + await _process_config(hass, config) + + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload top-level + platforms.""" + try: + unprocessed_conf = await async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + conf = await async_process_component_config( + hass, unprocessed_conf, await async_get_integration(hass, DOMAIN) + ) + + if conf is None: + return + + await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) + + if DOMAIN in conf: + await _process_config(hass, conf) + + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_RELOAD, _reload_config + ) + + return True + + +async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: + """Process config.""" + for conf_section in hass_config[DOMAIN]: + for platform_domain in PLATFORMS: + if platform_domain in conf_section: + hass.async_create_task( + discovery.async_load_platform( + hass, + platform_domain, + DOMAIN, + { + "devices": conf_section[platform_domain], + "options": OPTIONS_SCHEMA(conf_section), + }, + hass_config, + ) + ) diff --git a/custom_components/thermal_comfort/config.py b/custom_components/thermal_comfort/config.py new file mode 100644 index 000000000..e6bb7013e --- /dev/null +++ b/custom_components/thermal_comfort/config.py @@ -0,0 +1,52 @@ +"""Thermal Comfort config validator.""" + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol + +from . import sensor as sensor_platform +from .const import DOMAIN +from .sensor import PLATFORM_OPTIONS_SCHEMA as SENSOR_OPTIONS_SCHEMA + +PACKAGE_MERGE_HINT = "list" + +OPTIONS_SCHEMA = vol.Schema({}).extend( + SENSOR_OPTIONS_SCHEMA.schema, + extra=vol.REMOVE_EXTRA, +) + +CONFIG_SECTION_SCHEMA = vol.Schema( + { + vol.Optional(SENSOR_DOMAIN): vol.All( + cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + ), + } +).extend(OPTIONS_SCHEMA.schema) + + +async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: + """Validate config.""" + if DOMAIN not in config: + return config + + config_sections = [] + + for cfg in cv.ensure_list(config[DOMAIN]): + try: + cfg = CONFIG_SECTION_SCHEMA(cfg) + + except vol.Invalid as err: + async_log_exception(err, DOMAIN, cfg, hass) + continue + + config_sections.append(cfg) + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + config = config_without_domain(config, DOMAIN) + config[DOMAIN] = config_sections + + return config diff --git a/custom_components/thermal_comfort/config_flow.py b/custom_components/thermal_comfort/config_flow.py new file mode 100644 index 000000000..5c6ff7c18 --- /dev/null +++ b/custom_components/thermal_comfort/config_flow.py @@ -0,0 +1,528 @@ +"""Tests for config flows.""" +from __future__ import annotations + +import logging + +from homeassistant import config_entries +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_NAME, Platform +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import EntityRegistry +import voluptuous as vol + +from .const import DEFAULT_NAME, DOMAIN +from .sensor import ( + CONF_CUSTOM_ICONS, + CONF_ENABLED_SENSORS, + CONF_HUMIDITY_SENSOR, + CONF_POLL, + CONF_SCAN_INTERVAL, + CONF_TEMPERATURE_SENSOR, + POLL_DEFAULT, + SCAN_INTERVAL_DEFAULT, + SensorType, +) + +_LOGGER = logging.getLogger(__name__) + + +def get_sensors_by_device_class( + _er: EntityRegistry, + _hass: HomeAssistant, + device_class: SensorDeviceClass, + include_all: bool = False, +) -> list: + """Get sensors of required class from entity registry.""" + + def filter_by_device_class( + _state: State, _list: list[SensorDeviceClass], should_be_in: bool = True + ) -> bool: + """Filter state objects by device class. + + :param _state: state object for examination + :param _list: list of device classes + :param should_be_in: should the object's device_class be in the list to pass the filter or not + """ + collected_device_class = _state.attributes.get( + "device_class", _state.attributes.get("original_device_class") + ) + # XNOR + return not ((collected_device_class in _list) ^ should_be_in) + + def filter_for_device_class_sensor(state: State) -> bool: + """Filter states by Platform.SENSOR and required device class.""" + return state.domain == Platform.SENSOR and filter_by_device_class( + state, [device_class], should_be_in=True + ) + + def filter_useless_device_class(state: State) -> bool: + """Filter out states with useless for us device class.""" + device_class_for_exclude = [ + SensorDeviceClass.AQI, + SensorDeviceClass.BATTERY, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.CURRENT, + SensorDeviceClass.DATE, + SensorDeviceClass.ENERGY, + SensorDeviceClass.FREQUENCY, + SensorDeviceClass.GAS, + SensorDeviceClass.ILLUMINANCE, + SensorDeviceClass.MONETARY, + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PM1, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.POWER_FACTOR, + SensorDeviceClass.POWER, + SensorDeviceClass.PRESSURE, + SensorDeviceClass.SIGNAL_STRENGTH, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + SensorDeviceClass.VOLTAGE, + ] + """We are sure that this device classes could not be useful as data source in any case""" + return filter_by_device_class( + state, device_class_for_exclude, should_be_in=False + ) + + def filter_useless_domain(state: State) -> bool: + """Filter states with useless for us domains.""" + domains_for_exclude = [ + Platform.AIR_QUALITY, + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CALENDAR, + Platform.CAMERA, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.FAN, + Platform.GEO_LOCATION, + Platform.IMAGE_PROCESSING, + Platform.LIGHT, + Platform.LOCK, + Platform.MAILBOX, + Platform.MEDIA_PLAYER, + Platform.NOTIFY, + Platform.REMOTE, + Platform.SCENE, + Platform.SIREN, + Platform.STT, + Platform.SWITCH, + Platform.TTS, + Platform.VACUUM, + "automation", + "person", + "script", + "scene", + "sun", + "timer", + "zone", + ] + """We are sure that this domains could not be useful as data source in any case""" + return state.domain not in domains_for_exclude + + def filter_useless_units(state: State) -> bool: + """Filter out states with useless for us units of measurements.""" + units_for_exclude = [ + # Electric + "W", + "kW", + "VA", + "BTU/h" "Wh", + "kWh", + "MWh", + "mA", + "A", + "mV", + "V", + # Degree units + "°", + # Currency units + "€", + "$", + "¢", + # Time units + "μs", + "ms", + "s", + "min", + "h", + "d", + "w", + "m", + "y", + # Length units + "mm", + "cm", + "m", + "km", + "in", + "ft", + "yd", + "mi", + # Frequency units + "Hz", + "kHz", + "MHz", + "GHz", + # Pressure units + "Pa", + "hPa", + "kPa", + "bar", + "cbar", + "mbar", + "mmHg", + "inHg", + "psi", + # Sound pressure units + "dB", + "dBa", + # Volume units + "L", + "mL", + "m³", + "ft³", + "gal", + "fl. oz.", + # Volume Flow Rate units + "m³/h", + "ft³/m", + # Area units + "m²", + # Mass + "g", + "kg", + "mg", + "µg", + "oz", + "lb", + # + "µS/cm", + "lx", + "UV index", + "W/m²", + "BTU/(h×ft²)", + # Precipitation units + "mm/h", + "in", + "in/h", + # Concentration units + "µg/m³", + "mg/m³", + "μg/ft³", + "p/m³", + "ppm", + "ppb", + # Speed units + "mm/d", + "in/d", + "m/s", + "in/h", + "km/h", + "mph", + # Signal_strength units + "dB", + "dBm", + # Data units + "bit", + "kbit", + "Mbit", + "Gbit", + "B", + "kB", + "MB", + "GB", + "TB", + "PB", + "EB", + "ZB", + "YB", + "KiB", + "MiB", + "GiB", + "TiB", + "PiB", + "EiB", + "ZiB", + "YiB", + "bit/s", + "kbit/s", + "Mbit/s", + "Gbit/s", + "B/s", + "kB/s", + "MB/s", + "GB/s", + "KiB/s", + "MiB/s", + "GiB/s", + ] + """We are sure that entities with this units could not be useful as data source in any case""" + additional_units = { + SensorDeviceClass.HUMIDITY: ["°C", "°F", "K"], + SensorDeviceClass.TEMPERATURE: ["%"], + } + units_for_exclude += additional_units.get(device_class, []) + + unit_of_measurement = state.attributes.get( + "unit_of_measurement", state.attributes.get("native_unit_of_measurement") + ) + return unit_of_measurement not in units_for_exclude + + 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 + ) + + filters_for_additional_sensors: list[callable] = [ + filter_useless_device_class, + filter_useless_domain, + filter_useless_units, + ] + + result = [ + state.entity_id + for state in filter( + filter_for_device_class_sensor, + _hass.states.async_all(), + ) + ] + + result.sort() + _LOGGER.debug(f"Results for {device_class} based on device class: {result}") + + if include_all: + additional_sensors = _hass.states.async_all() + for f in filters_for_additional_sensors: + additional_sensors = list(filter(f, additional_sensors)) + + additional_entity_ids = [state.entity_id for state in additional_sensors] + additional_entity_ids = list(set(additional_entity_ids) - set(result)) + additional_entity_ids.sort() + _LOGGER.debug(f"Additional results: {additional_entity_ids}") + result += additional_entity_ids + + result = list( + filter( + filter_thermal_comfort_ids, + result, + ) + ) + + _LOGGER.debug(f"Results after cleaning own entities: {result}") + + return result + + +def get_value( + config_entry: config_entries.ConfigEntry | None, param: str, default=None +): + """Get current value for configuration parameter. + + :param config_entry: config_entries|None: config entry from Flow + :param param: str: parameter name for getting value + :param default: default value for parameter, defaults to None + :returns: parameter value, or default value or None + """ + if config_entry is not None: + return config_entry.options.get(param, config_entry.data.get(param, default)) + else: + return default + + +def build_schema( + config_entry: config_entries | None, + hass: HomeAssistant, + show_advanced: bool = False, + step: str = "user", +) -> vol.Schema: + """Build configuration schema. + + :param config_entry: config entry for getting current parameters on None + :param hass: Home Assistant instance + :param show_advanced: bool: should we show advanced options? + :param step: for which step we should build schema + :return: Configuration schema with default parameters + """ + entity_registry_instance = entity_registry.async_get(hass) + humidity_sensors = get_sensors_by_device_class( + entity_registry_instance, hass, SensorDeviceClass.HUMIDITY, show_advanced + ) + temperature_sensors = get_sensors_by_device_class( + entity_registry_instance, hass, SensorDeviceClass.TEMPERATURE, show_advanced + ) + + if not temperature_sensors or not humidity_sensors: + return None + + schema = vol.Schema( + { + vol.Required( + CONF_NAME, default=get_value(config_entry, CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required( + CONF_TEMPERATURE_SENSOR, + default=get_value( + config_entry, CONF_TEMPERATURE_SENSOR, temperature_sensors[0] + ), + ): vol.In(temperature_sensors), + vol.Required( + CONF_HUMIDITY_SENSOR, + default=get_value( + config_entry, CONF_HUMIDITY_SENSOR, humidity_sensors[0] + ), + ): vol.In(humidity_sensors), + }, + ) + if show_advanced: + schema = schema.extend( + { + vol.Optional( + CONF_POLL, default=get_value(config_entry, CONF_POLL, POLL_DEFAULT) + ): bool, + vol.Optional( + CONF_SCAN_INTERVAL, + default=get_value( + config_entry, CONF_SCAN_INTERVAL, SCAN_INTERVAL_DEFAULT + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional( + CONF_CUSTOM_ICONS, + default=get_value(config_entry, CONF_CUSTOM_ICONS, False), + ): bool, + } + ) + if step == "user": + schema = schema.extend( + { + vol.Optional( + CONF_ENABLED_SENSORS, + default=list(SensorType), + ): cv.multi_select( + { + sensor_type: sensor_type.to_title() + for sensor_type in SensorType + } + ), + } + ) + + return schema + + +def check_input(hass: HomeAssistant, user_input: dict) -> dict: + """Check that we may use suggested configuration. + + :param hass: hass instance + :param user_input: user input + :returns: dict with error. + """ + + # ToDo: user_input have ConfigType type, but it in codebase since 2021.12.10 + + result = {} + + t_sensor = hass.states.get(user_input[CONF_TEMPERATURE_SENSOR]) + p_sensor = hass.states.get(user_input[CONF_HUMIDITY_SENSOR]) + + if t_sensor is None: + result["base"] = "temperature_not_found" + + if p_sensor is None: + result["base"] = "humidity_not_found" + + # ToDo: we should not trust user and check: + # - that CONF_TEMPERATURE_SENSOR is temperature sensor and have state_class measurement + # - that CONF_HUMIDITY_SENSOR is humidity sensor and have state_class measurement + return result + + +class ThermalComfortConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Configuration flow for setting up new thermal_comfort entry.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return ThermalComfortOptionsFlow(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + if not (errors := check_input(self.hass, user_input)): + er = entity_registry.async_get(self.hass) + + t_sensor = er.async_get(user_input[CONF_TEMPERATURE_SENSOR]) + p_sensor = er.async_get(user_input[CONF_HUMIDITY_SENSOR]) + _LOGGER.debug(f"Going to use t_sensor {t_sensor}") + _LOGGER.debug(f"Going to use p_sensor {p_sensor}") + + 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) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + schema = build_schema( + config_entry=None, + hass=self.hass, + show_advanced=self.show_advanced_options, + ) + + if schema is None: + if self.show_advanced_options: + reason = "no_sensors_advanced" + else: + reason = "no_sensors" + return self.async_abort(reason=reason) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + +class ThermalComfortOptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + + errors = {} + if user_input is not None: + _LOGGER.debug(f"OptionsFlow: going to update configuration {user_input}") + if not (errors := check_input(self.hass, user_input)): + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=build_schema( + config_entry=self.config_entry, + hass=self.hass, + show_advanced=self.show_advanced_options, + step="init", + ), + errors=errors, + ) diff --git a/custom_components/thermal_comfort/const.py b/custom_components/thermal_comfort/const.py new file mode 100644 index 000000000..5cded1c91 --- /dev/null +++ b/custom_components/thermal_comfort/const.py @@ -0,0 +1,11 @@ +"""General thermal_comfort constants.""" +from homeassistant.const import Platform + +DOMAIN = "thermal_comfort" +PLATFORMS = [Platform.SENSOR] +CONF_TEMPERATURE_SENSOR = "temperature_sensor" +CONF_HUMIDITY_SENSOR = "humidity_sensor" +CONF_POLL = "poll" + +DEFAULT_NAME = "Thermal Comfort" +UPDATE_LISTENER = "update_listener" diff --git a/custom_components/thermal_comfort/manifest.json b/custom_components/thermal_comfort/manifest.json index 551886960..085f98e95 100644 --- a/custom_components/thermal_comfort/manifest.json +++ b/custom_components/thermal_comfort/manifest.json @@ -1,9 +1,10 @@ { "domain": "thermal_comfort", "name": "Thermal Comfort", - "version": "2021.3.1", + "version": "1.4.3", "documentation": "https://github.com/dolezsa/thermal_comfort/blob/master/README.md", - "dependencies": [], + "issue_tracker": "https://github.com/dolezsa/thermal_comfort/issues", "codeowners": ["@dolezsa"], - "requirements": [] + "iot_class": "calculated", + "config_flow": true } diff --git a/custom_components/thermal_comfort/sensor.py b/custom_components/thermal_comfort/sensor.py index c2f1d1da9..7bfd7f582 100644 --- a/custom_components/thermal_comfort/sensor.py +++ b/custom_components/thermal_comfort/sensor.py @@ -1,285 +1,766 @@ +"""Sensor platform for thermal_comfort.""" +from asyncio import Lock +from dataclasses import dataclass +from datetime import timedelta +from functools import wraps import logging -from typing import Optional - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components.sensor import ENTITY_ID_FORMAT, \ - PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA +import math +from typing import Any + +from homeassistant import util +from homeassistant.backports.enum import StrEnum +from homeassistant.components.sensor import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON_TEMPLATE, - CONF_ENTITY_PICTURE_TEMPLATE, CONF_SENSORS, EVENT_HOMEASSISTANT_START, - MATCH_ALL, CONF_DEVICE_CLASS, DEVICE_CLASS_TEMPERATURE, STATE_UNKNOWN, - STATE_UNAVAILABLE, DEVICE_CLASS_HUMIDITY, ATTR_TEMPERATURE) + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_SENSORS, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, +) +from homeassistant.helpers.template import Template +from homeassistant.loader import async_get_custom_components +import voluptuous as vol -import math +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -CONF_TEMPERATURE_SENSOR = 'temperature_sensor' -CONF_HUMIDITY_SENSOR = 'humidity_sensor' -ATTR_HUMIDITY = 'humidity' - -SENSOR_SCHEMA = vol.Schema({ - vol.Required(CONF_TEMPERATURE_SENSOR): cv.entity_id, - vol.Required(CONF_HUMIDITY_SENSOR): cv.entity_id, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, - vol.Optional(ATTR_FRIENDLY_NAME): cv.string -}) +ATTR_HUMIDITY = "humidity" +ATTR_FROST_RISK_LEVEL = "frost_risk_level" +CONF_ENABLED_SENSORS = "enabled_sensors" +CONF_SENSOR_TYPES = "sensor_types" +CONF_CUSTOM_ICONS = "custom_icons" +CONF_SCAN_INTERVAL = "scan_interval" + +CONF_TEMPERATURE_SENSOR = "temperature_sensor" +CONF_HUMIDITY_SENSOR = "humidity_sensor" +CONF_POLL = "poll" +# Default values +POLL_DEFAULT = False +SCAN_INTERVAL_DEFAULT = 30 + + +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" + + +# Deprecate shortform in 2.0 +class SensorType(StrEnum): + """Sensor type enum.""" + + ABSOLUTE_HUMIDITY = "absolute_humidity" + DEW_POINT = "dew_point" + 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: + """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("_", "") + + @classmethod + def from_string(cls, string: str) -> "SensorType": + """Return the sensor type from string.""" + if string in list(cls): + return cls(string) + elif 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}") -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), -}) SENSOR_TYPES = { - 'absolutehumidity': [DEVICE_CLASS_HUMIDITY, 'Absolute Humidity', 'g/m³'], - 'heatindex': [DEVICE_CLASS_TEMPERATURE, 'Heat Index', '°C'], - 'dewpoint': [DEVICE_CLASS_TEMPERATURE, 'Dew Point', '°C'], - 'perception': [None, 'Thermal Perception', None], + SensorType.ABSOLUTE_HUMIDITY: { + "key": SensorType.ABSOLUTE_HUMIDITY, + "device_class": SensorDeviceClass.HUMIDITY, + "native_unit_of_measurement": "g/m³", + "state_class": SensorStateClass.MEASUREMENT, + "icon": "mdi:water", + }, + SensorType.DEW_POINT: { + "key": SensorType.DEW_POINT, + "device_class": SensorDeviceClass.TEMPERATURE, + "native_unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "tc:dew-point", + }, + SensorType.FROST_POINT: { + "key": SensorType.FROST_POINT, + "device_class": SensorDeviceClass.TEMPERATURE, + "native_unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "tc:frost-point", + }, + SensorType.FROST_RISK: { + "key": SensorType.FROST_RISK, + "device_class": ThermalComfortDeviceClass.FROST_RISK, + "icon": "mdi:snowflake-alert", + }, + SensorType.HEAT_INDEX: { + "key": SensorType.HEAT_INDEX, + "device_class": SensorDeviceClass.TEMPERATURE, + "native_unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "tc:heat-index", + }, + SensorType.SIMMER_INDEX: { + "key": SensorType.SIMMER_INDEX, + "device_class": SensorDeviceClass.TEMPERATURE, + "native_unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + "icon": "tc:simmer-index", + }, + SensorType.SIMMER_ZONE: { + "key": SensorType.SIMMER_ZONE, + "device_class": ThermalComfortDeviceClass.SIMMER_ZONE, + "icon": "tc:simmer-zone", + }, + SensorType.THERMAL_PERCEPTION: { + "key": SensorType.THERMAL_PERCEPTION, + "device_class": ThermalComfortDeviceClass.THERMAL_PERCEPTION, + "icon": "tc:thermal-perception", + }, } -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +DEFAULT_SENSOR_TYPES = list(SENSOR_TYPES.keys()) + +PLATFORM_OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_POLL): cv.boolean, + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_CUSTOM_ICONS): cv.boolean, + vol.Optional(CONF_SENSOR_TYPES): cv.ensure_list, + }, + extra=vol.REMOVE_EXTRA, +) + +LEGACY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_TEMPERATURE_SENSOR): cv.entity_id, + vol.Required(CONF_HUMIDITY_SENSOR): cv.entity_id, + 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, + } +) + +SENSOR_SCHEMA = LEGACY_SENSOR_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + } +).extend(PLATFORM_OPTIONS_SCHEMA.schema) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), + } +).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.""" + + def wrapper(func): + @wraps(func) + async def wrapped(self, *args, **kwargs): + async with self._compute_states[sensor_type].lock: + if self._compute_states[sensor_type].needs_update: + setattr(self, f"_{sensor_type}", await func(self, *args, **kwargs)) + self._compute_states[sensor_type].needs_update = False + return getattr(self, f"_{sensor_type}", None) + + return wrapped + + return wrapper + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Thermal Comfort sensors.""" + if discovery_info is None: + devices = [ + dict(device_config, **{CONF_NAME: device_name}) + for (device_name, device_config) in config[CONF_SENSORS].items() + ] + options = {} + else: + devices = discovery_info["devices"] + options = discovery_info["options"] + sensors = [] - for device, device_config in config[CONF_SENSORS].items(): - temperature_entity = device_config.get(CONF_TEMPERATURE_SENSOR) - humidity_entity = device_config.get(CONF_HUMIDITY_SENSOR) - icon_template = device_config.get(CONF_ICON_TEMPLATE) - entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) - - for sensor_type in SENSOR_TYPES: - sensors.append( - SensorThermalComfort( - hass, - device, - temperature_entity, - humidity_entity, - friendly_name, - icon_template, - entity_picture_template, - sensor_type) - ) - if not sensors: - _LOGGER.error("No sensors added") - return False + for device_config in devices: + device_config = options | device_config + compute_device = DeviceThermalComfort( + hass=hass, + name=device_config.get(CONF_NAME), + unique_id=device_config.get(CONF_UNIQUE_ID), + temperature_entity=device_config.get(CONF_TEMPERATURE_SENSOR), + humidity_entity=device_config.get(CONF_HUMIDITY_SENSOR), + should_poll=device_config.get(CONF_POLL, POLL_DEFAULT), + scan_interval=device_config.get( + CONF_SCAN_INTERVAL, timedelta(seconds=SCAN_INTERVAL_DEFAULT) + ), + ) + + sensors += [ + SensorThermalComfort( + device=compute_device, + entity_description=SensorEntityDescription( + **SENSOR_TYPES[SensorType.from_string(sensor_type)] + ), + 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), + ) + for sensor_type in device_config.get( + CONF_SENSOR_TYPES, DEFAULT_SENSOR_TYPES + ) + ] async_add_entities(sensors) return True -class SensorThermalComfort(Entity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entity configured via user interface. + + Called via async_setup_platforms(, SENSOR) from __init__.py + """ + data = hass.data[DOMAIN][config_entry.entry_id] + if data.get(CONF_SCAN_INTERVAL) is None: + hass.data[DOMAIN][config_entry.entry_id][ + CONF_SCAN_INTERVAL + ] = SCAN_INTERVAL_DEFAULT + data[CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT + + _LOGGER.debug(f"async_setup_entry: {data}") + compute_device = DeviceThermalComfort( + hass=hass, + name=data[CONF_NAME], + unique_id=f"{config_entry.unique_id}", + temperature_entity=data[CONF_TEMPERATURE_SENSOR], + humidity_entity=data[CONF_HUMIDITY_SENSOR], + should_poll=data[CONF_POLL], + scan_interval=timedelta( + seconds=data.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL_DEFAULT) + ), + ) + + entities: list[SensorThermalComfort] = [ + SensorThermalComfort( + device=compute_device, + entity_description=SensorEntityDescription(**SENSOR_TYPES[sensor_type]), + sensor_type=sensor_type, + custom_icons=data[CONF_CUSTOM_ICONS], + ) + for sensor_type in SensorType + ] + if CONF_ENABLED_SENSORS in data: + for entity in entities: + if entity.entity_description.key not in data[CONF_ENABLED_SENSORS]: + entity.entity_description.entity_registry_enabled_default = False + + if entities: + async_add_entities(entities) + + +def id_generator(unique_id: str, sensor_type: str) -> str: + """Generate id based on unique_id and sensor type. + + :param unique_id: str: common part of id for all entities, device unique_id, as a rule + :param sensor_type: str: different part of id, sensor type, as s rule + :returns: str: unique_id+sensor_type + """ + return unique_id + sensor_type + + +class SensorThermalComfort(SensorEntity): """Representation of a Thermal Comfort Sensor.""" - def __init__(self, hass, device_id, temperature_entity, humidity_entity, - friendly_name, icon_template, entity_picture_template, sensor_type): + def __init__( + self, + device: "DeviceThermalComfort", + sensor_type: SensorType, + entity_description: SensorEntityDescription, + icon_template: Template = None, + entity_picture_template: Template = None, + friendly_name: str = None, + custom_icons: bool = False, + ) -> None: """Initialize the sensor.""" - self.hass = hass - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, "{}_{}".format(device_id, sensor_type), hass=hass) - self._name = "{} {}".format(friendly_name, SENSOR_TYPES[sensor_type][1]) - self._unit_of_measurement = SENSOR_TYPES[sensor_type][2] - self._state = None - self._device_state_attributes = {} + 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.name = ( + f"{friendly_name} {self._sensor_type.to_title()}" + ) + # 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 self._icon_template = icon_template self._entity_picture_template = entity_picture_template - self._icon = None - self._entity_picture = None - self._temperature_entity = temperature_entity - self._humidity_entity = humidity_entity - self._device_class = SENSOR_TYPES[sensor_type][0] - self._sensor_type = sensor_type - self._temperature = None - self._humidity = None + 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_should_poll = False - async_track_state_change( - self.hass, self._temperature_entity, self.temperature_state_listener) + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + return self._device.device_info - async_track_state_change( - self.hass, self._humidity_entity, self.humidity_state_listener) + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return dict( + self._device.extra_state_attributes, **self._attr_extra_state_attributes + ) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.sensors.append(self) + if self._icon_template is not None: + self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass + if self._device.compute_states[self._sensor_type].needs_update: + self.async_schedule_update_ha_state(True) - temperature_state = hass.states.get(temperature_entity) - if temperature_state and temperature_state.state != STATE_UNKNOWN and temperature_state.state != STATE_UNAVAILABLE: - self._temperature = float(temperature_state.state) + async def async_update(self): + """Update the state of the sensor.""" + value = await getattr(self._device, self._sensor_type)() + if value is None: # can happen during startup + return - humidity_state = hass.states.get(humidity_entity) - if humidity_state and humidity_state.state != STATE_UNKNOWN and humidity_state.state != STATE_UNAVAILABLE: - self._humidity = float(humidity_state.state) + if self._sensor_type == SensorType.FROST_RISK: + self._attr_extra_state_attributes[ATTR_FROST_RISK_LEVEL] = value + self._attr_native_value = list(FrostRisk)[value] + else: + self._attr_native_value = value - def temperature_state_listener(self, entity, old_state, new_state): - """Handle temperature device state changes.""" - if new_state and new_state.state != STATE_UNKNOWN and new_state.state != STATE_UNAVAILABLE: - self._temperature = float(new_state.state) + for property_name, template in ( + ("_attr_icon", self._icon_template), + ("_attr_entity_picture", self._entity_picture_template), + ): + if template is None: + continue - self.async_schedule_update_ha_state(True) + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace("_", " ") + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute" + ): + # Common during HA startup - so just a warning + _LOGGER.warning( + "Could not render %s template %s," " the state is unknown.", + friendly_property_name, + self.name, + ) + continue - def humidity_state_listener(self, entity, old_state, new_state): - """Handle humidity device state changes.""" - if new_state and new_state.state != STATE_UNKNOWN and new_state.state != STATE_UNAVAILABLE: - self._humidity = float(new_state.state) + try: + setattr(self, property_name, getattr(super(), property_name)) + except AttributeError: + _LOGGER.error( + "Could not render %s template %s: %s", + friendly_property_name, + self.name, + ex, + ) + + +@dataclass +class ComputeState: + """Thermal Comfort Calculation State.""" + + needs_update: bool = False + lock: Lock = None - self.async_schedule_update_ha_state(True) - def computeDewPoint(self, temperature, humidity): - """http://wahiduddin.net/calc/density_algorithms.htm""" - A0 = 373.15 / (273.15 + temperature) +class DeviceThermalComfort: + """Representation of a Thermal Comfort Sensor.""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + unique_id: str, + temperature_entity: str, + humidity_entity: str, + should_poll: bool, + scan_interval: timedelta, + ): + """Initialize the sensor.""" + self.hass = hass + self._unique_id = unique_id + self._device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=name, + manufacturer=DEFAULT_NAME, + model="Virtual Device", + ) + self.extra_state_attributes = {} + self._temperature_entity = temperature_entity + self._humidity_entity = humidity_entity + self._temperature = None + self._humidity = None + self._should_poll = should_poll + self.sensors = [] + self._compute_states = { + sensor_type: ComputeState(lock=Lock()) + for sensor_type in SENSOR_TYPES.keys() + } + + async_track_state_change_event( + self.hass, self._temperature_entity, self.temperature_state_listener + ) + + async_track_state_change_event( + self.hass, self._humidity_entity, self.humidity_state_listener + ) + + hass.async_create_task( + self._new_temperature_state(hass.states.get(temperature_entity)) + ) + hass.async_create_task( + self._new_humidity_state(hass.states.get(humidity_entity)) + ) + + hass.async_create_task(self._set_version()) + + if self._should_poll: + if scan_interval is None: + scan_interval = timedelta(seconds=SCAN_INTERVAL_DEFAULT) + async_track_time_interval( + self.hass, + self.async_update_sensors, + scan_interval, + ) + + async def _set_version(self): + self._device_info["sw_version"] = ( + await async_get_custom_components(self.hass) + )[DOMAIN].version.string + + async def temperature_state_listener(self, event): + """Handle temperature device state changes.""" + await self._new_temperature_state(event.data.get("new_state")) + + async def _new_temperature_state(self, state): + if _is_valid_state(state): + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = util.convert(state.state, float) + self.extra_state_attributes[ATTR_TEMPERATURE] = temp + # convert to celsius if necessary + if unit == TEMP_FAHRENHEIT: + temp = util.temperature.fahrenheit_to_celsius(temp) + self._temperature = temp + await self.async_update() + + async def humidity_state_listener(self, event): + """Handle humidity device state changes.""" + await self._new_humidity_state(event.data.get("new_state")) + + 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() + + @compute_once_lock(SensorType.DEW_POINT) + async def dew_point(self) -> float: + """Dew Point .""" + A0 = 373.15 / (273.15 + self._temperature) SUM = -7.90298 * (A0 - 1) SUM += 5.02808 * math.log(A0, 10) SUM += -1.3816e-7 * (pow(10, (11.344 * (1 - 1 / A0))) - 1) SUM += 8.1328e-3 * (pow(10, (-3.49149 * (A0 - 1))) - 1) SUM += math.log(1013.246, 10) - VP = pow(10, SUM - 3) * humidity + VP = pow(10, SUM - 3) * self._humidity Td = math.log(VP / 0.61078) Td = (241.88 * Td) / (17.558 - Td) return round(Td, 2) - def toFahrenheit(self, celsius): - """celsius to fahrenheit""" - return 1.8 * celsius + 32.0 - - def toCelsius(self, fahrenheit): - """fahrenheit to celsius""" - return (fahrenheit - 32.0) / 1.8 - - def computeHeatIndex(self, temperature, humidity): - """http://www.wpc.ncep.noaa.gov/html/heatindex_equation.shtml""" - fahrenheit = self.toFahrenheit(temperature) - hi = 0.5 * (fahrenheit + 61.0 + ((fahrenheit - 68.0) * 1.2) + (humidity * 0.094)); + @compute_once_lock(SensorType.HEAT_INDEX) + async def heat_index(self) -> float: + """Heat Index .""" + fahrenheit = util.temperature.celsius_to_fahrenheit(self._temperature) + hi = 0.5 * ( + fahrenheit + 61.0 + ((fahrenheit - 68.0) * 1.2) + (self._humidity * 0.094) + ) if hi > 79: hi = -42.379 + 2.04901523 * fahrenheit - hi = hi + 10.14333127 * humidity - hi = hi + -0.22475541 * fahrenheit * humidity + hi = hi + 10.14333127 * self._humidity + hi = hi + -0.22475541 * fahrenheit * self._humidity hi = hi + -0.00683783 * pow(fahrenheit, 2) - hi = hi + -0.05481717 * pow(humidity, 2) - hi = hi + 0.00122874 * pow(fahrenheit, 2) * humidity - hi = hi + 0.00085282 * fahrenheit * pow(humidity, 2) - hi = hi + -0.00000199 * pow(fahrenheit, 2) * pow(humidity, 2); - - if humidity < 13 and fahrenheit >= 80 and fahrenheit <= 112: - hi = hi - ((13 - humidity) * 0.25) * math.sqrt((17 - abs(fahrenheit - 95)) * 0.05882) - elif humidity > 85 and fahrenheit >= 80 and fahrenheit <= 87: - hi = hi + ((humidity - 85) * 0.1) * ((87 - fahrenheit) * 0.2) - - return round(self.toCelsius(hi), 2) - - def computePerception(self, temperature, humidity): - """https://en.wikipedia.org/wiki/Dew_point""" - dewPoint = self.computeDewPoint(temperature, humidity) - if dewPoint < 10: - return "A bit dry for some" - elif dewPoint < 13: - return "Very comfortable" - elif dewPoint < 16: - return "Comfortable" - elif dewPoint < 18: - return "OK for most, but all perceive the humidity at upper edge" - elif dewPoint < 21: - return "Somewhat uncomfortable for most people at upper edge" - elif dewPoint < 24: - return "Very humid, quite uncomfortable" - elif dewPoint < 26: - return "Extremely uncomfortable, oppressive" - return "Severely high, even deadly for asthma related illnesses" - - def computeAbsoluteHumidity(self, temperature, humidity): - """https://carnotcycle.wordpress.com/2012/08/04/how-to-convert-relative-humidity-to-absolute-humidity/""" - absTemperature = temperature + 273.15; - absHumidity = 6.112; - absHumidity *= math.exp((17.67 * temperature) / (243.5 + temperature)); - absHumidity *= humidity; - absHumidity *= 2.1674; - absHumidity /= absTemperature; - return round(absHumidity, 2) - - """Sensor Properties""" - @property - def name(self): - """Return the name of the sensor.""" - return self._name + hi = hi + -0.05481717 * pow(self._humidity, 2) + hi = hi + 0.00122874 * pow(fahrenheit, 2) * self._humidity + hi = hi + 0.00085282 * fahrenheit * pow(self._humidity, 2) + hi = hi + -0.00000199 * pow(fahrenheit, 2) * pow(self._humidity, 2) + + if self._humidity < 13 and fahrenheit >= 80 and fahrenheit <= 112: + hi = hi - ((13 - self._humidity) * 0.25) * math.sqrt( + (17 - abs(fahrenheit - 95)) * 0.05882 + ) + elif self._humidity > 85 and fahrenheit >= 80 and fahrenheit <= 87: + hi = hi + ((self._humidity - 85) * 0.1) * ((87 - fahrenheit) * 0.2) + + return round(util.temperature.fahrenheit_to_celsius(hi), 2) + + @compute_once_lock(SensorType.THERMAL_PERCEPTION) + async def thermal_perception(self) -> ThermalPerception: + """Dew Point .""" + dewpoint = await self.dew_point() + if dewpoint < 10: + return ThermalPerception.DRY + elif dewpoint < 13: + return ThermalPerception.VERY_COMFORTABLE + elif dewpoint < 16: + return ThermalPerception.COMFORTABLE + elif dewpoint < 18: + return ThermalPerception.OK_BUT_HUMID + elif dewpoint < 21: + return ThermalPerception.SOMEWHAT_UNCOMFORTABLE + elif dewpoint < 24: + return ThermalPerception.QUITE_UNCOMFORTABLE + elif dewpoint < 26: + return ThermalPerception.EXTREMELY_UNCOMFORTABLE + else: + return ThermalPerception.SEVERELY_HIGH + + @compute_once_lock(SensorType.ABSOLUTE_HUMIDITY) + async def absolute_humidity(self) -> float: + """Absolute Humidity .""" + abs_temperature = self._temperature + 273.15 + abs_humidity = 6.112 + abs_humidity *= math.exp( + (17.67 * self._temperature) / (243.5 + self._temperature) + ) + abs_humidity *= self._humidity + abs_humidity *= 2.1674 + abs_humidity /= abs_temperature + return round(abs_humidity, 2) + + @compute_once_lock(SensorType.FROST_POINT) + async def frost_point(self) -> float: + """Frost Point .""" + 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, + ) + + @compute_once_lock(SensorType.FROST_RISK) + async def frost_risk(self) -> int: + """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 + else: + return 3 # 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 + + @compute_once_lock(SensorType.SIMMER_INDEX) + async def simmer_index(self) -> float: + """.""" + fahrenheit = util.temperature.celsius_to_fahrenheit(self._temperature) + + si = ( + 1.98 + * (fahrenheit - (0.55 - (0.0055 * self._humidity)) * (fahrenheit - 58.0)) + - 56.83 + ) + + if fahrenheit < 70: + si = fahrenheit + + return round(util.temperature.fahrenheit_to_celsius(si), 2) + + @compute_once_lock(SensorType.SIMMER_ZONE) + async def simmer_zone(self) -> SimmerZone: + """.""" + si = await self.simmer_index() + if si < 21.1: + return SimmerZone.COOL + elif si < 25.0: + return SimmerZone.SLIGHTLY_COOL + elif si < 28.3: + return SimmerZone.COMFORTABLE + elif si < 32.8: + return SimmerZone.SLIGHTLY_WARM + elif si < 37.8: + return SimmerZone.INCREASING_DISCOMFORT + elif si < 44.4: + return SimmerZone.EXTREMELY_WARM + elif si < 51.7: + return SimmerZone.DANGER_OF_HEATSTROKE + elif si < 65.6: + return SimmerZone.EXTREME_DANGER_OF_HEATSTROKE + else: + return SimmerZone.CIRCULATORY_COLLAPSE_IMMINENT - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._device_state_attributes + async def async_update(self): + """Update the state.""" + if self._temperature is not None and self._humidity is not None: + for sensor_type in SENSOR_TYPES.keys(): + self._compute_states[sensor_type].needs_update = True + if not self._should_poll: + await self.async_update_sensors(True) - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon + async def async_update_sensors(self, force_refresh: bool = False) -> None: + """Update the state of the sensors.""" + for sensor in self.sensors: + sensor.async_schedule_update_ha_state(force_refresh) @property - def device_class(self) -> Optional[str]: - """Return the device class of the sensor.""" - return self._device_class + def compute_states(self) -> dict[SensorType, ComputeState]: + """Compute states of configured sensors.""" + return self._compute_states @property - def entity_picture(self): - """Return the entity_picture to use in the frontend, if any.""" - return self._entity_picture + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id @property - def unit_of_measurement(self): - """Return the unit_of_measurement of the device.""" - return self._unit_of_measurement + def device_info(self) -> dict: + """Return the device info.""" + return self._device_info @property - def should_poll(self): - """No polling needed.""" - return False + def name(self) -> str: + """Return the name.""" + return self._device_info["name"] - async def async_update(self): - """Update the state.""" - value = None - if self._temperature and self._humidity: - if self._sensor_type == "dewpoint": - value = self.computeDewPoint(self._temperature, self._humidity) - if self._sensor_type == "heatindex": - value = self.computeHeatIndex(self._temperature, self._humidity) - elif self._sensor_type == "perception": - value = self.computePerception(self._temperature, self._humidity) - elif self._sensor_type == "absolutehumidity": - value = self.computeAbsoluteHumidity(self._temperature, self._humidity) - elif self._sensor_type == "comfortratio": - value = "comfortratio" - - self._state = value - self._device_state_attributes[ATTR_TEMPERATURE] = self._temperature - self._device_state_attributes[ATTR_HUMIDITY] = self._humidity - - for property_name, template in ( - ('_icon', self._icon_template), - ('_entity_picture', self._entity_picture_template)): - if template is None: - continue +def _is_valid_state(state) -> bool: + if state is not None: + if state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): try: - setattr(self, property_name, template.async_render()) - except TemplateError as ex: - friendly_property_name = property_name[1:].replace('_', ' ') - if ex.args and ex.args[0].startswith( - "UndefinedError: 'None' has no attribute"): - # Common during HA startup - so just a warning - _LOGGER.warning('Could not render %s template %s,' - ' the state is unknown.', - friendly_property_name, self._name) - continue - - try: - setattr(self, property_name, - getattr(super(), property_name)) - except AttributeError: - _LOGGER.error('Could not render %s template %s: %s', - friendly_property_name, self._name, ex) - + return not math.isnan(float(state.state)) + except ValueError: + pass + return False diff --git a/custom_components/thermal_comfort/services.yaml b/custom_components/thermal_comfort/services.yaml new file mode 100644 index 000000000..f383cff4c --- /dev/null +++ b/custom_components/thermal_comfort/services.yaml @@ -0,0 +1,3 @@ +reload: + name: Reload + description: Reload all Thermal Comfort entities. diff --git a/custom_components/thermal_comfort/translations/de.json b/custom_components/thermal_comfort/translations/de.json new file mode 100644 index 000000000..f740f990c --- /dev/null +++ b/custom_components/thermal_comfort/translations/de.json @@ -0,0 +1,45 @@ +{ + "options": { + "error": { + "temperature_not_found": "Temperatursensor konnte nicht gefunden werden", + "humidity_not_found": "Feuchtigkeitssensor konnte nicht gefunden werden" + }, + "step": { + "init": { + "title": "Thermal Comfort Einstellungen", + "data": { + "temperature_sensor": "Temperatursensor", + "humidity_sensor": "Feuchtigkeitssensor", + "poll": "Polling verwenden", + "scan_interval": "Poll Interval (Sekunden)", + "custom_icons": "Benutzerdefinierte Icons verwenden" + } + } + } + }, + "config": { + "abort": { + "already_configured": "Diese Kombination aus Temperatursensor und Feuchtigkeitssensor wird bereits verwendet", + "no_sensors": "Es wurden entweder keine Temperatursensoren oder Feuchtigkeitssensoren gefunden. Versuche es erneut im erweiterten Modus.", + "no_sensors_advanced": "Es wurden entweder keine Temperatursensoren oder Feuchtigkeitssensoren gefunden." + }, + "error": { + "temperature_not_found": "Temperatursensor konnte nicht gefunden werden", + "humidity_not_found": "Feuchtigkeitssensor konnte nicht gefunden werden" + }, + "step": { + "user": { + "title": "Thermal Comfort Einstellungen", + "data": { + "name": "Name", + "temperature_sensor": "Temperatursensor", + "humidity_sensor": "Feuchtigkeitssensor", + "poll": "Polling verwenden", + "scan_interval": "Poll Interval (Sekunden)", + "custom_icons": "Benutzerdefinierte Icons verwenden", + "enabled_sensors": "Aktivierte Sensoren" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/en.json b/custom_components/thermal_comfort/translations/en.json new file mode 100644 index 000000000..7d116b6fe --- /dev/null +++ b/custom_components/thermal_comfort/translations/en.json @@ -0,0 +1,45 @@ +{ + "options": { + "error": { + "temperature_not_found": "Temperature sensor not found", + "humidity_not_found": "Humidity sensor not found" + }, + "step": { + "init": { + "title": "Thermal comfort settings", + "data": { + "temperature_sensor": "Temperature sensor", + "humidity_sensor": "Humidity sensor", + "poll": "Enable Polling", + "scan_interval": "Poll interval (seconds)", + "custom_icons": "Use custom icons pack" + } + } + } + }, + "config": { + "abort": { + "already_configured": "This combination of temperature and humidity sensors is already configured", + "no_sensors": "No temperature or humidity sensors found. Try again in advanced mode.", + "no_sensors_advanced": "No temperature or humidity sensors found." + }, + "error": { + "temperature_not_found": "Temperature sensor not found", + "humidity_not_found": "Humidity sensor not found" + }, + "step": { + "user": { + "title": "Thermal comfort settings", + "data": { + "name": "Name", + "temperature_sensor": "Temperature sensor", + "humidity_sensor": "Humidity sensor", + "poll": "Enable Polling", + "scan_interval": "Poll interval (seconds)", + "custom_icons": "Use custom icons pack", + "enabled_sensors": "Enabled sensors" + } + } + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.cs.json b/custom_components/thermal_comfort/translations/sensor.cs.json new file mode 100644 index 000000000..a89a9568d --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.cs.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Žádné riziko", + "unlikely": "Nepravděpodobné", + "probable": "Pravděpodobné", + "high": "Vysoce pravděpodobné" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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/sensor.de.json b/custom_components/thermal_comfort/translations/sensor.de.json new file mode 100644 index 000000000..d50c5d182 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.de.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Kein Risiko", + "unlikely": "Unwahrscheinlich", + "probable": "Wahrscheinlich", + "high": "Sehr wahrscheinlich" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.en.json b/custom_components/thermal_comfort/translations/sensor.en.json new file mode 100644 index 000000000..9f4b7cc12 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.en.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "No risk", + "unlikely": "Unlikely", + "probable": "Probable", + "high": "High probability" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__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" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.es.json b/custom_components/thermal_comfort/translations/sensor.es.json new file mode 100644 index 000000000..c927c4474 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.es.json @@ -0,0 +1,14 @@ +{ + "state": { + "thermal_comfort__thermal_perception": { + "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" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.fr.json b/custom_components/thermal_comfort/translations/sensor.fr.json new file mode 100644 index 000000000..af2da1deb --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.fr.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Aucun risque", + "unlikely": "Peu probable", + "probable": "Probable", + "high": "Haute probabilité" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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/sensor.hu.json b/custom_components/thermal_comfort/translations/sensor.hu.json new file mode 100644 index 000000000..603065367 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.hu.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Nincs kockázat", + "unlikely": "Nem valószínű", + "probable": "Valószínű", + "high": "Nagy valószínűség" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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/sensor.it.json b/custom_components/thermal_comfort/translations/sensor.it.json new file mode 100644 index 000000000..34cc22eab --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.it.json @@ -0,0 +1,20 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Nessun rischio", + "unlikely": "Improbabile", + "probable": "Probabile", + "high": "Alta probabilità" + }, + "thermal_comfort__thermal_perception": { + "dry": "Un po' secco", + "very_comfortable": "Molto confortevole", + "comfortable": "Confortevole", + "ok_but_humid": "Gradevole ma umido", + "somewhat_uncomfortable": "Possibile disagio", + "quite_uncomfortable": "Molto umido, abbastanza a disagio", + "extremely_uncomfortable": "Particolarmente a disagio e opprimente", + "severely_high": "Estremamente a disagio, rischioso per malattie correlati all'asma" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.nl.json b/custom_components/thermal_comfort/translations/sensor.nl.json new file mode 100644 index 000000000..911980a3d --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.nl.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Geen", + "unlikely": "Onwaarschijnlijk", + "probable": "Waarschijnlijk", + "high": "Hoogstwaarschijnlijk" + }, + "thermal_comfort__thermal_perception": { + "dry": "Droog", + "very_comfortable": "Erg aangenaam", + "comfortable": "Aangenaam", + "ok_but_humid": "Goed, maar vochtig", + "somewhat_uncomfortable": "Minder aangenaam", + "quite_uncomfortable": "Erg vochtig, niet aangenaam", + "extremely_uncomfortable": "Erg onaangenaam, benauwd", + "severely_high": "Erg hoog, gevaarlijk voor astma gerelateerde ziekten" + }, + "thermal_comfort__simmer_zone": { + "cool": "Koel", + "slightly_cool": "Beetje koel", + "comfortable": "Aangenaam", + "slightly_warm": "Beetje warm", + "increasing_discomfort": "Toenemend onbehagelijk", + "extremely_warm": "Extreem warm", + "danger_of_heatstroke": "Gevaar voor zonnesteek", + "extreme_danger_of_heatstroke": "Extreem gevaar voor een zonnesteek", + "circulatory_collapse_imminent": "Gevaar voor de bloedsomloop" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.no.json b/custom_components/thermal_comfort/translations/sensor.no.json new file mode 100644 index 000000000..80779d54e --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.no.json @@ -0,0 +1,14 @@ +{ + "state": { + "thermal_comfort__thermal_perception": { + "dry": "Litt tørt", + "very_comfortable": "Veldig komfortabelt", + "comfortable": "Komfortabelt", + "ok_but_humid": "Greit, men fuktig", + "somewhat_uncomfortable": "Litt ukomfortabelt", + "quite_uncomfortable": "Veldig fuktig, ganske ukomfortabelt", + "extremely_uncomfortable": "Ekstremt ukomfortabelt, trykkende", + "severely_high": "Skadelig, dødelig for astmastikere" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.pl.json b/custom_components/thermal_comfort/translations/sensor.pl.json new file mode 100644 index 000000000..6a375d96f --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.pl.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Brak", + "unlikely": "Małe", + "probable": "Możliwe", + "high": "Wysokie" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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ść" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.pt-BR.json b/custom_components/thermal_comfort/translations/sensor.pt-BR.json new file mode 100644 index 000000000..5cec2817e --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.pt-BR.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Sem risco", + "unlikely": "Improvável", + "probable": "Provável", + "high": "Muito provável" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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" + } + } +} \ No newline at end of file diff --git a/custom_components/thermal_comfort/translations/sensor.pt.json b/custom_components/thermal_comfort/translations/sensor.pt.json new file mode 100644 index 000000000..4924a2262 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.pt.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Sem risco", + "unlikely": "Improvável", + "probable": "Provável", + "high": "Muito provável" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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/sensor.ru.json b/custom_components/thermal_comfort/translations/sensor.ru.json new file mode 100644 index 000000000..3893debff --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.ru.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Не опасно", + "unlikely": "Маловероятно", + "probable": "Вероятно", + "high": "Очень вероятно" + }, + "thermal_comfort__thermal_perception": { + "dry": "Сухо", + "very_comfortable": "Очень комфортно", + "comfortable": "Комфортно", + "ok_but_humid": "Нормально, но сыровато", + "somewhat_uncomfortable": "Слегка некомфортно", + "quite_uncomfortable": "Довольно влажно, некомфортно", + "extremely_uncomfortable": "Очень влажно, угнетающе", + "severely_high": "Очень высокая влажность, опасно для астматиков" + }, + "thermal_comfort__simmer_zone": { + "cool": "Холодно", + "slightly_cool": "Прохладно", + "comfortable": "Комфортно", + "slightly_warm": "Тепло", + "increasing_discomfort": "Жарко", + "extremely_warm": "Очень жарко", + "danger_of_heatstroke": "Риск теплового удара", + "extreme_danger_of_heatstroke": "Серьёзный риск теплового удара", + "circulatory_collapse_imminent": "Возможны сосудистые нарушения" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.sk.json b/custom_components/thermal_comfort/translations/sensor.sk.json new file mode 100644 index 000000000..eef8b0ad3 --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.sk.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Žiadne riziko", + "unlikely": "Nepravdepodobné", + "probable": "Pravdepodobné", + "high": "Vysoká pravdepodobnosť" + }, + "thermal_comfort__thermal_perception": { + "dry": "Pre niekoho suché", + "very_comfortable": "Velmi komfortné", + "comfortable": "Príjemné", + "ok_but_humid": "Pre vǎcsinu 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ť" + }, + "thermal_comfort__simmer_zone": { + "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" + } + } +} diff --git a/custom_components/thermal_comfort/translations/sensor.sv.json b/custom_components/thermal_comfort/translations/sensor.sv.json new file mode 100644 index 000000000..11f1f0fbf --- /dev/null +++ b/custom_components/thermal_comfort/translations/sensor.sv.json @@ -0,0 +1,31 @@ +{ + "state": { + "thermal_comfort__frost_risk": { + "no_risk": "Ingen risk", + "unlikely": "Osannolikt", + "probable": "Sannolikt", + "high": "Stor risk" + }, + "thermal_comfort__thermal_perception": { + "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" + }, + "thermal_comfort__simmer_zone": { + "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/mqtt.yaml b/mqtt.yaml new file mode 100644 index 000000000..5ce1ec888 --- /dev/null +++ b/mqtt.yaml @@ -0,0 +1,81 @@ +--- +sensor: + + # Weather # + - name: "Weather station temperature" + state_topic: "weatherstation" + unit_of_measurement: "°C" + value_template: "{{ value_json.temperature }}" + - name: "Weather station relative humidity" + state_topic: "weatherstation" + unit_of_measurement: "%" + value_template: "{{ value_json.relhumidity }}" + - name: "Weather station wind velocity" + state_topic: "weatherstation" + unit_of_measurement: "m/s" + value_template: "{{ value_json.windvelocity }}" + - name: "Weather station wind maximum" + state_topic: "weatherstation" + unit_of_measurement: "m/s" + value_template: "{{ value_json.windmaximum }}" + - name: "Weather station wind direction" + state_topic: "weatherstation" + unit_of_measurement: "" + value_template: "{{ value_json.winddirection }}" + - name: "Weather station rainfall" + state_topic: "weatherstation" + unit_of_measurement: "mm" + value_template: "{{ value_json.rain }}" + + - name: "Kyle Oneplus One Battery" + state_topic: "owntracks/bagpuss/a0001" + unit_of_measurement: "%" + value_template: "{{ value_json.batt }}" + +binary_sensor: + - name: Charlotte Bed Sensor + device_class: occupancy + state_topic: "devices/5ccf7fd8d915/Contact1/state" + payload_on: "1" + payload_off: "0" + + - name: Kyle Bed Sensor + device_class: occupancy + state_topic: "devices/5ccf7fd8d915/Contact2/state" + payload_on: "1" + payload_off: "0" + + - name: "Kyle Driving" + state_topic: "driving/mode/kyle" + payload_on: "true" + payload_off: "false" + + - name: "Charlotte Driving" + state_topic: "driving/mode/charlotte" + payload_on: "true" + payload_off: "false" + +switch: + - name: Octoprint Relay 1 + unique_id: octoprint_relay_1 + command_topic: "octoprint/usb_relay_1/set" + payload_on: 'true' + payload_off: 'false' + + - name: Octoprint Relay 2 + unique_id: octoprint_relay_2 + command_topic: "octoprint/usb_relay_2/set" + payload_on: 'true' + payload_off: 'false' + +camera: + - name: Back Door Last Person + topic: frigate/back_door/person/snapshot + - name: Front Door Last Person + topic: frigate/front_door/person/snapshot + - name: Driveway Last Person + topic: frigate/driveway/person/snapshot + - name: Driveway Last Car + topic: frigate/driveway/car/snapshot + - name: octoPrint camera + topic: octoPrint/camera diff --git a/packages/climate.yaml b/packages/climate.yaml index b7921c38b..56887ffad 100644 --- a/packages/climate.yaml +++ b/packages/climate.yaml @@ -92,8 +92,6 @@ automation: - condition: state entity_id: group.people state: "home" - - condition: template - value_template: '{{ states("sensor.season") != "summer" }}' action: - service: climate.set_preset_mode data: @@ -114,9 +112,6 @@ automation: - platform: state entity_id: group.people to: "home" - condition: - - condition: template - value_template: '{{ states("sensor.season") != "summer" }}' action: - service: climate.set_preset_mode data: diff --git a/packages/hallway.yaml b/packages/hallway.yaml index 5a47ce777..32565d891 100644 --- a/packages/hallway.yaml +++ b/packages/hallway.yaml @@ -1,10 +1,11 @@ +--- automation: - alias: Hallway light toggle trigger: - platform: state entity_id: - binary_sensor.hall_single_switch_touch_pad - - binary_sensor.hall_dual_switch_touchpad_1 + - binary_sensor.hall_dual_switch_touchpad_2 to: 'on' action: - service: switch.turn_on @@ -41,30 +42,30 @@ automation: - alias: Hallway motion - 2 minute night timeout trigger: - - platform: state - entity_id: - - binary_sensor.hall_door_motion - - binary_sensor.hall_rooms_motion - to: 'off' - for: - minutes: 2 + - platform: state + entity_id: + - binary_sensor.hall_door_motion + - binary_sensor.hall_rooms_motion + to: 'off' + for: + minutes: 2 condition: - condition: state entity_id: sensor.time_of_day state: "Night" action: - - service: light.turn_off - entity_id: group.hall + - service: light.turn_off + entity_id: group.hall - alias: Hallway motion - 10 minute timeout trigger: - - platform: state - entity_id: - - binary_sensor.hall_door_motion - - binary_sensor.hall_rooms_motion - to: 'off' - for: - minutes: 10 + - platform: state + entity_id: + - binary_sensor.hall_door_motion + - binary_sensor.hall_rooms_motion + to: 'off' + for: + minutes: 10 action: - - service: light.turn_off - entity_id: group.hall + - service: light.turn_off + entity_id: group.hall diff --git a/packages/living_room_blinds.yaml b/packages/living_room_blinds.yaml index 538bd2b44..6e0febc4c 100644 --- a/packages/living_room_blinds.yaml +++ b/packages/living_room_blinds.yaml @@ -30,6 +30,12 @@ automation: event_type: deconz_event event_data: id: tradfri_open_close_switch + device_id: 402b382edcbcbd22f65129957ce935f8 + - platform: event + event_type: deconz_event + event_data: + id: patio_blinds_switch_2 + device_id: 6ce0802a8aef13556ad7c2b8cb43720b action: - variables: event: "{{ trigger.event.data.event }}" @@ -67,8 +73,10 @@ automation: for: minutes: 10 action: - service: cover.close_cover - entity_id: cover.patio_blinds + - service: cover.close_cover + entity_id: + - cover.patio_blinds + - cover.front_hall_blind - alias: Open blinds when bright trigger: @@ -78,5 +86,7 @@ automation: for: minutes: 10 action: - service: cover.open_cover - entity_id: cover.patio_blinds + - service: cover.open_cover + entity_id: + - cover.patio_blinds + - cover.front_hall_blind diff --git a/packages/master_bathroom.yaml b/packages/master_bathroom.yaml index 5b87a4971..747bf1a5e 100644 --- a/packages/master_bathroom.yaml +++ b/packages/master_bathroom.yaml @@ -1,3 +1,4 @@ +--- binary_sensor: - platform: template sensors: @@ -25,7 +26,7 @@ automation: trigger: - platform: state entity_id: - - binary_sensor.hall_dual_switch_touchpad_2 + - binary_sensor.hall_dual_switch_touchpad_1 to: 'on' action: - service: switch.turn_on diff --git a/packages/snmp_bandwidth.yaml b/packages/snmp_bandwidth.yaml index a7817f237..8f91bdee5 100644 --- a/packages/snmp_bandwidth.yaml +++ b/packages/snmp_bandwidth.yaml @@ -49,10 +49,12 @@ sensor: - platform: statistics name: 'WAN Traffic In' + state_characteristic: mean entity_id: sensor.internet_speed_in - platform: statistics name: 'WAN Traffic Out' + state_characteristic: mean entity_id: sensor.internet_speed_out input_number: diff --git a/sensors.yaml b/sensors.yaml index 8cc0756e0..5f735e3b4 100644 --- a/sensors.yaml +++ b/sensors.yaml @@ -75,46 +75,6 @@ unit_prefix: k round: 2 -## MQTT ## - -# Weather # -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station temperature" - unit_of_measurement: "°C" - value_template: "{{ value_json.temperature }}" -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station relative humidity" - unit_of_measurement: "%" - value_template: "{{ value_json.relhumidity }}" -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station wind velocity" - unit_of_measurement: "m/s" - value_template: "{{ value_json.windvelocity }}" -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station wind maximum" - unit_of_measurement: "m/s" - value_template: "{{ value_json.windmaximum }}" -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station wind direction" - unit_of_measurement: "" - value_template: "{{ value_json.winddirection }}" -- platform: mqtt - state_topic: "weatherstation" - name: "Weather station rainfall" - unit_of_measurement: "mm" - value_template: "{{ value_json.rain }}" - -- platform: mqtt - state_topic: "owntracks/bagpuss/a0001" - name: "Kyle Oneplus One Battery" - unit_of_measurement: "%" - value_template: "{{ value_json.batt }}" - - platform: min_max name: Average External Light Level type: median diff --git a/switches.yaml b/switches.yaml index 0d70f79ef..995bd87eb 100644 --- a/switches.yaml +++ b/switches.yaml @@ -5,17 +5,3 @@ # - platform: broadlink # host: 172.24.32.41 # mac: 'b4:43:0d:f9:19:93' - -- platform: mqtt - name: Octoprint Relay 1 - unique_id: octoprint_relay_1 - command_topic: "octoprint/usb_relay_1/set" - payload_on: 'true' - payload_off: 'false' - -- platform: mqtt - name: Octoprint Relay 2 - unique_id: octoprint_relay_2 - command_topic: "octoprint/usb_relay_2/set" - payload_on: 'true' - payload_off: 'false' diff --git a/travis_secrets.yaml b/travis_secrets.yaml index 067dfb0af..3d6617794 100644 --- a/travis_secrets.yaml +++ b/travis_secrets.yaml @@ -2,9 +2,6 @@ db_url: mysql://root@127.0.0.1/homeassistant api_password: testpassword home_latitude: 0.0000 home_longitude: 0.0000 -google_api: secretgoogleapi -google_oauth_client_id: client_id -google_oauth_client_secret: client_secret gmail_username: gmail_username gmail_password: gmail_password charlotte_work_latitude: 0.0000 @@ -40,8 +37,6 @@ overuplawmoor_access_token: access_token overuplawmoor_access_token_secret: access_token_secret octoprint_host: 172.24.32.167:88 octoprint_key: abcdefghijklmnopqrstuvwxy -spotify_client_id: spotify_client_id -spotify_client_secret: spotify_client_secret influxdb_organization: organization_id influxdb_bucket: bucket_name influxdb_token: influxdb_token