diff --git a/custom_components/smartthinq_sensors/select.py b/custom_components/smartthinq_sensors/select.py index 068056b4..3002673e 100644 --- a/custom_components/smartthinq_sensors/select.py +++ b/custom_components/smartthinq_sensors/select.py @@ -49,6 +49,33 @@ class ThinQSelectEntityDescription( available_fn=lambda x: x.device.select_course_enabled, value_fn=lambda x: x.device.selected_course, ), + # ThinQSelectEntityDescription( + # key="temp_selection", + # name="Set Water Temp", + # icon="mdi:tune-vertical-variant", + # options_fn=lambda x: x.device.temps_list, + # select_option_fn=lambda x, option: x.device.select_start_temp(option), + # available_fn=lambda x: x.device.select_temp_enabled, + # value_fn=lambda x: x.device.selected_temp, + # ), + # ThinQSelectEntityDescription( + # key="rinse_selection", + # name="Set Rinse Option", + # icon="mdi:tune-vertical-variant", + # options_fn=lambda x: x.device.rinses_list, + # select_option_fn=lambda x, option: x.device.select_start_rinse(option), + # available_fn=lambda x: x.device.select_rinse_enabled, + # value_fn=lambda x: x.device.selected_rinse, + # ), + # ThinQSelectEntityDescription( + # key="spin_selection", + # name="Set Spin Speed", + # icon="mdi:tune-vertical-variant", + # options_fn=lambda x: x.device.spins_list, + # select_option_fn=lambda x, option: x.device.select_start_spin(option), + # available_fn=lambda x: x.device.select_spin_enabled, + # value_fn=lambda x: x.device.selected_spin, + # ), ) MICROWAVE_SELECT: tuple[ThinQSelectEntityDescription, ...] = ( ThinQSelectEntityDescription( diff --git a/custom_components/smartthinq_sensors/sensor.py b/custom_components/smartthinq_sensors/sensor.py index c51b7d5e..e166cb14 100644 --- a/custom_components/smartthinq_sensors/sensor.py +++ b/custom_components/smartthinq_sensors/sensor.py @@ -617,7 +617,10 @@ def _async_discover_device(lge_devices: dict) -> None: platform = current_platform.get() platform.async_register_entity_service( SERVICE_REMOTE_START, - {vol.Optional("course"): str}, + { + vol.Optional("course"): str, + vol.Optional("overrides"): str + }, "async_remote_start", [SUPPORT_WM_SERVICES], ) @@ -729,11 +732,15 @@ def _get_sensor_state(self): return None - async def async_remote_start(self, course: str | None = None): + async def async_remote_start( + self, + course: str | None = None, + overrides: str | None = None + ): """Call the remote start command for WM devices.""" if self._api.type not in WM_DEVICE_TYPES: raise NotImplementedError() - await self._api.device.remote_start(course) + await self._api.device.remote_start(course, overrides) async def async_wake_up(self): """Call the wakeup command for WM devices.""" diff --git a/custom_components/smartthinq_sensors/services.yaml b/custom_components/smartthinq_sensors/services.yaml index f5c10224..19985e47 100644 --- a/custom_components/smartthinq_sensors/services.yaml +++ b/custom_components/smartthinq_sensors/services.yaml @@ -7,12 +7,18 @@ remote_start: domain: sensor fields: course: - name: course - description: Course (if not set will use current) + name: Programme + description: The wash programme (course), if not set will use current. required: false selector: text: - + overrides: + name: Option overrides + description: A JSON dictionary containing the options to override and their new value as name:value pairs. + required: false + selector: + text: + wake_up: name: WakeUp description: Send to ThinQ device the wakeup command. diff --git a/custom_components/smartthinq_sensors/translations/en.json b/custom_components/smartthinq_sensors/translations/en.json index b90481eb..62a056cd 100644 --- a/custom_components/smartthinq_sensors/translations/en.json +++ b/custom_components/smartthinq_sensors/translations/en.json @@ -56,5 +56,28 @@ } }, "title": "SmartThinQ LGE Sensors" + }, + "exceptions": { + "remote_start_disabled": { + "message": "Machine is off, asleep or running, or remote start is not enabled." + }, + "course_name_required": { + "message": "Programme required if option overrides are specified." + }, + "invalid_json": { + "message": "Option overrides contains invalid JSON. {error}" + }, + "option_missing": { + "message": "Option overrides must contain at least one option from {friendly_list} or {internal_list}." + }, + "option_cannot_be_overridden": { + "message": "The programme {course} does not allow the option {option} to be overridden. Permitted options are: {friendly_list} or {internal_list}." + }, + "invalid_programme": { + "message": "The programme {name} is invalid. Permitted programmes are: {permitted}." + }, + "invalid_option_value": { + "message": "The value {value} is invalid and has been ignored. The value for the option {option} must be one of: {friendly_list} or {internal_list}." + } } } diff --git a/custom_components/smartthinq_sensors/wideq/devices/washerDryer.py b/custom_components/smartthinq_sensors/wideq/devices/washerDryer.py index bc7f37a9..be888401 100644 --- a/custom_components/smartthinq_sensors/wideq/devices/washerDryer.py +++ b/custom_components/smartthinq_sensors/wideq/devices/washerDryer.py @@ -1,10 +1,12 @@ """------------------for Washer and Dryer""" from __future__ import annotations +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import base64 from copy import deepcopy from enum import IntEnum +from collections import namedtuple import json import logging @@ -14,6 +16,7 @@ from ..core_exceptions import InvalidDeviceStatus from ..device import Device, DeviceStatus from ..device_info import DeviceInfo, DeviceType +from ...const import DOMAIN STATE_WM_POWER_OFF = "STATE_POWER_OFF" STATE_WM_INITIAL = "STATE_INITIAL" @@ -95,6 +98,8 @@ class CourseType(IntEnum): _COURSE_TYPE = "courseType" _CURRENT_COURSE = "Current course" +FriendlyName = namedtuple("FriendlyName", ["internal", "friendly"]) + class WMDevice(Device): """A higher-level interface for washer and dryer.""" @@ -124,13 +129,110 @@ def __init__( self._course_keys: dict[CourseType, str | None] | None = None self._course_infos: dict[str, str] | None = None self._selected_course: str | None = None + # For the selected course, the options that can be overridden, their permitted values + # and current value. Uses internal names. + # {'option internal name': { 'currently': value internal name, + # 'permitted: [value internal name, ...]}, ...} + # Initialised by select_start_course and _select_start_option. read in selected_*, + # _select_option_enabled, _select_start_option, _prepare_course_info and remote_start. + self._course_overrides: dict | None = {} + + # For some options and their values, map the internal name to and from a friendly name. + # The friendly name is extracted from the machine info language pack + # {'option internal name': {'friendly_name': option friendly name, + # 'enum_values': {'value internal name': 'value friendly name', ...} + self._option_friendly_names: dict | None = {} self._is_cycle_finishing = False self._stand_by = False self._remote_start_status: dict | None = None self._remote_start_pressed = False self._power_on_available: bool = None self._initial_bit_start: bool = False + + def _build_friendly_enum_value(self, option: str): + """Add to friendly names dictionary the enum friendly values for 'option'.""" + ##jl + ##_LOGGER.debug('self.model_info._data: %s',self.model_info._data) + if self._option_friendly_names.get(option) != None : + return + if not self.model_info.is_enum_type(option): + return + + # Some emums (e.g. spin) are not incrementally indexed + friendly_enum_values = {} + for index in range(256): + if encoded_value := self.model_info.enum_index(option, index): + internal_value = self.model_info.enum_value(option, encoded_value) + friendly_value = self.get_enum_text(encoded_value) + friendly_enum_values[internal_value] = friendly_value + + friendly_enum = {} + friendly_enum['friendly_name'] = self.get_enum_text( + self.model_info._data_root(option).get('label', option)) + friendly_enum['enum_values'] = friendly_enum_values + self._option_friendly_names[option] = friendly_enum + ##jl + _LOGGER.debug('self._option_friendly_names: %s',self._option_friendly_names) + + def _convert_option_name(self, option_str: str) -> FriendlyName | None: + """Convert an option name to its internal and friendly forms.""" + # If option matches a key, it is the internal name + internal_name = option_str + if friendly_name := self._option_friendly_names.get(internal_name): + return FriendlyName(internal_name, + friendly_name.get('friendly_name', internal_name)) + + # Search for a matching friendly name + friendly_name = option_str + for internal_name in self._option_friendly_names: + if self._option_friendly_names[internal_name].get('friendly_name','') == friendly_name: + return FriendlyName(internal_name, friendly_name) + + # option does not match an internal or friendly option name + return None + + def _convert_option_value(self, option_str: str, value_str: str) -> FriendlyName | None: + """Convert an option value to its internal and friendly forms.""" + if not(option := self._convert_option_name(option_str)): + return None + + if friendly_names := self._option_friendly_names.get(option.internal): + enum_values = friendly_names['enum_values'] + + #If value matches a key, it is the internal value + internal_value = value_str + if internal_value in enum_values: + return FriendlyName(internal_value, + enum_values.get(internal_value, '')) + + # Search for a matching friendly name + friendly_value = value_str + for key in enum_values: + if enum_values[key] == friendly_value: + return FriendlyName(key, friendly_value) + + # value does not match an internal or friendly option name + return None + + def _get_friendly_names_list(self, internal_names: list) -> list | None: + """Convert a list of internal enum names to list of friendly names.""" + friendly_names = [] + for internal_name in internal_names: + if option := self._convert_option_name(internal_name): + friendly_names.append(option.friendly) + return friendly_names + + def _get_friendly_values_list(self, option_str: str, internal_values: list) -> list | None: + """Convert a list of enum internal values to list of friendly values.""" + if not(option := self._convert_option_name(option_str)): + return None + friendly_values = [] + for value_str in internal_values: + if value := self._convert_option_value(option.internal, value_str): + friendly_values.append(value.friendly) + return friendly_values + @cached_property def _state_power_off(self): """Return native value for power off state.""" @@ -167,6 +269,50 @@ def selected_course(self) -> str: """Return current selected course.""" return self._selected_course or _CURRENT_COURSE + # @cached_property + # def temps_list(self) -> list: + # """Return a list of available water temperatures for the selected course.""" + # self._build_friendly_enum_value('temp') + #JL + # _LOGGER.debug('_option_friendly_names: %s', self._option_friendly_names) + # return list(self._option_friendly_names['temp']['enum_values'].values()) + + # @property + # def selected_temp(self) -> str: + # """Return current selected water temperature.""" + # value = self._convert_option_value( + # 'temp', + # self._course_overrides.get('temp',{}).get('currently')) + # return value.friendly + + # @cached_property + # def rinses_list(self) -> list: + # """Return a list of available rinse options for the selected course.""" + # self._build_friendly_enum_value('rinse') + # return list(self._option_friendly_names['rinse']['enum_values'].values()) + + # @property + # def selected_rinse(self) -> str: + # """Return current selected rinse option.""" + # value = self._convert_option_value( + # 'rinse', + # self._course_overrides.get('rinse',{}).get('currently')) + # return value.friendly + + # @cached_property + # def spins_list(self) -> list: + # """Return a list of available spin speeds for the selected course.""" + # self._build_friendly_enum_value('spin') + # return list(self._option_friendly_names['spin']['enum_values'].values()) + + # @property + # def selected_spin(self) -> str: + # """Return current selected spin speed.""" + # value = self._convert_option_value( + # 'spin', + # self._course_overrides.get('spin',{}).get('currently')) + # return value.friendly + @property def run_state(self) -> str: """Return calculated pre state.""" @@ -365,7 +511,6 @@ def _prepare_course_info( s_course_key: str | None, ) -> dict: """Prepare the course info used to run the command.""" - ret_data = deepcopy(data) # Prepare the course data initializing option for infoV1 device @@ -418,6 +563,11 @@ def _prepare_course_info( continue ret_data[ckey] = cdata + # If an override is defeined then apply it + if override_value := self._course_overrides.get(ckey,{}).get('currently'): + ret_data[ckey] = override_value + _LOGGER.debug("_prepare_course_info, course data override: %s: %s", ckey, ret_data[ckey]) + if not course_set: ret_data[VT_CTRL_COURSE_INFO] = course_info @@ -705,9 +855,94 @@ async def select_start_course(self, course_name: str) -> None: self._selected_course = None return if course_name not in self.course_list: - raise ValueError(f"Invalid course: {course_name}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_programme", + translation_placeholders={ + "name": "'"+str(course_name)+"'", + "permitted": str(list(self.course_list)) + } + ) self._selected_course = course_name + # For the selected course save the permitted values for setting that can be overridden + course_id = self._get_course_infos().get(self._selected_course) + n_course_key = self.get_course_key(CourseType.COURSE) + course_info = self._get_course_details(n_course_key, course_id) + if not course_info: + raise ValueError("Course info not available") + + self._course_overrides.clear() + for func_key in course_info["function"]: + value = func_key.get("value") + default = func_key.get("default") + selectable = func_key.get("selectable") + if selectable is None: + continue + + self._build_friendly_enum_value(value) + self._course_overrides[value] = {'currently': default, 'permitted': selectable} + _LOGGER.debug("select_start_course(%s), set overrides for %s to %s", course_name, value, self._course_overrides[value]) + + def _select_option_enabled(self, select_name: str) -> bool: + """Return if specified option select is enabled.""" + enabled = self.select_course_enabled and self._selected_course and self._course_overrides.get(select_name) + if (not enabled) and (select_name in self._course_overrides): + del self._course_overrides[select_name] + return enabled + + def _select_start_option(self, option_str: str, value_str: str) -> None: + """ + Add option to the list of remote start overrides and set its value. + - option may be a friendly or internal name + - value may be a friendly or internal name + """ + option = self._convert_option_name(option_str) + value = self._convert_option_value(option.internal, value_str) + + # _course_overrides_lists, _course_overrides and permitted_options use internal names + permitted_options = self._course_overrides.get(option.internal,{}).get('permitted') + if value and permitted_options and (value.internal in permitted_options): + self._course_overrides[option.internal]['currently'] = value.internal + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_option_value", + translation_placeholders={ + "value": "'"+str(value_str)+"'", + "option": str(option.friendly), + "friendly_list": str(self._get_friendly_values_list(option.internal, permitted_options)), + "internal_list": str(list(permitted_options)) + } + ) + + # @property + # def select_temp_enabled(self) -> bool: + # """Return if select temp is enabled.""" + # return self._select_option_enabled('temp') + + # async def select_start_temp(self, temp_name: str) -> None: + # """Select a secific water temperature for remote start.""" + # self._select_start_option('temp', temp_name) + + # @property + # def select_rinse_enabled(self) -> bool: + # """Return if select rinse is enabled.""" + # return self._select_option_enabled('rinse') + + # async def select_start_rinse(self, rinse_name: str) -> None: + # """Select a secific rinse option for remote start.""" + # self._select_start_option('rinse', rinse_name) + + # @property + # def select_spin_enabled(self) -> bool: + # """Return if select spin is enabled.""" + # return self._select_option_enabled('spin') + + # async def select_start_spin(self, spin_name: str) -> None: + # """Select a secific spin for remote start.""" + # self._select_start_option('spin', spin_name) + async def power_off(self): """Power off the device.""" keys = self._get_cmd_keys(CMD_POWER_OFF) @@ -725,14 +960,60 @@ async def wake_up(self): self._stand_by = False self._update_status(POWER_STATUS_KEY, self._state_power_on_init) - async def remote_start(self, course_name: str | None = None) -> None: + async def remote_start(self, course_name: str | None = None, overrides_json: str | None = None) -> None: """Remote start the device.""" if not self.remote_start_enabled: - raise InvalidDeviceStatus() + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="remote_start_disabled" + ) if course_name and self._initial_bit_start: await self.select_start_course(course_name) + if overrides_json: + if not course_name: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="course_name_required" + ) + + try: overrides = json.loads(overrides_json) + except json.JSONDecodeError as error: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_json", + translation_placeholders={"error": str(error)} + ) from error + + allowed_overrides = self._course_overrides.keys() + if not overrides: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="option_missing", + translation_placeholders={ + "friendly_list": str(self._get_friendly_names_list(list(allowed_overrides))), + "internal_list": str(list(allowed_overrides)) + } + ) + + for option_str in overrides: + option = self._convert_option_name(option_str) + if option and (option.internal in allowed_overrides): + value_str = overrides.get(option_str) + self._select_start_option(option.internal, value_str) + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="option_cannot_be_overridden", + translation_placeholders={ + "course": str(course_name), + "option": "'"+str(option_str)+"'", + "friendly_list": str(self._get_friendly_names_list(list(allowed_overrides))), + "internal_list": str(list(allowed_overrides)) + } + ) + keys = self._get_cmd_keys(CMD_REMOTE_START) await self.set(keys[0], keys[1], key=keys[2]) self._remote_start_pressed = True diff --git a/info.md b/info.md index c5f75349..fbc81529 100644 --- a/info.md +++ b/info.md @@ -28,7 +28,18 @@ If during configuration you receive the message "No SmartThinQ devices found", p **Important 2**: If you receive an "Invalid Credential" error during component configuration/startup, check in the LG mobile app if is requested to accept new Term Of Service. -**Note**: some device status may not be correctly detected, this depends on the model. I'm working to map all possible status developing the component in a way to allow to configure model option in the simplest possible way and provide update using Pull Requests. I will provide a guide on how update this information. +**Note**: some device status may not be correctly detected, this depend on the model. I'm working to map all possible status developing the component in a way to allow to configure model option in the simplest possible way and provide update using Pull Requests. I will provide a guide on how update this information. + +**Washer-Dryer remote start**: The component provides entities to select a course and override some of the course settings, before remotely starting the machine. The overrides available and their permitted values depend on the selected course. Attempts to set an invalid value for an override are ignored and result in an error message pop-up on the lovelace UI. To remotely start the washer perform the following steps in order: + +- Turn on the washer and enable remote start using its front panel. This is an LG safety feature that is also required for the LG app. +- Select a course. +- Optionally, select a value for the course setting (e.g. water temperature) you would like to override. +- "Press" the Washer Remote Start button. + +Nothing will happen/change on the washer and the component sensor entities will not show your selected course or overrides, until you press Remote Start. This is the same behaviour as the LG app. + +Please note, remote start feature override was developed for use in scripts and automations. If you use the locelace UI and select an invalid override value, it will incorrectly be shown as selected. In fact, it has been ignored and you must refresh the page to see the currently selected value. Pull requests that fix this issue are welcome. ## Component configuration