diff --git a/CODEOWNERS b/CODEOWNERS index 816dfc1e3bd..d76167bf372 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -197,6 +197,7 @@ /bundles/org.openhab.binding.lcn/ @fwolter /bundles/org.openhab.binding.leapmotion/ @kaikreuzer /bundles/org.openhab.binding.lghombot/ @FluBBaOfWard +/bundles/org.openhab.binding.lgthinq/ @nemerdaud /bundles/org.openhab.binding.lgtvserial/ @fa2k /bundles/org.openhab.binding.lgwebos/ @sprehn /bundles/org.openhab.binding.lifx/ @wborn diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 9548e10e61b..d7ba2c25fcd 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -971,6 +971,11 @@ org.openhab.binding.lghombot ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.lgthinq + ${project.version} + org.openhab.addons.bundles org.openhab.binding.lgtvserial diff --git a/bundles/org.openhab.binding.lgthinq/NOTICE b/bundles/org.openhab.binding.lgthinq/NOTICE new file mode 100644 index 00000000000..443a6b2cff1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/NOTICE @@ -0,0 +1,25 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons + +== Third-party Content + +jackson +* License: Apache 2.0 License +* Project: https://github.com/FasterXML/jackson +* Source: https://github.com/FasterXML/jackson + +Wiremock +* License: Apache 2.0 License +* Project: https://github.com/wiremock/wiremock +* Source: https://github.com/wiremock/wiremock diff --git a/bundles/org.openhab.binding.lgthinq/README.md b/bundles/org.openhab.binding.lgthinq/README.md new file mode 100644 index 00000000000..4baba810d05 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/README.md @@ -0,0 +1,256 @@ +# LG ThinQ Binding + +This binding was developed to integrate the LG ThinQ API with openHAB. + +## Supported Things + +This binding support several devices from the LG ThinQ Devices V1 & V2 line. +All devices require a configured Bridge. +See the table bellow: + +| Thing ID | Device Name | Versions | Special Functions | Commands | Obs | +|---------------------|-----------------|----------|------------------------------|-------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| air-conditioner-401 | Air Conditioner | V1 & V2 | Filter and Energy Monitoring | All features in LG App, except Wind Direction | | +| dishwasher-204 | Dish Washer | V2 | None | None | Provide only some channels to follow the cycle | +| dryer-tower-222 | Dryer Tower | V1 & V2 | None | All features in LG App (including remote start) | LG has a WasherDryer Tower that is 2 in one device.
When this device is discovered by this binding, it's recognized as 2 separated devices Washer and Dryer | +| washer-tower-221 | Washer Tower | V1 & V2 | None | All features in LG App (including remote start) | LG has a WasherDryer Tower that is 2 in one device.
When this device is discovered by this binding, it's recognized as 2 separated devices Washer and Dryer | +| washer-201 | Washer Machine | V1 & V2 | None | All features in LG App (including remote start) | | +| dryer-202 | Dryer Machine | V1 & V2 | None | All features in LG App (including remote start) | | +| fridge-101 | Refrigerator | V1 & V2 | None | All features in LG App | | +| heatpump-401HP | Heat Pump | V1 & V2 | None | All features in LG App | | + +## `bridge` Thing + +This binding has a Bridge responsible for discovering and registering LG Things. +Thus, adding the Bridge (LGThinq GW Bridge) is the first step in configuring this Binding. +The following parameters are available to configure the Bridge and to link to your LG Account as well: + +| Bridge Parameter | Label | Description | Obs | +|--------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| language | User Language | More frequent languages used | If you choose other, you can fill Manual user language (only if your language was not pre-defined in this combo | +| country | User Country | More frequent countries used | If you choose other, you can fill Manual user country (only if your country was not pre-defined in this combo | +| manualLanguage | Manual User Language | The acronym for the language (PT, EN, IT, etc) | | +| manualCountry | Manual User Country | The acronym for the country (UK, US, BR, etc) | | +| username | LG User name | The LG user's account (normally an email) | | +| password | LG Password | The LG user's password | | +| pollingIntervalSec | Polling Discovery Interval | It the time (in seconds) that the bridge wait to try to fetch de devices registered to the user's account and, if find some new device, will show available to link. Please, choose some long time greater than 300 seconds | +| alternativeServer | Alt Gateway Server | Only used if you have some proxy to the LG API Server or for Mock Tests | | + +## Discovery + +This Binding has auto-discovery for the supported LG Thinq devices. +Once LG Thinq Bridge has been added, LG Thinq devices linked to your account will be automatically discovered and displayed in the openHAB Inbox. + +## Thing Configuration + +All the configurations are pre-defined by the discovery process. +But you can customize to fine-tune the device's state polling process. +See the table below: + +| Parameter | Description | Default Value | Supported Devices | +|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|-------------------------------| +| pollingPeriodPowerOffSeconds | Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. | 10 | All | +| pollingPeriodPowerOnSeconds | Seconds to wait to the next polling for device state (dashboard channels) | 10 | All | +| pollingExtraInfoPeriodSeconds | Seconds to wait to the next polling for Device's Extra Info (energy consumption, remaining filter, etc) | 60 | Air Conditioner and Heat Pump | +| pollExtraInfoOnPowerOff | If enables, extra info will be fetched in the polling process even when the device is powered off. It's not so common, since extra info are normally changed only when the device is running. | Off | Air Conditioner and Heat Pump | + +## Channels + +### Air Conditioner + +Most, but not all, LG ThinQ Air Conditioners support the following channels: + +#### Dashboard Channels + +| channel # | channel | type | description | +|---------------------|--------------------|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| target-temperature | Target Temperature | Number:Temperature | Defines the desired target temperature for the device | +| current-temperature | Temperature | Number:Temperature | Read-Only channel that indicates the current temperature informed by the device | +| fan-speed | Fan Speed | Number | This channel let you choose the current label value for the fan speed (Low, Medium, High, Auto, etc.). These values are pre-configured in discovery time. | +| op-mode | Operation Mode | Number (Labeled) | Defines device's operation mode (Fan, Cool, Dry, etc). These values are pre-configured at discovery time. | +| power | Power | Switch | Define the device's Power state. | +| cool-jet | Cool Jet | Switch | Switch Cool Jet ON/OFF | +| auto-dry | Auto Dry | Switch | Switch Auto Dry ON/OFF | +| energy-saving | Energy Saving | Switch | Switch Energy Saving ON/OFF | +| fan-step-up-down | Fan VDir | Number | Define the fan vertical direction's mode (Off, Upper, Circular, Up, Middle Up, etc) | +| fan-step-left-right | Fan HDir | Number | Define the fan horizontal direction's mode (Off, Lefter, Left, Circular, Right, etc) | + +#### More Information Channel + +| channel # | channel | type | description | +|----------------------|--------------------------------|----------------------|------------------------------------------------------------------------------| +| extra-info-collector | Enable Extended Info Collector | Switch | Enable/Disable the extra information collector to update the bellow channels | +| current-energy | Current Energy | Number:Energy | The Current Energy consumption in Kwh | +| remaining-filter | Remaining Filter | Number:Dimensionless | Percentage of the remaining filter | + +### Heat Pump + +LG ThinQ Heat Pump supports the following channels + +#### Dashboard Channels + +| channel # | channel | type | description | +|---------------------|---------------------|--------------------|-----------------------------------------------------------------------------------------------------------| +| target-temperature | Target Temperature | Number:Temperature | Defines the desired target temperature for the device | +| min-temperature | Minimum Temperature | Number:Temperature | Minimum temperature for the current operation mode | +| max-temperature | Maximum Temperature | Number:Temperature | Maximum temperature for the current operation mode | +| current-temperature | Temperature | Number:Temperature | Read-Only channel that indicates the current temperature informed by the device | +| op-mode | Operation Mode | Number (Labeled) | Defines device's operation mode (Fan, Cool, Dry, etc). These values are pre-configured at discovery time. | +| power | Power | Switch | Define the device's Current Power state. | +| air-water-switch | Air/Water Switch | Switch | Switch the heat pump operation between Air or Water | + +#### More Information Channel + +| channel # | channel | type | description | +|----------------------|--------------------------------|----------------------|------------------------------------------------------------------------------| +| extra-info-collector | Enable Extended Info Collector | Switch | Enable/Disable the extra information collector to update the bellow channels | +| current-energy | Current Energy | Number:Energy | The Current Energy consumption in Kwh | + +### Washer Machine + +LG ThinQ Washer Machine supports the following channels + +#### Dashboard Channels + +| channel # | channel | type | description | +|--------------------|-------------------|------------|------------------------------------------------------------------------------------------------------------| +| state | Washer State | String | General State of the Washer | +| power | Power | Switch | Define the device's Current Power state. | +| process-state | Process State | String | States of the running cycle | +| course | Course | String | Course set up to work | +| temperature-level | Temperature Level | String | Temperature level supported by the Washer (Cold, 20, 30, 40, 50, etc.) | +| door-lock | Door Lock | Switch | Display if the Door is Locked. | +| rinse | Rinse | String | The Rinse set program | +| spin | Spin | String | The Spin set option | +| delay-time | Delay Time | String | Delay time programmed to start the cycle | +| remain-time | Remaining Time | String | Remaining time to finish the course | +| stand-by | Stand By Mode | Switch | If the Washer is in stand-by-mode | +| rs-flag | Remote Start | Switch | If the Washer is in remote start mode waiting to be remotely started | + +#### Remote Start Option + +This Channel Group is only available if the Washer is configured to Remote Start + +| channel # | channel | type | description | +|----------------------|-------------------|--------------------|---------------------------------------------------------------------------------------------------------| +| rs-start-stop | Remote Start/Stop | Switch | Switch to control if you want to start/stop the cycle remotely | +| rs-course | Course to Run | String (Selection) | The pre-programmed course (or default) is shown. You can change-it if you want before remote start | +| rs-temperature-level | Temperature Level | String (Selection) | The pre-programmed temperature (or default) is shown. You can change-it if you want before remote start | +| rs-spin | Spin | String | The pre-programmed spin (or default) is shown. You can change-it if you want before remote start | +| rs-rinse | Rinse | String | The pre-programmed rinse (or default) is shown. You can change-it if you want before remote start | + +### Dryer Machine + +LG ThinQ Dryer Machine supports the following channels + +#### Dashboard Channels + +| channel # | channel | type | description | +|--------------------|-------------------|---------|------------------------------------------------------------------------| +| power | Power | Switch | Define the device's Current Power state. | +| state | Dryer State | String | General State of the Washer | +| process-state | Process State | String | States of the running cycle | +| course | Course | String | Course set up to work | +| temperature-level | Temperature Level | String | Temperature level supported by the Washer (Cold, 20, 30, 40, 50, etc.) | +| child-lock | Child Lock | Switch | Display if the Door is Locked. | +| dry-level | Dry Level Course | String | Dry level set to work in the course | +| delay-time | Delay Time | String | Delay time programmed to start the cycle | +| remain-time | Remaining Time | String | Remaining time to finish the course | +| stand-by | Stand By Mode | Switch | If the Washer is in stand-by-mode | +| rs-flag | Remote Start | Switch | If the Washer is in remote start mode waiting to be remotely started | + +#### Remote Start Option + +This Channel Group is only available if the Dryer is configured to Remote Start + +| channel # | channel | type | description | +|---------------|-------------------|--------------------|---------------------------------------------------------------------------------------------------------| +| rs-start-stop | Remote Start/Stop | Switch | Switch to control if you want to start/stop the cycle remotely | +| rs-course | Course to Run | String (Selection) | The pre-programmed course (or default) is shown. You can change-it if you want before remote start | + +### Dryer/Washer Tower + +LG ThinQ Dryer/Washer is recognized as 2 different things: Dryer & Washer machines. +Thus, for this device, follow the paragraph's for Dryer Machine and Washer Machine + +### Refrigerator + +LG ThinQ Refrigerator supports the following channels + +#### Dashboard Channels + +| channel # | channel | type | description | +|----------------------|-------------------------------|--------------------|--------------------------------------------------------------------------------| +| door-open | Door Open | Contact | Advice if the door is opened | +| freezer-temperature | Freezer Set Point Temperature | Number:Temperature | Temperature level chosen. This channel supports commands to change temperature | +| fridge-temperature | Fridge Set Point Temperature | Number:Temperature | Temperature level chosen. This channel supports commands to change temperature | +| temp-unit | Temp. Unit | String | Temperature Unit (°C/F). Supports command to change the unit | +| express-mode | Express Freeze | Switch | Channel to change the express freeze function (ON/OFF/Rapid) | +| express-cool-mode | Express Cool | Switch | Channel to switch ON/OFF express cool function | +| eco-friendly-mode | Vacation | Switch | Channel to switch ON/OFF Vacation function (unit will work in eco mode) | + +#### More Information + +This Channel Group is reports useful information data for the device: + +| channel # | channel | type | description | +|---------------------|------------------|-----------|------------------------------------------------------------| +| fresh-air-filter | Fresh Air Filter | String | Shows the Fresh Air filter status (OFF/AUTO/POWER/REPLACE) | +| water-filter | Water Filter | String | Shows the filter's used months | + +OBS: some versions of this device can not support all the channels, depending on the model's capabilities. + +## Full Example + +Example of how to configure a thing. + +### Example `demo.things` + +```java +Bridge lgthinq:bridge:MyLGThinqBridge [ username="user@registered.com", password="cleartext-password", language="en", country="US", poolingIntervalSec=600] { + Thing air-conditioner-401 myAC [ modelUrlInfo="", deviceId="", platformType="", modelId="", deviceAlias="" ] +} +``` + +Until now, there is no way to easily obtain the values of ac-model-url, device-id, platform-type and model-id. So, if you really need +to configure the LGThinq thing textually, I suggest you to first add it with the UI discovery process through the LG Thinq Bridge, then after, copy +these properties from the thing created and complete the textual configuration. + +Here are some examples on how to map the channels to items. + +### Example `demo.items` + +```java +Switch ACPower "Power" { channel="lgthinq:air-conditioner-401:myAC:dashboard#power" } +Number ACOpMode "Operation Mode" { channel="lgthinq:air-conditioner-401:myAC:dashboard#op-mode" } +Number:Temperature ACTargetTemp "Target Temperature" { channel="lgthinq:air-conditioner-401:myAC:dashboard#target-temperature" } +Number:Temperature ACCurrTemp "Temperature" { channel="lgthinq:air-conditioner-401:myAC:dashboard#current-temperature" } +Number ACFanSpeed "Fan Speed" { channel="lgthinq:air-conditioner-401:myAC:dashboard#fan-speed" } +Switch ACCoolJet "CoolJet" { channel="lgthinq:air-conditioner-401:myAC:dashboard#cool-jet" } +Switch ACAutoDry "Auto Dry" { channel="lgthinq:air-conditioner-401:myAC:dashboard#auto-dry" } +Switch ACEnSaving "Energy Saving" { channel="lgthinq:air-conditioner-401:myAC:dashboard#emergy-saving" } +Number ACFanVDir "Vertical Direction" { channel="lgthinq:air-conditioner-401:myAC:dashboard#fan-step-up-down" } +``` + +### Example `demo.sitemap` + +All the channels already have StateDescription for the selection Channels. So, unless you want to rename theirs into demo.items, +you can simply define as Selection that the default description of the values will be displayed. + +```perl + +sitemap demo label="Air Conditioner" +{ + Frame label="Dashboard" { + Switch item=ACPower + Selection item=ACOpMode + Selection item=ACTargetTemp + Text item=ACCurrTemp + Selection item=ACFanSpeed + Switch item=ACCoolJet + Switch item=ACAutoDry + Switch item=ACEnSaving + Selection item=ACFanSpeed + } +} +``` diff --git a/bundles/org.openhab.binding.lgthinq/doc/bridge-configuration.jpg b/bundles/org.openhab.binding.lgthinq/doc/bridge-configuration.jpg new file mode 100644 index 00000000000..80f2587d339 Binary files /dev/null and b/bundles/org.openhab.binding.lgthinq/doc/bridge-configuration.jpg differ diff --git a/bundles/org.openhab.binding.lgthinq/doc/lg-thinq-air.jpg b/bundles/org.openhab.binding.lgthinq/doc/lg-thinq-air.jpg new file mode 100644 index 00000000000..ad3e8912b8a Binary files /dev/null and b/bundles/org.openhab.binding.lgthinq/doc/lg-thinq-air.jpg differ diff --git a/bundles/org.openhab.binding.lgthinq/pom.xml b/bundles/org.openhab.binding.lgthinq/pom.xml new file mode 100644 index 00000000000..b7d53dfb372 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.0.0-SNAPSHOT + + + org.openhab.binding.lgthinq + + openHAB Add-ons :: Bundles :: LG Thinq Binding + + + + com.github.tomakehurst + wiremock-jre8 + 2.32.0 + test + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/feature/feature.xml b/bundles/org.openhab.binding.lgthinq/src/main/feature/feature.xml new file mode 100644 index 00000000000..d9ccd7fb9b2 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/feature/feature.xml @@ -0,0 +1,11 @@ + + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.lgthinq/${project.version} + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBindingConstants.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBindingConstants.java new file mode 100644 index 00000000000..4328af5a247 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBindingConstants.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal; + +import java.io.File; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.LGServicesConstants; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.core.OpenHAB; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link LGThinQBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQBindingConstants extends LGServicesConstants { + + public static final String BINDING_ID = "lgthinq"; + + // =============== Thing Configuration Constants =========== + public static final String CFG_POLLING_PERIOD_POWER_ON_SEC = "pollingPeriodPowerOnSeconds"; + public static final String CFG_POLLING_PERIOD_POWER_OFF_SEC = "pollingPeriodPowerOffSeconds"; + public static final String CFG_POLLING_EXTRA_INFO_PERIOD_SEC = "pollingExtraInfoPeriodSeconds"; + public static final String CFG_POLLING_EXTRA_INFO_ON_POWER_OFF = "pollExtraInfoOnPowerOff"; + // =============== Thing Type IDs ================== + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "cloud-account"); + public static final ThingTypeUID THING_TYPE_AIR_CONDITIONER = new ThingTypeUID(BINDING_ID, + DeviceTypes.AIR_CONDITIONER.thingTypeId()); + public static final ThingTypeUID THING_TYPE_WASHING_MACHINE = new ThingTypeUID(BINDING_ID, + DeviceTypes.WASHERDRYER_MACHINE.thingTypeId()); + public static final ThingTypeUID THING_TYPE_WASHING_TOWER = new ThingTypeUID(BINDING_ID, + DeviceTypes.WASHER_TOWER.thingTypeId()); + public static final ThingTypeUID THING_TYPE_DRYER = new ThingTypeUID(BINDING_ID, DeviceTypes.DRYER.thingTypeId()); + public static final ThingTypeUID THING_TYPE_HEAT_PUMP = new ThingTypeUID(BINDING_ID, + DeviceTypes.HEAT_PUMP.thingTypeId()); + public static final ThingTypeUID THING_TYPE_DRYER_TOWER = new ThingTypeUID(BINDING_ID, + DeviceTypes.DRYER_TOWER.thingTypeId()); + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_AIR_CONDITIONER, + THING_TYPE_WASHING_MACHINE, THING_TYPE_WASHING_TOWER, THING_TYPE_DRYER, THING_TYPE_DRYER_TOWER, + THING_TYPE_HEAT_PUMP); + public static final ThingTypeUID THING_TYPE_FRIDGE = new ThingTypeUID(BINDING_ID, DeviceTypes.FRIDGE.thingTypeId()); + public static final ThingTypeUID THING_TYPE_DISHWASHER = new ThingTypeUID(BINDING_ID, + DeviceTypes.DISH_WASHER.thingTypeId()); + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIR_CONDITIONER, + THING_TYPE_WASHING_MACHINE, THING_TYPE_WASHING_TOWER, THING_TYPE_DRYER_TOWER, THING_TYPE_DRYER, + THING_TYPE_FRIDGE, THING_TYPE_BRIDGE, THING_TYPE_HEAT_PUMP, THING_TYPE_DISHWASHER); + // ======== Common Channels & Constants ======== + public static final String CHANNEL_DASHBOARD_GRP_ID = "dashboard"; + public static final String CHANNEL_DASHBOARD_GRP_WITH_SEP = "dashboard#"; + public static final String CHANNEL_EXTENDED_INFO_GRP_ID = "extended-information"; + public static final String CHANNEL_EXTENDED_INFO_COLLECTOR_ID = "extra-info-collector"; + // Max number of retries trying to get the monitor (V1) until consider ERROR in the connection + public static final int MAX_GET_MONITOR_RETRIES = 3; + public static final int DISCOVERY_SEARCH_TIMEOUT = 20; + // === Biding property info + public static final String PROP_INFO_DEVICE_ALIAS = "deviceAlias"; + public static final String PROP_INFO_DEVICE_ID = "deviceId"; + public static final String PROP_INFO_MODEL_URL_INFO = "modelUrlInfo"; + public static final String PROP_INFO_PLATFORM_TYPE = "platformType"; + public static final String PROP_INFO_MODEL_ID = "modelId"; + + // === UserData Directory and File Format + private static final String DEFAULT_USER_DATA_FOLDER = OpenHAB.getUserDataFolder() + File.separator + "thinq"; + + public static String getThinqUserDataFolder() { + return Objects.requireNonNullElse(System.getProperty("THINQ_USER_DATA_FOLDER"), DEFAULT_USER_DATA_FOLDER); + } + + public static String getThinqConnectionDataFile() { + return Objects.requireNonNullElse(System.getProperty("THINQ_CONNECTION_DATA_FILE"), + getThinqUserDataFolder() + File.separator + "thinqbridge-%s.json"); + } + + public static String getBaseCapConfigDataFile() { + return Objects.requireNonNullElse(System.getProperty("BASE_CAP_CONFIG_DATA_FILE"), + getThinqUserDataFolder() + File.separator + "thinq-%s-cap.json"); + } + + // ==================================================== + + /** + * ============ Air Conditioner Channels & Constant Definition ============= + */ + public static final String CHANNEL_AC_AIR_CLEAN_ID = "air-clean"; + public static final String CHANNEL_AC_AIR_WATER_SWITCH_ID = "air-water-switch"; + public static final String CHANNEL_AC_AUTO_DRY_ID = "auto-dry"; + public static final String CHANNEL_AC_COOL_JET_ID = "cool-jet"; + public static final String CHANNEL_AC_CURRENT_ENERGY_ID = "current-energy"; + public static final String CHANNEL_AC_CURRENT_TEMP_ID = "current-temperature"; + public static final String CHANNEL_AC_ENERGY_SAVING_ID = "energy-saving"; + public static final String CHANNEL_AC_FAN_SPEED_ID = "fan-speed"; + public static final String CHANNEL_AC_MAX_TEMP_ID = "max-temperature"; + public static final String CHANNEL_AC_MIN_TEMP_ID = "min-temperature"; + public static final String CHANNEL_AC_MOD_OP_ID = "op-mode"; + public static final String CHANNEL_AC_POWER_ID = "power"; + public static final String CHANNEL_AC_REMAINING_FILTER_ID = "remaining-filter"; + public static final String CHANNEL_AC_STEP_LEFT_RIGHT_ID = "fan-step-left-right"; + public static final String CHANNEL_AC_STEP_UP_DOWN_ID = "fan-step-up-down"; + public static final String CHANNEL_AC_TARGET_TEMP_ID = "target-temperature"; + + /** + * ============ Refrigerator's Channels & Constant Definition ============= + */ + public static final String CHANNEL_FR_ACTIVE_SAVING = "active-saving"; + public static final String CHANNEL_FR_DOOR_OPEN = "door-open"; + public static final String CHANNEL_FR_EXPRESS_COOL_MODE = "express-cool-mode"; + public static final String CHANNEL_FR_EXPRESS_FREEZE_MODE = "express-mode"; + public static final String CHANNEL_FR_FREEZER_TEMP_ID = "freezer-temperature"; + public static final String CHANNEL_FR_FRESH_AIR_FILTER = "fresh-air-filter"; + public static final String CHANNEL_FR_FRIDGE_TEMP_ID = "fridge-temperature"; + public static final String CHANNEL_FR_ICE_PLUS = "ice-plus"; + public static final String CHANNEL_FR_REF_TEMP_UNIT = "temp-unit"; + public static final String CHANNEL_FR_SMART_SAVING_MODE_V2 = "smart-saving-mode"; + public static final String CHANNEL_FR_SMART_SAVING_SWITCH_V1 = "smart-saving-switch"; + public static final String CHANNEL_FR_VACATION_MODE = "eco-friendly-mode"; + public static final String CHANNEL_FR_WATER_FILTER = "water-filter"; + + /** + * ============ Washing Machine/Dryer and DishWasher Channels & Constant Definition ============= + * DishWasher, Washing Machine and Dryer have the same channel core and features + */ + public static final String CHANNEL_WMD_CHILD_LOCK_ID = "child-lock"; + public static final String CHANNEL_WMD_DRY_LEVEL_ID = "dry-level"; + public static final String CHANNEL_WMD_COURSE_ID = "course"; + public static final String CHANNEL_WMD_DELAY_TIME_ID = "delay-time"; + public static final String CHANNEL_WMD_DOOR_LOCK_ID = "door-lock"; + public static final String CHANNEL_WMD_PROCESS_STATE_ID = "process-state"; + public static final String CHANNEL_WMD_REMAIN_TIME_ID = "remain-time"; + public static final String CHANNEL_WMD_REMOTE_COURSE = "rs-course"; + public static final String CHANNEL_WMD_REMOTE_START_GRP_ID = "rs-grp"; + public static final String CHANNEL_WMD_REMOTE_START_ID = "rs-flag"; + public static final String CHANNEL_WMD_REMOTE_START_START_STOP = "rs-start-stop"; + public static final String CHANNEL_WMD_RINSE_ID = "rinse"; + public static final String CHANNEL_WMD_SMART_COURSE_ID = "smart-course"; + public static final String CHANNEL_WMD_SPIN_ID = "spin"; + public static final String CHANNEL_WMD_STAND_BY_ID = "stand-by"; + public static final String CHANNEL_WMD_STATE_ID = "state"; + public static final String CHANNEL_WMD_TEMP_LEVEL_ID = "temperature-level"; + public static final String CHANNEL_WMD_REMOTE_START_RINSE = "rs-rinse"; + public static final String CHANNEL_WMD_REMOTE_START_SPIN = "rs-spin"; + public static final String CHANNEL_WMD_REMOTE_START_TEMP = "rs-temperature-level"; + + // ============================================================================== + // DIGEST CONSTANTS + public static final String MESSAGE_DIGEST_ALGORITHM = "SHA-512"; + public static final String HMAC_SHA1_ALGORITHM = "HmacSHA1"; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBridgeConfiguration.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBridgeConfiguration.java new file mode 100644 index 00000000000..88a229c213e --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQBridgeConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinQBridgeConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQBridgeConfiguration { + /** + * Sample configuration parameters. Replace with your own. + */ + public String username = ""; + public String password = ""; + public String country = ""; + public String language = ""; + public String manualCountry = ""; + public String manualLanguage = ""; + public int pollingIntervalSec = 0; + public String alternativeServer = ""; + + public LGThinQBridgeConfiguration() { + } + + public LGThinQBridgeConfiguration(String username, String password, String country, String language, + int pollingIntervalSec, String alternativeServer) { + this.username = username; + this.password = password; + this.country = country; + this.language = language; + this.pollingIntervalSec = pollingIntervalSec; + this.alternativeServer = alternativeServer; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getCountry() { + if ("--".equals(country)) { + return manualCountry; + } + return country; + } + + public String getLanguage() { + if ("--".equals(language)) { + return manualLanguage; + } + return language; + } + + public int getPollingIntervalSec() { + return pollingIntervalSec; + } + + public String getAlternativeServer() { + return alternativeServer; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQHandlerFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQHandlerFactory.java new file mode 100644 index 00000000000..0ce90222690 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQHandlerFactory.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.internal.handler.LGThinQAirConditionerHandler; +import org.openhab.binding.lgthinq.internal.handler.LGThinQBridgeHandler; +import org.openhab.binding.lgthinq.internal.handler.LGThinQDishWasherHandler; +import org.openhab.binding.lgthinq.internal.handler.LGThinQFridgeHandler; +import org.openhab.binding.lgthinq.internal.handler.LGThinQWasherDryerHandler; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory responsible for creating {@link ThingHandler} instances for LG ThinQ devices. + * This factory supports various appliance types and maps each to its corresponding handler. + * It extends {@link BaseThingHandlerFactory} and implements {@link ThingHandlerFactory}. + * + *

+ * Supported device types include: + *

+ *
    + *
  • Air Conditioners
  • + *
  • Heat Pumps
  • + *
  • Washing Machines & Towers
  • + *
  • Dryers & Dryer Towers
  • + *
  • Refrigerators
  • + *
  • Dishwashers
  • + *
  • Bridges
  • + *
+ * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@Component(service = ThingHandlerFactory.class, configurationPid = "binding.lgthinq") +public class LGThinQHandlerFactory extends BaseThingHandlerFactory { + + private final Logger logger = LoggerFactory.getLogger(LGThinQHandlerFactory.class); + + private final HttpClientFactory httpClientFactory; + private final LGThinQStateDescriptionProvider stateDescriptionProvider; + private final ItemChannelLinkRegistry itemChannelLinkRegistry; + + /** + * Constructs the handler factory with required dependencies. + * + * @param stateDescriptionProvider Provides state descriptions for ThinQ devices. + * @param httpClientFactory Handles HTTP requests for API communication. + * @param itemChannelLinkRegistry Manages item-channel links for device integration. + */ + @Activate + public LGThinQHandlerFactory(@Reference LGThinQStateDescriptionProvider stateDescriptionProvider, + @Reference HttpClientFactory httpClientFactory, + @Reference ItemChannelLinkRegistry itemChannelLinkRegistry) { + this.stateDescriptionProvider = stateDescriptionProvider; + this.httpClientFactory = httpClientFactory; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + } + + /** + * Determines whether this factory supports the given {@link ThingTypeUID}. + * + * @param thingTypeUID The Thing type to check. + * @return {@code true} if the type is supported, {@code false} otherwise. + */ + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + /** + * Creates the appropriate {@link ThingHandler} for the specified {@link Thing}. + * + * @param thing The Thing to create a handler for. + * @return The corresponding handler instance, or {@code null} if the type is unsupported. + */ + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_AIR_CONDITIONER.equals(thingTypeUID) || THING_TYPE_HEAT_PUMP.equals(thingTypeUID)) { + return new LGThinQAirConditionerHandler(thing, stateDescriptionProvider, + Objects.requireNonNull(itemChannelLinkRegistry), httpClientFactory); + } else if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { + return new LGThinQBridgeHandler((Bridge) thing, httpClientFactory); + } else if (THING_TYPE_WASHING_MACHINE.equals(thingTypeUID) || THING_TYPE_WASHING_TOWER.equals(thingTypeUID) + || THING_TYPE_DRYER.equals(thingTypeUID) || THING_TYPE_DRYER_TOWER.equals(thingTypeUID)) { + return new LGThinQWasherDryerHandler(thing, stateDescriptionProvider, + Objects.requireNonNull(itemChannelLinkRegistry), httpClientFactory); + } else if (THING_TYPE_FRIDGE.equals(thingTypeUID)) { + return new LGThinQFridgeHandler(thing, stateDescriptionProvider, + Objects.requireNonNull(itemChannelLinkRegistry), httpClientFactory); + } else if (THING_TYPE_DISHWASHER.equals(thingTypeUID)) { + return new LGThinQDishWasherHandler(thing, stateDescriptionProvider, + Objects.requireNonNull(itemChannelLinkRegistry), httpClientFactory); + } + + logger.warn("Unsupported Thing type: {}", thingTypeUID.getId()); + return null; + } + + /** + * Creates a new {@link Thing} instance based on its type and configuration. + * + * @param thingTypeUID The Thing type. + * @param configuration The initial configuration. + * @param thingUID The unique identifier for the Thing (nullable). + * @param bridgeUID The bridge's UID, if applicable (nullable). + * @return The created Thing instance, or {@code null} if unsupported. + */ + @Override + public @Nullable Thing createThing(ThingTypeUID thingTypeUID, Configuration configuration, + @Nullable ThingUID thingUID, @Nullable ThingUID bridgeUID) { + if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { + return super.createThing(thingTypeUID, configuration, thingUID, null); + } + + if (THING_TYPE_AIR_CONDITIONER.equals(thingTypeUID) || THING_TYPE_HEAT_PUMP.equals(thingTypeUID) + || THING_TYPE_WASHING_MACHINE.equals(thingTypeUID) || THING_TYPE_WASHING_TOWER.equals(thingTypeUID) + || THING_TYPE_DRYER.equals(thingTypeUID) || THING_TYPE_DRYER_TOWER.equals(thingTypeUID) + || THING_TYPE_FRIDGE.equals(thingTypeUID) || THING_TYPE_DISHWASHER.equals(thingTypeUID)) { + return super.createThing(thingTypeUID, configuration, thingUID, bridgeUID); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQStateDescriptionProvider.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQStateDescriptionProvider.java new file mode 100644 index 00000000000..941ba2369bc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/LGThinQStateDescriptionProvider.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.DynamicStateDescriptionProvider; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link LGThinQStateDescriptionProvider} Custom State Description Provider + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@Component(service = { DynamicStateDescriptionProvider.class, LGThinQStateDescriptionProvider.class }) +public class LGThinQStateDescriptionProvider extends BaseDynamicStateDescriptionProvider { + @Activate + public LGThinQStateDescriptionProvider(final @Reference EventPublisher eventPublisher, // + final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, // + final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/discovery/LGThinqDiscoveryService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/discovery/LGThinqDiscoveryService.java new file mode 100644 index 00000000000..4dbbf29824d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/discovery/LGThinqDiscoveryService.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.discovery; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.internal.handler.LGThinQBridgeHandler; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory.LGThinQGeneralApiClientService; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.LGDevice; +import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinqDiscoveryService} - Responsable to discovery new LG Thinq Devices for the registered Bridge + * + * @author Nemer Daud - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = LGThinqDiscoveryService.class) +@NonNullByDefault +public class LGThinqDiscoveryService extends AbstractThingHandlerDiscoveryService { + + private final Logger logger = LoggerFactory.getLogger(LGThinqDiscoveryService.class); + private @Nullable ThingUID bridgeHandlerUID; + private @Nullable LGThinQGeneralApiClientService lgApiClientService; + + public LGThinqDiscoveryService() { + super(LGThinQBridgeHandler.class, SUPPORTED_THING_TYPES, DISCOVERY_SEARCH_TIMEOUT); + } + + @Override + public void initialize() { + bridgeHandlerUID = thingHandler.getThing().getUID(); + // thingHandler is the LGThinQBridgeHandler + thingHandler.registerDiscoveryListener(this); + lgApiClientService = LGThinQApiClientServiceFactory + .newGeneralApiClientService(thingHandler.getHttpClientFactory()); + super.initialize(); + } + + @Override + protected void startScan() { + logger.debug("Scan started"); + // thingHandler is the LGThinQBridgeHandler + thingHandler.runDiscovery(); + } + + @Override + protected synchronized void stopScan() { + super.stopScan(); + removeOlderResults(getTimestampOfLastScan(), thingHandler.getThing().getUID()); + } + + public void removeLgDeviceDiscovery(LGDevice device) { + logger.debug("Thing removed from discovery: {}", device.getDeviceId()); + try { + ThingUID thingUID = getThingUID(device); + thingRemoved(thingUID); + } catch (LGThinqException e) { + logger.warn("Error getting Thing UID"); + } + } + + public void addLgDeviceDiscovery(LGDevice device) { + logger.debug("Thing added to discovery: {}", device.getDeviceId()); + String modelId = device.getModelName(); + ThingUID thingUID; + ThingTypeUID thingTypeUID; + try { + // load capability to cache and troubleshooting + Objects.requireNonNull(lgApiClientService, "Unexpected null here") + .loadDeviceCapability(device.getDeviceId(), device.getModelJsonUri(), false); + thingUID = getThingUID(device); + thingTypeUID = getThingTypeUID(device); + } catch (LGThinqException e) { + logger.debug("Discovered unsupported LG device of type '{}'({}) and model '{}' with id {}", + device.getDeviceType(), device.getDeviceTypeId(), modelId, device.getDeviceId()); + return; + } + + Map properties = new HashMap<>(); + properties.put(PROP_INFO_DEVICE_ID, device.getDeviceId()); + properties.put(PROP_INFO_DEVICE_ALIAS, device.getAlias()); + properties.put(PROP_INFO_MODEL_URL_INFO, device.getModelJsonUri()); + properties.put(PROP_INFO_PLATFORM_TYPE, device.getPlatformType()); + properties.put(PROP_INFO_MODEL_ID, modelId); + + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID) + .withProperties(properties).withBridge(bridgeHandlerUID).withRepresentationProperty(PROP_INFO_DEVICE_ID) + .withLabel(device.getAlias()).build(); + + thingDiscovered(discoveryResult); + } + + /** + * Normalizes the Thing UID by replacing any non-alphanumeric characters with a hyphen. + * + * @param uid the original Thing UID to be normalized + * @return the normalized Thing UID with non-alphanumeric characters replaced by hyphens + */ + private String normalizeThingUID(String uid) { + return uid.replaceAll("[^a-zA-Z0-9]", "-"); + } + + private ThingUID getThingUID(LGDevice device) throws LGThinqException { + ThingTypeUID thingTypeUID = getThingTypeUID(device); + return new ThingUID(thingTypeUID, + Objects.requireNonNull(bridgeHandlerUID, "bridgeHandleUid should never be null here"), + normalizeThingUID(device.getDeviceId())); + } + + private ThingTypeUID getThingTypeUID(LGDevice device) throws LGThinqException { + // Short switch, but is here because it is going to be increase after new LG Devices were added + return switch (device.getDeviceType()) { + case AIR_CONDITIONER -> THING_TYPE_AIR_CONDITIONER; + case HEAT_PUMP -> THING_TYPE_HEAT_PUMP; + case WASHERDRYER_MACHINE -> THING_TYPE_WASHING_MACHINE; + case WASHER_TOWER -> THING_TYPE_WASHING_TOWER; + case DRYER_TOWER -> THING_TYPE_DRYER_TOWER; + case DRYER -> THING_TYPE_DRYER; + case FRIDGE -> THING_TYPE_FRIDGE; + case DISH_WASHER -> THING_TYPE_DISHWASHER; + default -> + throw new LGThinqException(String.format("device type [%s] not supported", device.getDeviceType())); + }; + } + + @Override + public void dispose() { + super.dispose(); + removeOlderResults(Instant.now().toEpochMilli(), bridgeHandlerUID); + thingHandler.unregisterDiscoveryListener(); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/BaseThingWithExtraInfoHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/BaseThingWithExtraInfoHandler.java new file mode 100644 index 00000000000..4eeb7b3618b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/BaseThingWithExtraInfoHandler.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.BaseThingHandler; + +/** + * The {@link BaseThingWithExtraInfoHandler} contains method definitions to the Handle be able to work + * with extra info data. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class BaseThingWithExtraInfoHandler extends BaseThingHandler { + /** + * Creates a new instance of this class for the {@link Thing}. + * + * @param thing the thing that should be handled, not null + */ + public BaseThingWithExtraInfoHandler(Thing thing) { + super(thing); + } + + /** + * Handle must implement this method to update device's extra information collected to the respective channels. + * + * @param energyStateAttributes map containing the key and values collected + */ + protected void updateExtraInfoStateChannels(Map energyStateAttributes) { + throw new UnsupportedOperationException( + "Method must be implemented in the Handle that supports energy collector. It is most likely a bug"); + } + + /** + * Must be implemented with the code to get energy state if the thing supports it. + * + * @return map containing energy state attributes + */ + protected Map collectExtraInfoState() throws LGThinqException { + throw new UnsupportedOperationException( + "Method must be implemented in the Handle that supports energy collector. It is most likely a bug"); + } + + /** + * Reset (put in UNDEF) the channels related to extra information. Normally called when the collector stops. + */ + protected void resetExtraInfoChannels() { + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAbstractDeviceHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAbstractDeviceHandler.java new file mode 100644 index 00000000000..95f271f181e --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAbstractDeviceHandler.java @@ -0,0 +1,816 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; +import java.lang.reflect.Constructor; +import java.lang.reflect.ParameterizedType; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.internal.LGThinQStateDescriptionProvider; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqAccessException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiExhaustionException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1MonitorExpiredException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1OfflineException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDataType; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; +import org.openhab.binding.lgthinq.lgservices.model.SnapshotDefinition; +import org.openhab.core.items.Item; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.ChannelKind; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; + +/** + * The {@link LGThinQAbstractDeviceHandler} is a main interface contract for all LG Thinq things + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class LGThinQAbstractDeviceHandler<@NonNull C extends CapabilityDefinition, @NonNull S extends SnapshotDefinition> + extends BaseThingWithExtraInfoHandler { + private static final Set BRIDGE_STATUS_DETAIL_ERROR = Set.of(ThingStatusDetail.BRIDGE_OFFLINE, + ThingStatusDetail.BRIDGE_UNINITIALIZED, ThingStatusDetail.COMMUNICATION_ERROR, + ThingStatusDetail.CONFIGURATION_ERROR); + protected final String lgPlatformType; + protected final ItemChannelLinkRegistry itemChannelLinkRegistry; + protected final LinkedBlockingQueue commandBlockQueue = new LinkedBlockingQueue<>(30); + protected final LGThinQStateDescriptionProvider stateDescriptionProvider; + private final ExecutorService executorService = Executors.newFixedThreadPool(1); + private final ScheduledExecutorService pollingScheduler = Executors.newScheduledThreadPool(1); + protected @Nullable LGThinQBridgeHandler account; + @Nullable + protected C thinQCapability; + private S lastShot; + private @Nullable Future commandExecutorQueueJob; + private @Nullable ScheduledFuture thingStatePollingJob; + private @Nullable ScheduledFuture extraInfoCollectorPollingJob; + /** + * Defined in the configurations of the thing. + */ + private int pollingPeriodOnSeconds = 10; + private int pollingPeriodOffSeconds = 10; + private int currentPeriodSeconds = 10; + private int pollingExtraInfoPeriodSeconds = 60; + private boolean pollExtraInfoOnPowerOff = false; + private Integer fetchMonitorRetries = 0; + private boolean monitorV1Began = false; + private boolean isThingReconfigured = false; + private String monitorWorkId = ""; + private String bridgeId = ""; + private ThingStatus lastThingStatus = ThingStatus.UNKNOWN; + private final Runnable queuedCommandExecutor = () -> { + while (!Thread.currentThread().isInterrupted()) { + AsyncCommandParams params; + try { + params = commandBlockQueue.take(); + } catch (InterruptedException e) { + getLogger().debug("Interrupting async command queue executor."); + return; + } + + try { + processCommand(params); + String channelUid = getSimpleChannelUID(params.channelUID); + if (CHANNEL_AC_POWER_ID.equals(channelUid)) { + // if processed command come from POWER channel, then force updateDeviceChannels immediatly + // this is important to analise if the polling needs to be changed in time. + updateThingStateFromLG(); + } else if (CHANNEL_EXTENDED_INFO_COLLECTOR_ID.equals(channelUid)) { + if (OnOffType.ON.equals(params.command)) { + getLogger().debug("Turning ON extended information collector"); + if (pollExtraInfoOnPowerOff + || DevicePowerState.DV_POWER_ON.equals(getLastShot().getPowerStatus())) { + startExtraInfoCollectorPolling(); + } + } else if (OnOffType.OFF.equals(params.command)) { + getLogger().debug("Turning OFF extended information collector"); + stopExtraInfoCollectorPolling(); + } else { + getLogger().error("Command {} for {} channel is unexpected. It's most likely a bug", + params.command, CHANNEL_EXTENDED_INFO_COLLECTOR_ID); + } + } + } catch (LGThinqException e) { + getLogger().error("Error executing Command {} to the channel {}. Thing goes offline until retry", + params.command, params.channelUID, e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } catch (Exception e) { + getLogger().error("System error executing Command {} to the channel {}. Ignoring command", + params.command, params.channelUID, e); + } + } + getLogger().debug("Finishing QueueCommandExecutor thread..."); + }; + + public LGThinQAbstractDeviceHandler(Thing thing, LGThinQStateDescriptionProvider stateDescriptionProvider, + ItemChannelLinkRegistry itemChannelLinkRegistry) { + super(thing); + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.stateDescriptionProvider = stateDescriptionProvider; + normalizeConfigurationsAndProperties(); + lgPlatformType = String.valueOf(thing.getProperties().get(PROP_INFO_PLATFORM_TYPE)); + + Class snapshotClass = getSnapshotClass(); + try { + Constructor constructor = snapshotClass.getDeclaredConstructor(); + this.lastShot = Objects.requireNonNull(constructor.newInstance(), + "Unexpected null returned from newInstance()"); + } catch (Exception e) { + throw new IllegalArgumentException("Snapshot class can't be instantiated. It is most likely a bug", e); + } + } + + protected S getLastShot() { + return Objects.requireNonNull(lastShot, "LastShot shouldn't be null. It is most likely a bug."); + } + + @SuppressWarnings("unchecked") + public Class getSnapshotClass() { + ParameterizedType genSupClass = (ParameterizedType) getClass().getGenericSuperclass(); + if (genSupClass == null) { + throw new IllegalStateException("Snapshot class has no parameterized type. It is most likely a bug!"); + } + return (Class) genSupClass.getActualTypeArguments()[1]; + } + + private void normalizeConfigurationsAndProperties() { + List.of(PROP_INFO_PLATFORM_TYPE, PROP_INFO_MODEL_URL_INFO, PROP_INFO_DEVICE_ID).forEach(p -> { + if (!thing.getProperties().containsKey(p)) { + thing.setProperty(p, (String) thing.getConfiguration().get(p)); + } + }); + } + + /** + * Returns the simple channel UID name, i.e., without group. + * + * @param uid Full UID name + * @return simple channel UID name, i.e., without group. + */ + protected String getSimpleChannelUID(String uid) { + String simpleChannelUID; + if (uid.indexOf("#") > 0) { + // I have to remove the channelGroup from de channelUID + simpleChannelUID = uid.split("#")[1]; + } else { + simpleChannelUID = uid; + } + return simpleChannelUID; + } + + /** + * Return empty string if null argument is passed + * + * @param value value to test + * @return empty string if null argument is passed + */ + protected final String emptyIfNull(@Nullable String value) { + return Objects.requireNonNullElse(value, ""); + } + + /** + * Return the key informed if there is no correpondent value in map for that key. + * + * @param map map with key/value + * @param key key to search for a value into map + * @return return value related to that key in the map, or the own key if there is no correspondent. + */ + protected final String keyIfValueNotFound(Map map, String key) { + return Objects.requireNonNullElse(map.get(key), key); + } + + protected void startCommandExecutorQueueJob() { + Future commandExecutorQueueJob = this.commandExecutorQueueJob; + if (commandExecutorQueueJob == null || commandExecutorQueueJob.isDone()) { + this.commandExecutorQueueJob = getExecutorService().submit(getQueuedCommandExecutor()); + } + } + + protected void stopCommandExecutorQueueJob() { + Future commandExecutorQueueJob = this.commandExecutorQueueJob; + if (commandExecutorQueueJob != null) { + commandExecutorQueueJob.cancel(true); + } + this.commandExecutorQueueJob = null; + } + + protected void handleStatusChanged(ThingStatus newStatus, ThingStatusDetail statusDetail) { + if (lastThingStatus != ThingStatus.ONLINE && newStatus == ThingStatus.ONLINE) { + // start the thing polling + startThingStatePolling(); + } else if (lastThingStatus == ThingStatus.ONLINE && newStatus == ThingStatus.OFFLINE + && BRIDGE_STATUS_DETAIL_ERROR.contains(statusDetail)) { + // comunication error is not a specific Bridge error, then we must analise it to give + // this thinq the change to recovery from communication errors + if (statusDetail != ThingStatusDetail.COMMUNICATION_ERROR + || (getBridge() instanceof Bridge bridge && bridge.getStatus() != ThingStatus.ONLINE)) { + stopThingStatePolling(); + stopExtraInfoCollectorPolling(); + } + } + lastThingStatus = newStatus; + } + + @Override + protected void updateStatus(ThingStatus newStatus, ThingStatusDetail statusDetail, @Nullable String description) { + handleStatusChanged(newStatus, statusDetail); + super.updateStatus(newStatus, statusDetail, description); + } + + @Override + + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateThingStateFromLG(); + } else { + AsyncCommandParams params = new AsyncCommandParams(channelUID.getId(), command); + try { + // Ensure commands are send in a pipe per device. + commandBlockQueue.add(params); + } catch (IllegalStateException ex) { + getLogger().warn( + "Device's command queue reached the size limit. Probably the device is busy or stuck. Ignoring command."); + if (getLogger().isDebugEnabled()) { + Future commandExecutorQueueJob = this.commandExecutorQueueJob; + getLogger().debug("Status of the commandQueue: consumer: {}, size: {}", + commandExecutorQueueJob == null || commandExecutorQueueJob.isDone() ? "OFF" : "ON", + commandBlockQueue.size()); + } + if (getLogger().isTraceEnabled()) { + // logging the thread dump to analise possible stuck thread. + ThreadMXBean bean = ManagementFactory.getThreadMXBean(); + ThreadInfo[] infos = bean.dumpAllThreads(true, true); + String message = ""; + for (ThreadInfo i : infos) { + message = String.format("%s\n%s", message, i.toString()); + } + getLogger().trace("{}", message); + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/error.handler.device-cmd-queue-busy"); + } + } + } + + protected ExecutorService getExecutorService() { + return executorService; + } + + public String getDeviceId() { + return Objects.requireNonNullElse(getThing().getProperties().get(PROP_INFO_DEVICE_ID), "undef"); + } + + public abstract String getDeviceAlias(); + + public abstract String getDeviceUriJsonConfig(); + + public abstract void onDeviceRemoved(); + + public abstract void onDeviceDisconnected(); + + public abstract void updateChannelDynStateDescription() throws LGThinqApiException; + + public abstract LGThinQApiClientService<@NonNull C, @NonNull S> getLgThinQAPIClientService(); + + public C getCapabilities() throws LGThinqApiException { + if (thinQCapability == null) { + thinQCapability = getLgThinQAPIClientService().getCapability(getDeviceId(), getDeviceUriJsonConfig(), + false); + } + return Objects.requireNonNull(thinQCapability, "Unexpected error. Return of capability shouldn't ever be null"); + } + + /** + * Get the first item value associated to the channel + * + * @param channelUID channel + * @return value of the first item related to this channel. + */ + @Nullable + protected String getItemLinkedValue(ChannelUID channelUID) { + Set items = itemChannelLinkRegistry.getLinkedItems(channelUID); + if (!items.isEmpty()) { + for (Item i : items) { + return i.getState().toString(); + } + } + return null; + } + + protected abstract Logger getLogger(); + + @Override + public void initialize() { + getLogger().debug("Initializing Thinq thing."); + + final Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof LGThinQBridgeHandler bridgeHandler) { + this.account = bridgeHandler; + this.bridgeId = bridge.getUID().getId(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.communication-error.no-bridge-set"); + return; + } + + initializeThing(bridge.getStatus()); + } + + protected void initializeThing(@Nullable ThingStatus bridgeStatus) { + getLogger().debug("initializeThing LQ Thinq {}. Bridge status {}", getThing().getUID(), bridgeStatus); + String thingId = getThing().getUID().getId(); + + // setup configurations + loadConfigurations(); + + if (!thingId.isBlank()) { + try { + updateChannelDynStateDescription(); + } catch (LGThinqApiException e) { + getLogger().warn( + "Error updating channels dynamic options descriptions based on capabilities of the device. Fallback to default values.", + e); + } + // registry this thing to the bridge + var account = this.account; + if (account == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.communication-error.no-bridge-set"); + } else { + account.registryListenerThing(this); + if (bridgeStatus == null) { + updateStatus(ThingStatus.UNINITIALIZED); + } else { + switch (bridgeStatus) { + case ONLINE: + updateStatus(ThingStatus.ONLINE); + break; + case INITIALIZING: + case UNINITIALIZED: + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); + break; + case UNKNOWN: + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + break; + default: + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + break; + } + } + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.conf-error-no-device-id"); + } + // finally, start command queue, regardless of the thing state, since we can still try to send commands without + // property ONLINE (the successful result from command request can put the thing in ONLINE status). + startCommandExecutorQueueJob(); + if (getThing().getStatus() == ThingStatus.ONLINE) { + try { + getLgThinQAPIClientService().initializeDevice(bridgeId, getDeviceId()); + } catch (Exception e) { + getLogger().warn("Error initializing the device {} from bridge {}.", thingId, bridgeId, e); + } + // force start state pooling if the device is ONLINE + resetExtraInfoChannels(); + startThingStatePolling(); + } + } + + public void refreshStatus() { + if (thing.getStatus() == ThingStatus.OFFLINE) { + initialize(); + } + } + + private void loadConfigurations() { + isThingReconfigured = true; + Map props = getThing().getConfiguration().getProperties(); + pollingPeriodOnSeconds = (props.get(CFG_POLLING_PERIOD_POWER_ON_SEC) instanceof BigDecimal value) + ? value.intValue() + : pollingPeriodOnSeconds; + pollingPeriodOffSeconds = (props.get(CFG_POLLING_PERIOD_POWER_OFF_SEC) instanceof BigDecimal value) + ? value.intValue() + : pollingPeriodOffSeconds; + pollingExtraInfoPeriodSeconds = (props.get(CFG_POLLING_EXTRA_INFO_PERIOD_SEC) instanceof BigDecimal value) + ? value.intValue() + : pollingExtraInfoPeriodSeconds; + pollExtraInfoOnPowerOff = (props.get(CFG_POLLING_EXTRA_INFO_ON_POWER_OFF) instanceof Boolean value) ? value + : pollExtraInfoOnPowerOff; + // if the periods are the same, I can define currentPeriod for polling right now. If not, I postpone to the nest + // snapshot update + if (pollingPeriodOffSeconds == pollingPeriodOnSeconds) { + currentPeriodSeconds = pollingPeriodOffSeconds; + } + } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + getLogger().debug("bridgeStatusChanged {}", bridgeStatusInfo); + // restart scheduler + initializeThing(bridgeStatusInfo.getStatus()); + } + + /** + * Determine if the Handle for this device supports Energy State Collector + * + * @return always false and must be overridden if the implemented handler supports energy collector + */ + protected boolean isExtraInfoCollectorSupported() { + return false; + } + + /** + * Returns if the energy collector is enabled. The handle that supports energy collection must + * provide a logic that defines if the collector is currently enabled. Normally, it uses a Switch Channel + * to provide a way to the user turn on/off the collector. + * + * @return true if the energyCollector must be enabled. + */ + protected boolean isExtraInfoCollectorEnabled() { + return false; + } + + private void updateExtraInfoState() { + if (!isExtraInfoCollectorSupported()) { + getLogger().error( + "The Energy Collector was started for a Handler that doesn't support it. It is most likely a bug."); + return; + } + try { + Map extraInfoCollected = collectExtraInfoState(); + updateExtraInfoStateChannels(extraInfoCollected); + } catch (LGThinqException ex) { + getLogger().warn( + "Error getting energy state and update the correlated channels. DeviceName: {}, DeviceId: {}. Error: {}", + getDeviceAlias(), getDeviceId(), ex.getMessage(), ex); + } + } + + protected void updateThingStateFromLG() { + try { + @Nullable + S shot = getSnapshotDeviceAdapter(getDeviceId()); + if (shot == null) { + // no data to update. Maybe, the monitor stopped, then it's going to be restarted next try. + return; + } + fetchMonitorRetries = 0; + if (!shot.isOnline()) { + if (getThing().getStatus() != ThingStatus.OFFLINE) { + // only update channels if the device has just gone OFFLINE. + updateDeviceChannelsWrapper(shot); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.device-disconnected"); + onDeviceDisconnected(); + } + } else { + // do not update channels if the device is offline + updateDeviceChannelsWrapper(shot); + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } + } catch (LGThinqAccessException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } catch (LGThinqApiExhaustionException e) { + fetchMonitorRetries++; + getLogger().warn("LG API returns null monitoring data for the thing {}/{}. No data available yet ?", + getDeviceAlias(), getDeviceId()); + if (fetchMonitorRetries > MAX_GET_MONITOR_RETRIES) { + getLogger().error( + "The thing {}/{} reach maximum retries for monitor data. Thing goes OFFLINE until next retry.", + getDeviceAlias(), getDeviceId(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } catch (LGThinqException e) { + getLogger().error("Error updating thing {}/{} from LG API. Thing goes OFFLINE until next retry: {}", + getDeviceAlias(), getDeviceId(), e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } catch (Exception e) { + getLogger().error( + "System error in pooling thread (UpdateDevice) for device {}/{}. Filtering to do not stop the thread", + getDeviceAlias(), getDeviceId(), e); + } + } + + private void handlePowerChange(@Nullable DevicePowerState previous, DevicePowerState current) { + // isThingReconfigured is true when configurations has been updated or thing has just initialized + // this will force to analyse polling periods and starts + if (!isThingReconfigured && previous == current) { + // no changes needed + return; + } + + // change from OFF to ON / OFF to ON + boolean isEnableToStartCollector = isExtraInfoCollectorEnabled() && isExtraInfoCollectorSupported(); + + if (current == DevicePowerState.DV_POWER_ON) { + currentPeriodSeconds = pollingPeriodOnSeconds; + + // if extendedInfo collector is enabled, then force do start to prevent previous stop + if (isEnableToStartCollector) { + startExtraInfoCollectorPolling(); + } + } else { + currentPeriodSeconds = pollingPeriodOffSeconds; + + // if it's configured to stop extra-info collection on PowerOff, then stop the job + if (!pollExtraInfoOnPowerOff) { + stopExtraInfoCollectorPolling(); + } else if (isEnableToStartCollector) { + startExtraInfoCollectorPolling(); + } + } + + // restart thing state polling for the new poolingPeriod configuration + if (pollingPeriodOffSeconds != pollingPeriodOnSeconds) { + stopThingStatePolling(); + } + + startThingStatePolling(); + } + + private void updateDeviceChannelsWrapper(S snapshot) throws LGThinqApiException { + updateDeviceChannels(snapshot); + // handle power changes + handlePowerChange(getLastShot().getPowerStatus(), snapshot.getPowerStatus()); + // after updated successfully, copy snapshot to last snapshot + lastShot = snapshot; + // and finish the cycle of thing reconfiguration (when thing starts or has configurations changed - if it's the + // case) + isThingReconfigured = false; + } + + protected abstract void updateDeviceChannels(S snapshot) throws LGThinqApiException; + + protected String translateFeatureToItemType(FeatureDataType dataType) { + return switch (dataType) { + case UNDEF, ENUM -> CoreItemFactory.STRING; + case RANGE -> CoreItemFactory.DIMMER; + case BOOLEAN -> CoreItemFactory.SWITCH; + default -> throw new IllegalStateException( + String.format("Feature DataType %s not supported for this ThingHandler", dataType)); + }; + } + + protected void stopThingStatePolling() { + try { + ScheduledFuture thingStatePollingJob = this.thingStatePollingJob; + + if (!(thingStatePollingJob == null || thingStatePollingJob.isDone())) { + getLogger().debug("Stopping LG thinq polling for device/alias: {}/{}", getDeviceId(), getDeviceAlias()); + thingStatePollingJob.cancel(true); + } + this.thingStatePollingJob = null; + } catch (Exception ex) { + getLogger().warn("Unexpected error trying to cancel state polling job."); + } + } + + private void stopExtraInfoCollectorPolling() { + try { + ScheduledFuture extraInfoCollectorPollingJob = this.extraInfoCollectorPollingJob; + if (extraInfoCollectorPollingJob != null && !extraInfoCollectorPollingJob.isDone()) { + getLogger().debug("Stopping Energy Collector for device/alias: {}/{}", getDeviceId(), getDeviceAlias()); + extraInfoCollectorPollingJob.cancel(true); + } + resetExtraInfoChannels(); + this.extraInfoCollectorPollingJob = null; + } catch (Exception ex) { + getLogger().warn("Unexpected error trying to cancel extra info polling job."); + } + } + + protected void startThingStatePolling() { + ScheduledFuture thingStatePollingJob = this.thingStatePollingJob; + if (thingStatePollingJob == null || thingStatePollingJob.isDone()) { + getLogger().debug("Starting LG thinq polling for device/alias: {}/{}", getDeviceId(), getDeviceAlias()); + this.thingStatePollingJob = pollingScheduler.scheduleWithFixedDelay(new UpdateThingStateFromLG(), 5, + currentPeriodSeconds, TimeUnit.SECONDS); + } + } + + /** + * Method responsible for start the Energy Collector Polling. Must be called buy the handles when it's desired. + * Normally, the thing has a Switch Channel that enable/disable the energy collector. By default, the collector is + * disabled. + */ + private void startExtraInfoCollectorPolling() { + ScheduledFuture extraInfoCollectorPollingJob = this.extraInfoCollectorPollingJob; + if (extraInfoCollectorPollingJob == null || extraInfoCollectorPollingJob.isDone()) { + getLogger().debug("Starting Energy Collector for device/alias: {}/{}", getDeviceId(), getDeviceAlias()); + this.extraInfoCollectorPollingJob = pollingScheduler.scheduleWithFixedDelay(new UpdateExtraInfoCollector(), + 10, pollingExtraInfoPeriodSeconds, TimeUnit.SECONDS); + } + } + + private void stopDeviceV1Monitor(String deviceId) { + try { + monitorV1Began = false; + getLgThinQAPIClientService().stopMonitor(getBridgeId(), deviceId, monitorWorkId); + } catch (LGThinqDeviceV1OfflineException e) { + getLogger().debug("Monitor stopped. Device is unavailable/disconnected", e); + } catch (Exception e) { + getLogger().error("Error stopping LG Device monitor", e); + } + } + + protected String getBridgeId() { + if (bridgeId.isBlank() && getBridge() == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.communication-error.no-bridge-set"); + return "UNKNOWN"; + } else if (bridgeId.isBlank() && getBridge() != null) { + bridgeId = Objects.requireNonNull(getBridge()).getUID().getId(); + } + return bridgeId; + } + + protected abstract DeviceTypes getDeviceType(); + + @Nullable + protected S getSnapshotDeviceAdapter(String deviceId) throws LGThinqApiException, LGThinqApiExhaustionException { + // analise de platform version + if (LG_API_PLATFORM_TYPE_V2.equals(lgPlatformType)) { + return getLgThinQAPIClientService().getDeviceData(getBridgeId(), getDeviceId(), getCapabilities()); + } else { + try { + if (!monitorV1Began) { + monitorWorkId = getLgThinQAPIClientService().startMonitor(getBridgeId(), getDeviceId()); + monitorV1Began = true; + } + } catch (LGThinqDeviceV1OfflineException e) { + try { + stopDeviceV1Monitor(deviceId); + } catch (Exception ignored) { + } + return getLgThinQAPIClientService().buildDefaultOfflineSnapshot(); + } catch (Exception e) { + stopDeviceV1Monitor(deviceId); + throw new LGThinqApiException("Error starting device monitor in LG API for the device:" + deviceId, e); + } + int retries = 10; + @Nullable + S shot; + try { + while (retries > 0) { + // try to get monitoring data result 3 times. + + shot = getLgThinQAPIClientService().getMonitorData(getBridgeId(), deviceId, monitorWorkId, + getDeviceType(), getCapabilities()); + if (shot != null) { + return shot; + } + Thread.sleep(500); + retries--; + } + } catch (LGThinqDeviceV1MonitorExpiredException | LGThinqUnmarshallException e) { + getLogger().debug("Monitor for device {} is invalid. Forcing stop and start to next cycle.", deviceId); + return null; + } catch (Exception e) { + // If it can't get monitor handler, then stop monitor and restart the process again in new + // interaction + // Force restart monitoring because of the errors returned (just in case) + throw new LGThinqApiException("Error getting monitor data for the device:" + deviceId, e); + } finally { + try { + stopDeviceV1Monitor(deviceId); + } catch (Exception ignored) { + } + } + throw new LGThinqApiExhaustionException("Exhausted trying to get monitor data for the device:" + deviceId); + } + } + + protected abstract void processCommand(AsyncCommandParams params) throws LGThinqApiException; + + protected Runnable getQueuedCommandExecutor() { + return queuedCommandExecutor; + } + + @Override + public void dispose() { + getLogger().debug("Disposing Thinq Thing {}", getDeviceId()); + var account = this.account; + if (account != null) { + account.unRegistryListenerThing(this); + } + + stopThingStatePolling(); + stopExtraInfoCollectorPolling(); + stopCommandExecutorQueueJob(); + try { + if (LGAPIVerion.V1_0.equals(getCapabilities().getDeviceVersion())) { + stopDeviceV1Monitor(getDeviceId()); + } + } catch (Exception e) { + getLogger().warn("Can't stop active monitor. It's can be normally ignored. Cause:{}", e.getMessage()); + } + } + + /** + * Create Dynamic channel. The channel type must be pre-defined in the thing definition (xml) and with + * the same name as the channel. + * + * @param channelNameAndTypeName channel name to be created and the same channel type name defined in the channels + * descriptor + * @param channelUuid Uid of the channel + * @param itemType item type (see openhab documentation) + * @return return the new channel created + */ + protected Channel createDynChannel(String channelNameAndTypeName, ChannelUID channelUuid, String itemType) { + if (getCallback() == null) { + throw new IllegalStateException("Unexpected behaviour. Callback not ready! Can't create dynamic channels"); + } else { + // dynamic create channel + ChannelBuilder builder = Objects + .requireNonNull(getCallback(), "Not expected callback null here. It is most likely a bug") + .createChannelBuilder(channelUuid, new ChannelTypeUID(BINDING_ID, channelNameAndTypeName)); + Channel channel = builder.withKind(ChannelKind.STATE).withAcceptedItemType(itemType).build(); + updateThing(editThing().withChannel(channel).build()); + return channel; + } + } + + protected void manageDynChannel(ChannelUID channelUid, String channelName, String itemType, + boolean isFeatureAvailable) { + Channel chan = getThing().getChannel(channelUid); + if (chan == null && isFeatureAvailable) { + createDynChannel(channelName, channelUid, itemType); + } else if (chan != null && (!isFeatureAvailable)) { + updateThing(editThing().withoutChannel(chan.getUID()).build()); + } + } + + protected static class AsyncCommandParams { + final String channelUID; + final Command command; + + public AsyncCommandParams(String channelUUID, Command command) { + this.channelUID = channelUUID; + this.command = command; + } + } + + private class UpdateExtraInfoCollector implements Runnable { + @Override + public void run() { + updateExtraInfoState(); + } + } + + private class UpdateThingStateFromLG implements Runnable { + @Override + public void run() { + updateThingStateFromLG(); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAirConditionerHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAirConditionerHandler.java new file mode 100644 index 00000000000..8c939624804 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQAirConditionerHandler.java @@ -0,0 +1,467 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.internal.LGThinQStateDescriptionProvider; +import org.openhab.binding.lgthinq.lgservices.LGThinQACApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACTargetTmp; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ExtendedDeviceInfo; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.types.Command; +import org.openhab.core.types.StateOption; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link LGThinQAirConditionerHandler} Handle Air Conditioner and HeatPump Things + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQAirConditionerHandler extends LGThinQAbstractDeviceHandler { + + public final ChannelGroupUID channelGroupExtendedInfoUID; + public final ChannelGroupUID channelGroupDashboardUID; + private final ChannelUID powerChannelUID; + private final ChannelUID opModeChannelUID; + private final ChannelUID hpAirWaterSwitchChannelUID; + private final ChannelUID fanSpeedChannelUID; + private final ChannelUID targetTempChannelUID; + private final ChannelUID currTempChannelUID; + private final ChannelUID minTempChannelUID; + private final ChannelUID maxTempChannelUID; + private final ChannelUID jetModeChannelUID; + private final ChannelUID airCleanChannelUID; + private final ChannelUID autoDryChannelUID; + private final ChannelUID stepUpDownChannelUID; + private final ChannelUID stepLeftRightChannelUID; + private final ChannelUID energySavingChannelUID; + private final ChannelUID extendedInfoCollectorChannelUID; + private final ChannelUID currentEnergyConsumptionChannelUID; + private final ChannelUID remainingFilterChannelUID; + private final ObjectMapper mapper = new ObjectMapper(); + private final Logger logger = LoggerFactory.getLogger(LGThinQAirConditionerHandler.class); + private final LGThinQACApiClientService lgThinqACApiClientService; + private double minTempConstraint = 16, maxTempConstraint = 30; + + public LGThinQAirConditionerHandler(Thing thing, LGThinQStateDescriptionProvider stateDescriptionProvider, + ItemChannelLinkRegistry itemChannelLinkRegistry, HttpClientFactory httpClientFactory) { + super(thing, stateDescriptionProvider, itemChannelLinkRegistry); + lgThinqACApiClientService = LGThinQApiClientServiceFactory.newACApiClientService(lgPlatformType, + httpClientFactory); + channelGroupDashboardUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_DASHBOARD_GRP_ID); + channelGroupExtendedInfoUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_EXTENDED_INFO_GRP_ID); + + opModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_MOD_OP_ID); + hpAirWaterSwitchChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_AIR_WATER_SWITCH_ID); + targetTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_TARGET_TEMP_ID); + minTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_MIN_TEMP_ID); + maxTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_MAX_TEMP_ID); + currTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_CURRENT_TEMP_ID); + fanSpeedChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_FAN_SPEED_ID); + jetModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_COOL_JET_ID); + airCleanChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_AIR_CLEAN_ID); + autoDryChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_AUTO_DRY_ID); + energySavingChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_ENERGY_SAVING_ID); + stepUpDownChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_STEP_UP_DOWN_ID); + stepLeftRightChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_STEP_LEFT_RIGHT_ID); + powerChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_AC_POWER_ID); + extendedInfoCollectorChannelUID = new ChannelUID(channelGroupExtendedInfoUID, + CHANNEL_EXTENDED_INFO_COLLECTOR_ID); + currentEnergyConsumptionChannelUID = new ChannelUID(channelGroupExtendedInfoUID, CHANNEL_AC_CURRENT_ENERGY_ID); + remainingFilterChannelUID = new ChannelUID(channelGroupExtendedInfoUID, CHANNEL_AC_REMAINING_FILTER_ID); + } + + @Override + public void initialize() { + super.initialize(); + try { + ACCapability cap = getCapabilities(); + if (!isExtraInfoCollectorSupported()) { + ThingBuilder builder = editThing() + .withoutChannels(this.getThing().getChannelsOfGroup(channelGroupExtendedInfoUID.getId())); + updateThing(builder.build()); + } else if (!cap.isEnergyMonitorAvailable()) { + ThingBuilder builder = editThing().withoutChannel(currentEnergyConsumptionChannelUID); + updateThing(builder.build()); + } else if (!cap.isFilterMonitorAvailable()) { + ThingBuilder builder = editThing().withoutChannel(remainingFilterChannelUID); + updateThing(builder.build()); + } + } catch (LGThinqApiException e) { + logger.warn("Error getting capability of the device: {}", getDeviceId()); + } + } + + @Override + protected void updateDeviceChannels(ACCanonicalSnapshot shot) { + updateState(powerChannelUID, OnOffType.from(DevicePowerState.DV_POWER_ON == shot.getPowerStatus())); + updateState(opModeChannelUID, new DecimalType(shot.getOperationMode())); + if (DeviceTypes.HEAT_PUMP.equals(getDeviceType())) { + updateState(hpAirWaterSwitchChannelUID, new DecimalType(shot.getHpAirWaterTempSwitch())); + } + updateState(fanSpeedChannelUID, new DecimalType(shot.getAirWindStrength())); + updateState(currTempChannelUID, new DecimalType(shot.getCurrentTemperature())); + updateState(targetTempChannelUID, new DecimalType(shot.getTargetTemperature())); + try { + ACCapability acCap = getCapabilities(); + if (getThing().getChannel(stepUpDownChannelUID) != null) { + updateState(stepUpDownChannelUID, new DecimalType((int) shot.getStepUpDownMode())); + } + if (getThing().getChannel(stepLeftRightChannelUID) != null) { + updateState(stepLeftRightChannelUID, new DecimalType((int) shot.getStepLeftRightMode())); + } + if (getThing().getChannel(jetModeChannelUID) != null) { + Double commandCoolJetOn = Double.valueOf(acCap.getCoolJetModeCommandOn()); + updateState(jetModeChannelUID, OnOffType.from(commandCoolJetOn.equals(shot.getCoolJetMode()))); + } + if (getThing().getChannel(airCleanChannelUID) != null) { + Double commandAirCleanOn = Double.valueOf(acCap.getAirCleanModeCommandOn()); + updateState(airCleanChannelUID, OnOffType.from(commandAirCleanOn.equals(shot.getAirCleanMode()))); + } + if (getThing().getChannel(energySavingChannelUID) != null) { + Double energySavingOn = Double.valueOf(acCap.getEnergySavingModeCommandOn()); + updateState(energySavingChannelUID, OnOffType.from(energySavingOn.equals(shot.getEnergySavingMode()))); + } + if (getThing().getChannel(autoDryChannelUID) != null) { + Double autoDryOn = Double.valueOf(acCap.getCoolJetModeCommandOn()); + updateState(autoDryChannelUID, OnOffType.from(autoDryOn.equals(shot.getAutoDryMode()))); + } + if (DeviceTypes.HEAT_PUMP.equals(getDeviceType())) { + // HP has different combination of min and max target temperature depending on the switch mode and + // operation + // mode + String opModeValue = Objects + .requireNonNullElse(acCap.getOpMode().get(getLastShot().getOperationMode().toString()), ""); + if (CAP_HP_AIR_SWITCH.equals(shot.getHpAirWaterTempSwitch())) { + if (opModeValue.equals(CAP_ACHP_OP_MODE_COOL_KEY)) { + minTempConstraint = shot.getHpAirTempCoolMin(); + maxTempConstraint = shot.getHpAirTempCoolMax(); + } else if (opModeValue.equals(CAP_ACHP_OP_MODE_HEAT_KEY)) { + minTempConstraint = shot.getHpAirTempHeatMin(); + maxTempConstraint = shot.getHpAirTempHeatMax(); + } + } else if (CAP_HP_WATER_SWITCH.equals(shot.getHpAirWaterTempSwitch())) { + if (opModeValue.equals(CAP_ACHP_OP_MODE_COOL_KEY)) { + minTempConstraint = shot.getHpWaterTempCoolMin(); + maxTempConstraint = shot.getHpWaterTempCoolMax(); + } else if (opModeValue.equals(CAP_ACHP_OP_MODE_HEAT_KEY)) { + minTempConstraint = shot.getHpWaterTempHeatMin(); + maxTempConstraint = shot.getHpWaterTempHeatMax(); + } + } else { + logger.warn("Invalid value received by HP snapshot for the air/water switch property: {}", + shot.getHpAirWaterTempSwitch()); + } + updateState(minTempChannelUID, new DecimalType(BigDecimal.valueOf(minTempConstraint))); + updateState(maxTempChannelUID, new DecimalType(BigDecimal.valueOf(maxTempConstraint))); + } + } catch (LGThinqApiException e) { + logger.error("Unexpected Error getting ACCapability Capabilities", e); + } catch (NumberFormatException e) { + logger.warn("command value for capability is not numeric.", e); + } + } + + @Override + public void updateChannelDynStateDescription() throws LGThinqApiException { + ACCapability acCap = getCapabilities(); + manageDynChannel(jetModeChannelUID, CHANNEL_AC_COOL_JET_ID, CoreItemFactory.SWITCH, acCap.isJetModeAvailable()); + manageDynChannel(autoDryChannelUID, CHANNEL_AC_AUTO_DRY_ID, CoreItemFactory.SWITCH, + acCap.isAutoDryModeAvailable()); + manageDynChannel(airCleanChannelUID, CHANNEL_AC_AIR_CLEAN_ID, CoreItemFactory.SWITCH, + acCap.isAirCleanAvailable()); + manageDynChannel(energySavingChannelUID, CHANNEL_AC_ENERGY_SAVING_ID, CoreItemFactory.SWITCH, + acCap.isEnergySavingAvailable()); + manageDynChannel(stepUpDownChannelUID, CHANNEL_AC_STEP_UP_DOWN_ID, CoreItemFactory.SWITCH, + acCap.isStepUpDownAvailable()); + manageDynChannel(stepLeftRightChannelUID, CHANNEL_AC_STEP_LEFT_RIGHT_ID, CoreItemFactory.SWITCH, + acCap.isStepLeftRightAvailable()); + manageDynChannel(stepLeftRightChannelUID, CHANNEL_AC_STEP_LEFT_RIGHT_ID, CoreItemFactory.SWITCH, + acCap.isStepLeftRightAvailable()); + + if (!acCap.getFanSpeed().isEmpty()) { + List options = new ArrayList<>(); + acCap.getFanSpeed() + .forEach((k, v) -> options.add(new StateOption(k, emptyIfNull(CAP_AC_FAN_SPEED.get(v))))); + stateDescriptionProvider.setStateOptions(fanSpeedChannelUID, options); + } + if (!acCap.getOpMode().isEmpty()) { + List options = new ArrayList<>(); + acCap.getOpMode().forEach((k, v) -> options.add(new StateOption(k, emptyIfNull(CAP_AC_OP_MODE.get(v))))); + stateDescriptionProvider.setStateOptions(opModeChannelUID, options); + } + if (!acCap.getStepLeftRight().isEmpty()) { + List options = new ArrayList<>(); + acCap.getStepLeftRight().forEach( + (k, v) -> options.add(new StateOption(k, emptyIfNull(CAP_AC_STEP_LEFT_RIGHT_MODE.get(v))))); + stateDescriptionProvider.setStateOptions(stepLeftRightChannelUID, options); + } + if (!acCap.getStepUpDown().isEmpty()) { + List options = new ArrayList<>(); + acCap.getStepUpDown() + .forEach((k, v) -> options.add(new StateOption(k, emptyIfNull(CAP_AC_STEP_UP_DOWN_MODE.get(v))))); + stateDescriptionProvider.setStateOptions(stepUpDownChannelUID, options); + } + } + + @Override + public LGThinQApiClientService getLgThinQAPIClientService() { + return lgThinqACApiClientService; + } + + @Override + protected Logger getLogger() { + return logger; + } + + protected DeviceTypes getDeviceType() { + if (THING_TYPE_HEAT_PUMP.equals(getThing().getThingTypeUID())) { + return DeviceTypes.HEAT_PUMP; + } else if (THING_TYPE_AIR_CONDITIONER.equals(getThing().getThingTypeUID())) { + return DeviceTypes.AIR_CONDITIONER; + } else { + throw new IllegalArgumentException( + "DeviceTypeUuid [" + getThing().getThingTypeUID() + "] not expected for AirConditioner/HeatPump"); + } + } + + @Override + public String getDeviceAlias() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_DEVICE_ALIAS)); + } + + @Override + public String getDeviceUriJsonConfig() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_MODEL_URL_INFO)); + } + + @Override + public void onDeviceRemoved() { + } + + @Override + public void onDeviceDisconnected() { + } + + protected void resetExtraInfoChannels() { + updateState(currentEnergyConsumptionChannelUID, UnDefType.UNDEF); + if (!isExtraInfoCollectorEnabled()) { // if collector is enabled we can keep the current value + updateState(remainingFilterChannelUID, UnDefType.UNDEF); + } + } + + protected void processCommand(AsyncCommandParams params) throws LGThinqApiException { + Command command = params.command; + switch (getSimpleChannelUID(params.channelUID)) { + case CHANNEL_AC_MOD_OP_ID: { + if (params.command instanceof DecimalType dtCmd) { + lgThinqACApiClientService.changeOperationMode(getBridgeId(), getDeviceId(), dtCmd.intValue()); + } else { + logger.warn("Received command different of Numeric in Mod Operation. Ignoring"); + } + break; + } + case CHANNEL_AC_FAN_SPEED_ID: { + if (command instanceof DecimalType dtCmd) { + lgThinqACApiClientService.changeFanSpeed(getBridgeId(), getDeviceId(), dtCmd.intValue()); + } else { + logger.warn("Received command different of Numeric in FanSpeed Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_STEP_UP_DOWN_ID: { + if (command instanceof DecimalType dtCmd) { + lgThinqACApiClientService.changeStepUpDown(getBridgeId(), getDeviceId(), getLastShot(), + dtCmd.intValue()); + } else { + logger.warn("Received command different of Numeric in Step Up/Down Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_STEP_LEFT_RIGHT_ID: { + if (command instanceof DecimalType dtCmd) { + lgThinqACApiClientService.changeStepLeftRight(getBridgeId(), getDeviceId(), getLastShot(), + dtCmd.intValue()); + } else { + logger.warn("Received command different of Numeric in Step Left/Right Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_POWER_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqACApiClientService.turnDevicePower(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON ? DevicePowerState.DV_POWER_ON : DevicePowerState.DV_POWER_OFF); + } else { + logger.warn("Received command different of OnOffType in Power Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_COOL_JET_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqACApiClientService.turnCoolJetMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON ? getCapabilities().getCoolJetModeCommandOn() + : getCapabilities().getCoolJetModeCommandOff()); + } else { + logger.warn("Received command different of OnOffType in CoolJet Mode Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_AIR_CLEAN_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqACApiClientService.turnAirCleanMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON ? getCapabilities().getAirCleanModeCommandOn() + : getCapabilities().getAirCleanModeCommandOff()); + } else { + logger.warn("Received command different of OnOffType in AirClean Mode Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_AUTO_DRY_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqACApiClientService.turnAutoDryMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON ? getCapabilities().getAutoDryModeCommandOn() + : getCapabilities().getAutoDryModeCommandOff()); + } else { + logger.warn("Received command different of OnOffType in AutoDry Mode Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_ENERGY_SAVING_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqACApiClientService.turnEnergySavingMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON ? getCapabilities().getEnergySavingModeCommandOn() + : getCapabilities().getEnergySavingModeCommandOff()); + } else { + logger.warn("Received command different of OnOffType in EvergySaving Mode Channel. Ignoring"); + } + break; + } + case CHANNEL_AC_TARGET_TEMP_ID: { + double targetTemp; + if (command instanceof DecimalType dtCmd) { + targetTemp = dtCmd.doubleValue(); + } else if (command instanceof QuantityType qtCmd) { + targetTemp = qtCmd.doubleValue(); + } else { + logger.warn("Received command different of Numeric in TargetTemp Channel. Ignoring"); + break; + } + // analise temperature constraints + if (targetTemp > maxTempConstraint || targetTemp < minTempConstraint) { + // values out of range + logger.warn("Target Temperature: {} is out of range: {} - {}. Ignoring command", targetTemp, + minTempConstraint, maxTempConstraint); + break; + } + lgThinqACApiClientService.changeTargetTemperature(getBridgeId(), getDeviceId(), + ACTargetTmp.statusOf(targetTemp)); + break; + } + case CHANNEL_EXTENDED_INFO_COLLECTOR_ID: { + break; + } + default: { + logger.warn("Command {} to the channel {} not supported. Ignored.", command, params.channelUID); + } + } + } + // =========== Energy Colletor Implementation ============= + + @Override + protected boolean isExtraInfoCollectorSupported() { + try { + return getCapabilities().isEnergyMonitorAvailable() || getCapabilities().isFilterMonitorAvailable(); + } catch (LGThinqApiException e) { + logger.warn("Can't get capabilities of the device: {}", getDeviceId()); + } + return false; + } + + @Override + protected boolean isExtraInfoCollectorEnabled() { + String value = getItemLinkedValue(extendedInfoCollectorChannelUID); + return value != null && OnOffType.from(value) == OnOffType.ON; + } + + @Override + protected Map collectExtraInfoState() throws LGThinqException { + ExtendedDeviceInfo info = lgThinqACApiClientService.getExtendedDeviceInfo(getBridgeId(), getDeviceId()); + Map result = mapper.convertValue(info, new TypeReference<>() { + }); + return result == null ? Collections.emptyMap() : result; + } + + @Override + protected void updateExtraInfoStateChannels(Map energyStateAttributes) { + logger.debug("Calling updateExtraInfoStateChannels for device: {}", getDeviceId()); + String instantEnergyConsumption = (String) energyStateAttributes.get(CAP_EXTRA_ATTR_INSTANT_POWER); + String filterUsed = (String) energyStateAttributes.get(CAP_EXTRA_ATTR_FILTER_USED_TIME); + String filterLifetime = (String) energyStateAttributes.get(CAP_EXTRA_ATTR_FILTER_MAX_TIME_TO_USE); + if (instantEnergyConsumption == null) { + updateState(currentEnergyConsumptionChannelUID, UnDefType.NULL); + } else { + try { + double ip = Double.parseDouble(instantEnergyConsumption); + updateState(currentEnergyConsumptionChannelUID, new QuantityType<>(ip, Units.WATT_HOUR)); + } catch (NumberFormatException e) { + updateState(currentEnergyConsumptionChannelUID, UnDefType.UNDEF); + } + } + + if (filterLifetime == null || filterUsed == null) { + updateState(remainingFilterChannelUID, UnDefType.NULL); + } else { + try { + double used = Double.parseDouble(filterUsed); + double max = Double.parseDouble(filterLifetime); + double perc = (1 - (used / max)) * 100; + updateState(remainingFilterChannelUID, new QuantityType<>(perc, Units.PERCENT)); + } catch (NumberFormatException ex) { + updateState(remainingFilterChannelUID, UnDefType.UNDEF); + } + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridge.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridge.java new file mode 100644 index 00000000000..632bd8e9f44 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridge.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.internal.discovery.LGThinqDiscoveryService; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.SnapshotDefinition; + +/** + * The {@link LGThinQBridge} - Specific methods for discovery integration + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQBridge { + /** + * Register Discovery Listener + * + * @param listener + */ + void registerDiscoveryListener(LGThinqDiscoveryService listener); + + /** + * Registry a device Thing to the bridge + * + * @param thing Thing to be registered. + */ + void registryListenerThing( + LGThinQAbstractDeviceHandler thing); + + /** + * Unregistry the thing + * + * @param thing to be unregistered + */ + void unRegistryListenerThing( + LGThinQAbstractDeviceHandler thing); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridgeHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridgeHandler.java new file mode 100644 index 00000000000..90e1e914472 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQBridgeHandler.java @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.internal.LGThinQBridgeConfiguration; +import org.openhab.binding.lgthinq.internal.discovery.LGThinqDiscoveryService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory.LGThinQGeneralApiClientService; +import org.openhab.binding.lgthinq.lgservices.api.TokenManager; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.errors.RefreshTokenException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGDevice; +import org.openhab.binding.lgthinq.lgservices.model.SnapshotDefinition; +import org.openhab.core.config.core.status.ConfigStatusMessage; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.ConfigStatusBridgeHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinQBridgeHandler} - connect to the LG Account and get information about the user and registered + * devices of that user. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQBridgeHandler extends ConfigStatusBridgeHandler implements LGThinQBridge { + + private static final LGThinqDiscoveryService DUMMY_DISCOVERY_SERVICE = new LGThinqDiscoveryService(); + + static { + var logger = LoggerFactory.getLogger(LGThinQBridgeHandler.class); + try { + File directory = new File(getThinqUserDataFolder()); + if (!directory.exists()) { + if (!directory.mkdir()) { + throw new LGThinqException("Can't create directory for userdata thinq"); + } + } + } catch (Exception e) { + logger.warn("Unable to setup thinq userdata directory: {}", e.getMessage()); + } + } + + final ReentrantLock pollingLock = new ReentrantLock(); + private final Map> lGDeviceRegister = new ConcurrentHashMap<>(); + private final Map lastDevicesDiscovered = new ConcurrentHashMap<>(); + private final Logger logger = LoggerFactory.getLogger(LGThinQBridgeHandler.class); + private final TokenManager tokenManager; + private final LGThinQGeneralApiClientService lgApiClient; + private final HttpClientFactory httpClientFactory; + private final LGDevicePollingRunnable lgDevicePollingRunnable; + private LGThinQBridgeConfiguration lgthinqConfig = new LGThinQBridgeConfiguration(); + private LGThinqDiscoveryService discoveryService = DUMMY_DISCOVERY_SERVICE; + private @Nullable ScheduledFuture devicePollingJob; + + public LGThinQBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) { + super(bridge); + this.httpClientFactory = httpClientFactory; + tokenManager = new TokenManager(httpClientFactory.getCommonHttpClient()); + lgApiClient = LGThinQApiClientServiceFactory.newGeneralApiClientService(httpClientFactory); + lgDevicePollingRunnable = new LGDevicePollingRunnable(bridge.getUID().getId()); + } + + public HttpClientFactory getHttpClientFactory() { + return httpClientFactory; + } + + @Override + public void registerDiscoveryListener(LGThinqDiscoveryService listener) { + discoveryService = listener; + } + + @Override + public void registryListenerThing( + LGThinQAbstractDeviceHandler thing) { + if (lGDeviceRegister.get(thing.getDeviceId()) == null) { + lGDeviceRegister.put(thing.getDeviceId(), thing); + // remove device from discovery list, if exists. + LGDevice device = lastDevicesDiscovered.get(thing.getDeviceId()); + if (device != null && discoveryService != DUMMY_DISCOVERY_SERVICE) { + discoveryService.removeLgDeviceDiscovery(device); + } + } + } + + @Override + public void unRegistryListenerThing( + LGThinQAbstractDeviceHandler thing) { + lGDeviceRegister.remove(thing.getDeviceId()); + } + + @Override + public Collection getConfigStatus() { + List resultList = new ArrayList<>(); + if (lgthinqConfig.username.isBlank()) { + resultList.add(ConfigStatusMessage.Builder.error("USERNAME").withMessageKeySuffix("missing field") + .withArguments("username").build()); + } + if (lgthinqConfig.password.isBlank()) { + resultList.add(ConfigStatusMessage.Builder.error("PASSWORD").withMessageKeySuffix("missing field") + .withArguments("password").build()); + } + if (lgthinqConfig.language.isBlank()) { + resultList.add(ConfigStatusMessage.Builder.error("LANGUAGE").withMessageKeySuffix("missing field") + .withArguments("language").build()); + } + if (lgthinqConfig.country.isBlank()) { + resultList.add(ConfigStatusMessage.Builder.error("COUNTRY").withMessageKeySuffix("missing field") + .withArguments("country").build()); + } + return resultList; + } + + @Override + public void handleRemoval() { + ScheduledFuture devicePollingJob = this.devicePollingJob; + if (devicePollingJob != null) { + devicePollingJob.cancel(true); + } + tokenManager.cleanupTokenRegistry(this.getThing().getUID().getId()); + super.handleRemoval(); + } + + @Override + public void dispose() { + ScheduledFuture devicePollingJob = this.devicePollingJob; + if (devicePollingJob != null) { + devicePollingJob.cancel(true); + this.devicePollingJob = null; + } + } + + @Override + public void initialize() { + logger.debug("Initializing LGThinq bridge handler."); + lgthinqConfig = getConfigAs(LGThinQBridgeConfiguration.class); + lgDevicePollingRunnable.lgthinqConfig = lgthinqConfig; + if (lgthinqConfig.username.isBlank() || lgthinqConfig.password.isBlank() || lgthinqConfig.language.isBlank() + || lgthinqConfig.country.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.mandotory-fields-missing"); + } else { + updateStatus(ThingStatus.UNKNOWN); + startLGThinqDevicePolling(); + } + } + + @Override + public void handleConfigurationUpdate(Map configurationParameters) { + logger.debug("Bridge Configuration was updated. Cleaning the token registry file"); + File f = new File(String.format(getThinqConnectionDataFile(), getThing().getUID().getId())); + if (f.isFile()) { + // file exists. Delete it + if (!f.delete()) { + logger.error("Unexpected error deleting file:{}", f.getAbsolutePath()); + } + } + super.handleConfigurationUpdate(configurationParameters); + } + + private void startLGThinqDevicePolling() { + // stop current scheduler, if any + ScheduledFuture devicePollingJob = this.devicePollingJob; + if (devicePollingJob != null && !devicePollingJob.isDone()) { + devicePollingJob.cancel(true); + } + long pollingInterval; + int configPollingInterval = lgthinqConfig.getPollingIntervalSec(); + // It's not recommended to polling for resources in LG API short intervals to do not enter in BlackList + if (configPollingInterval == 0) { + logger.debug("LG's discovery polling disabled"); + return; + } else if (configPollingInterval < 300) { + configPollingInterval = 300; + logger.warn("Wrong configuration value for polling interval. Using default value: {}s", + configPollingInterval); + } + pollingInterval = configPollingInterval; + // submit instantlly and schedule for the next polling interval. + runDiscovery(); + this.devicePollingJob = scheduler.scheduleWithFixedDelay(lgDevicePollingRunnable, 2, pollingInterval, + TimeUnit.SECONDS); + } + + public void runDiscovery() { + scheduler.submit(lgDevicePollingRunnable); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + public void unregisterDiscoveryListener() { + discoveryService = DUMMY_DISCOVERY_SERVICE; + } + + /** + * Registry the OSGi services used by this Bridge. + * Eventually, the Discovery Service will be activated with this bridge as argument. + * + * @return Services to be registered to OSGi. + */ + public Collection> getServices() { + return Set.of(LGThinqDiscoveryService.class); + } + + /** + * Abstract Runnable Polling Class to schedule synchronization status of the Bridge Thing Kinds ! + */ + abstract class PollingRunnable implements Runnable { + protected final String bridgeName; + protected LGThinQBridgeConfiguration lgthinqConfig = new LGThinQBridgeConfiguration(); + + PollingRunnable(String bridgeName) { + this.bridgeName = bridgeName; + } + + @Override + public void run() { + try { + pollingLock.lock(); + // check if configuration file already exists + if (tokenManager.isOauthTokenRegistered(bridgeName)) { + logger.debug( + "Token authentication process has been already done. Skip first authentication process."); + try { + tokenManager.getValidRegisteredToken(bridgeName); + } catch (IOException e) { + logger.error("Unexpected error reading LGThinq TokenFile", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, + "@text/error.toke-file-corrupted"); + return; + } catch (RefreshTokenException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, + "@text/error.toke-refresh"); + logger.error("Error refreshing token", e); + return; + } + } else { + try { + tokenManager.oauthFirstRegistration(bridgeName, lgthinqConfig.getLanguage(), + lgthinqConfig.getCountry(), lgthinqConfig.getUsername(), lgthinqConfig.getPassword(), + lgthinqConfig.getAlternativeServer()); + tokenManager.getValidRegisteredToken(bridgeName); + logger.debug("Successful getting token from LG API"); + } catch (IOException e) { + logger.debug( + "I/O error accessing json token configuration file. Updating Bridge Status to OFFLINE.", + e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.toke-file-access-error"); + return; + } catch (LGThinqException e) { + logger.debug("Error accessing LG API. Updating Bridge Status to OFFLINE.", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/error.lgapi-communication-error"); + return; + } + } + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + + try { + doConnectedRun(); + } catch (Exception e) { + logger.error("Unexpected error getting device list from LG account", e); + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, + "@text/error.lgapi-getting-devices"); + } + } finally { + pollingLock.unlock(); + } + } + + protected abstract void doConnectedRun() throws LGThinqException; + } + + class LGDevicePollingRunnable extends PollingRunnable { + public LGDevicePollingRunnable(String bridgeName) { + super(bridgeName); + } + + @Override + protected void doConnectedRun() throws LGThinqException { + Map lastDevicesDiscoveredCopy = new HashMap<>(lastDevicesDiscovered); + List devices = lgApiClient.listAccountDevices(bridgeName); + // if not registered yet, and not discovered before, then add to discovery list. + devices.forEach(device -> { + String deviceId = device.getDeviceId(); + logger.debug("Device found: {}", deviceId); + if (lGDeviceRegister.get(deviceId) == null && !lastDevicesDiscovered.containsKey(deviceId)) { + logger.debug("Adding new LG Device to things registry with id:{}", deviceId); + if (discoveryService != DUMMY_DISCOVERY_SERVICE) { + discoveryService.addLgDeviceDiscovery(device); + } + } else { + if (discoveryService != DUMMY_DISCOVERY_SERVICE && lGDeviceRegister.get(deviceId) != null) { + discoveryService.removeLgDeviceDiscovery(device); + } + } + lastDevicesDiscovered.put(deviceId, device); + lastDevicesDiscoveredCopy.remove(deviceId); + }); + // the rest in lastDevicesDiscoveredCopy is not more registered in LG API. Remove from discovery + lastDevicesDiscoveredCopy.forEach((deviceId, device) -> { + logger.debug("LG Device '{}' removed.", deviceId); + lastDevicesDiscovered.remove(deviceId); + + LGThinQAbstractDeviceHandler deviceThing = lGDeviceRegister + .get(deviceId); + if (deviceThing != null) { + deviceThing.onDeviceRemoved(); + } + if (discoveryService != DUMMY_DISCOVERY_SERVICE && deviceThing != null) { + discoveryService.removeLgDeviceDiscovery(device); + } + }); + lGDeviceRegister.values().forEach(LGThinQAbstractDeviceHandler::refreshStatus); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQDishWasherHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQDishWasherHandler.java new file mode 100644 index 00000000000..96ab4c384bf --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQDishWasherHandler.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.internal.LGThinQStateDescriptionProvider; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQDishWasherApiClientService; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshot; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.types.StateOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinQDishWasherHandler} Handle the Dish Washer Things + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQDishWasherHandler extends LGThinQAbstractDeviceHandler { + + public final ChannelGroupUID channelGroupDashboardUID; + private final LGThinQStateDescriptionProvider stateDescriptionProvider; + private final ChannelUID courseChannelUID; + private final ChannelUID remainTimeChannelUID; + private final ChannelUID stateChannelUID; + private final ChannelUID processStateChannelUID; + private final ChannelUID doorLockChannelUID; + private final Logger logger = LoggerFactory.getLogger(LGThinQDishWasherHandler.class); + + private final LGThinQDishWasherApiClientService lgThinqDishWasherApiClientService; + + public LGThinQDishWasherHandler(Thing thing, LGThinQStateDescriptionProvider stateDescriptionProvider, + ItemChannelLinkRegistry itemChannelLinkRegistry, HttpClientFactory httpClientFactory) { + super(thing, stateDescriptionProvider, itemChannelLinkRegistry); + this.stateDescriptionProvider = stateDescriptionProvider; + lgThinqDishWasherApiClientService = LGThinQApiClientServiceFactory.newDishWasherApiClientService(lgPlatformType, + httpClientFactory); + channelGroupDashboardUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_DASHBOARD_GRP_ID); + courseChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_COURSE_ID); + stateChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_STATE_ID); + processStateChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_PROCESS_STATE_ID); + remainTimeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_REMAIN_TIME_ID); + doorLockChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_DOOR_LOCK_ID); + } + + private void loadOptionsCourse(DishWasherCapability cap, ChannelUID courseChannel) { + List optionsCourses = new ArrayList<>(); + cap.getCourses().forEach((k, v) -> optionsCourses.add(new StateOption(k, emptyIfNull(v.getCourseName())))); + stateDescriptionProvider.setStateOptions(courseChannel, optionsCourses); + } + + @Override + public void updateChannelDynStateDescription() throws LGThinqApiException { + DishWasherCapability dwCap = getCapabilities(); + + List options = new ArrayList<>(); + dwCap.getStateFeat().getValuesMapping() + .forEach((k, v) -> options.add(new StateOption(k, keyIfValueNotFound(CAP_DW_STATE, v)))); + stateDescriptionProvider.setStateOptions(stateChannelUID, options); + + loadOptionsCourse(dwCap, courseChannelUID); + + List optionsDoor = new ArrayList<>(); + dwCap.getDoorStateFeat().getValuesMapping() + .forEach((k, v) -> optionsDoor.add(new StateOption(k, keyIfValueNotFound(CAP_DW_DOOR_STATE, v)))); + stateDescriptionProvider.setStateOptions(doorLockChannelUID, optionsDoor); + + List optionsPre = new ArrayList<>(); + dwCap.getProcessState().getValuesMapping() + .forEach((k, v) -> optionsPre.add(new StateOption(k, keyIfValueNotFound(CAP_DW_PROCESS_STATE, v)))); + stateDescriptionProvider.setStateOptions(processStateChannelUID, optionsPre); + } + + @Override + public LGThinQApiClientService getLgThinQAPIClientService() { + return lgThinqDishWasherApiClientService; + } + + @Override + protected Logger getLogger() { + return logger; + } + + @Override + protected void updateDeviceChannels(DishWasherSnapshot shot) { + updateState(CHANNEL_DASHBOARD_GRP_WITH_SEP + CHANNEL_AC_POWER_ID, + OnOffType.from(DevicePowerState.DV_POWER_ON.equals(shot.getPowerStatus()))); + updateState(stateChannelUID, new StringType(shot.getState())); + updateState(processStateChannelUID, new StringType(shot.getProcessState())); + updateState(courseChannelUID, new StringType(shot.getCourse())); + updateState(doorLockChannelUID, new StringType(shot.getDoorLock())); + updateState(remainTimeChannelUID, new StringType(shot.getRemainingTime())); + } + + @Override + protected DeviceTypes getDeviceType() { + if (THING_TYPE_WASHING_MACHINE.equals(getThing().getThingTypeUID())) { + return DeviceTypes.WASHERDRYER_MACHINE; + } else if (THING_TYPE_WASHING_TOWER.equals(getThing().getThingTypeUID())) { + return DeviceTypes.WASHER_TOWER; + } else { + throw new IllegalArgumentException( + "DeviceTypeUuid [" + getThing().getThingTypeUID() + "] not expected for WashingTower/Machine"); + } + } + + @Override + protected void processCommand(AsyncCommandParams params) { + logger.warn("Command {} to the channel {} not supported. Ignored.", params.command, params.channelUID); + } + + @Override + public String getDeviceAlias() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_DEVICE_ALIAS)); + } + + @Override + public String getDeviceUriJsonConfig() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_MODEL_URL_INFO)); + } + + @Override + public void onDeviceRemoved() { + } + + /** + * Put the channels in default state if the device is disconnected or gone. + */ + @Override + public void onDeviceDisconnected() { + updateState(CHANNEL_AC_POWER_ID, OnOffType.OFF); + updateState(CHANNEL_WMD_STATE_ID, new StringType(WMD_POWER_OFF_VALUE)); + updateState(CHANNEL_WMD_COURSE_ID, new StringType("NOT_SELECTED")); + updateState(CHANNEL_WMD_SMART_COURSE_ID, new StringType("NOT_SELECTED")); + updateState(CHANNEL_WMD_DOOR_LOCK_ID, new StringType("DOOR_LOCK_OFF")); + updateState(CHANNEL_WMD_REMAIN_TIME_ID, new StringType("00:00")); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQFridgeHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQFridgeHandler.java new file mode 100644 index 00000000000..8fd05b68e9d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQFridgeHandler.java @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.measure.Unit; +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.internal.LGThinQStateDescriptionProvider; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQFridgeApiClientService; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapability; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.ImperialUnits; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.StateOption; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinQFridgeHandler} Handle Fridge things + * + * @author Nemer Daud - Initial contribution + * @author Arne Seime - Complementary sensors + */ +@NonNullByDefault +public class LGThinQFridgeHandler extends LGThinQAbstractDeviceHandler { + public final ChannelGroupUID channelGroupExtendedInfoUID; + public final ChannelGroupUID channelGroupDashboardUID; + private final ChannelUID fridgeTempChannelUID; + private final ChannelUID freezerTempChannelUID; + private final ChannelUID doorChannelUID; + private final ChannelUID smartSavingModeChannelUID; + private final ChannelUID activeSavingChannelUID; + private final ChannelUID icePlusChannelUID; + private final ChannelUID expressFreezeModeChannelUID; + private final ChannelUID expressCoolModeChannelUID; + private final ChannelUID vacationModeChannelUID; + private final ChannelUID freshAirFilterChannelUID; + private final ChannelUID waterFilterChannelUID; + private final ChannelUID tempUnitUID; + private final Logger logger = LoggerFactory.getLogger(LGThinQFridgeHandler.class); + private final LGThinQFridgeApiClientService lgThinqFridgeApiClientService; + private String tempUnit = RE_TEMP_UNIT_CELSIUS; + + public LGThinQFridgeHandler(Thing thing, LGThinQStateDescriptionProvider stateDescriptionProvider, + ItemChannelLinkRegistry itemChannelLinkRegistry, HttpClientFactory httpClientFactory) { + super(thing, stateDescriptionProvider, itemChannelLinkRegistry); + lgThinqFridgeApiClientService = LGThinQApiClientServiceFactory.newFridgeApiClientService(lgPlatformType, + httpClientFactory); + channelGroupDashboardUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_DASHBOARD_GRP_ID); + channelGroupExtendedInfoUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_EXTENDED_INFO_GRP_ID); + fridgeTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_FRIDGE_TEMP_ID); + freezerTempChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_FREEZER_TEMP_ID); + doorChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_DOOR_OPEN); + tempUnitUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_REF_TEMP_UNIT); + icePlusChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_ICE_PLUS); + expressFreezeModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_EXPRESS_FREEZE_MODE); + expressCoolModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_EXPRESS_COOL_MODE); + vacationModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_VACATION_MODE); + smartSavingModeChannelUID = new ChannelUID(channelGroupDashboardUID, + LG_API_PLATFORM_TYPE_V2.equals(lgPlatformType) ? CHANNEL_FR_SMART_SAVING_MODE_V2 + : CHANNEL_FR_SMART_SAVING_SWITCH_V1); + activeSavingChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_FR_ACTIVE_SAVING); + freshAirFilterChannelUID = new ChannelUID(channelGroupExtendedInfoUID, CHANNEL_FR_FRESH_AIR_FILTER); + waterFilterChannelUID = new ChannelUID(channelGroupExtendedInfoUID, CHANNEL_FR_WATER_FILTER); + } + + private Unit getTemperatureUnit(FridgeCanonicalSnapshot shot) { + if (!(RE_CELSIUS_UNIT_VALUES.contains(shot.getTempUnit()) + || RE_FAHRENHEIT_UNIT_VALUES.contains(shot.getTempUnit()))) { + logger.warn( + "Temperature Unit not recognized (must be Celsius or Fahrenheit). Ignoring and considering Celsius as default"); + return SIUnits.CELSIUS; + } + return RE_CELSIUS_UNIT_VALUES.contains(shot.getTempUnit()) ? SIUnits.CELSIUS : ImperialUnits.FAHRENHEIT; + } + + @Override + protected void updateDeviceChannels(FridgeCanonicalSnapshot shot) { + Unit unTemp = getTemperatureUnit(shot); + if (isLinked(fridgeTempChannelUID)) { + updateState(fridgeTempChannelUID, + new QuantityType<>(decodeTempValue(fridgeTempChannelUID, shot.getFridgeTemp().intValue()), unTemp)); + } + if (isLinked(freezerTempChannelUID)) { + updateState(freezerTempChannelUID, new QuantityType<>( + decodeTempValue(freezerTempChannelUID, shot.getFreezerTemp().intValue()), unTemp)); + } + if (isLinked(doorChannelUID)) { + updateState(doorChannelUID, parseDoorStatus(shot.getDoorStatus())); + } + if (isLinked(expressFreezeModeChannelUID)) { + updateState(expressFreezeModeChannelUID, new StringType(shot.getExpressMode())); + } + if (isLinked(expressCoolModeChannelUID)) { + updateState(expressCoolModeChannelUID, OnOffType.from(shot.getExpressCoolMode())); + } + if (isLinked(vacationModeChannelUID)) { + updateState(vacationModeChannelUID, OnOffType.from(shot.getEcoFriendlyMode())); + } + if (isLinked(freshAirFilterChannelUID)) { + updateState(freshAirFilterChannelUID, new StringType(shot.getFreshAirFilterState())); + } + if (isLinked(waterFilterChannelUID)) { + updateState(waterFilterChannelUID, new StringType(shot.getWaterFilterUsedMonth())); + } + + updateState(tempUnitUID, new StringType(shot.getTempUnit())); + if (!tempUnit.equals(shot.getTempUnit())) { + tempUnit = shot.getTempUnit(); + try { + // force update states after first snapshot fetched to fit changes in temperature unit + updateChannelDynStateDescription(); + } catch (Exception ex) { + logger.warn("Unexpected error updating dynamic state description.", ex); + } + } + } + + private State parseDoorStatus(String doorStatus) { + if (RE_DOOR_CLOSE_VALUES.contains(doorStatus)) { + return OpenClosedType.CLOSED; + } else if (RE_DOOR_OPEN_VALUES.contains(doorStatus)) { + return OpenClosedType.OPEN; + } else { + return UnDefType.UNDEF; + } + } + + protected Integer decodeTempValue(ChannelUID ch, Integer value) { + FridgeCapability refCap; + try { + refCap = getCapabilities(); + } catch (LGThinqApiException e) { + logger.error("Error getting capability of the device. It's mostly like a bug", e); + return 0; + } + // temperature channels are little different. First we need to get the tempUnit in the first snapshot, + Map conversionMap = getConversionMap(ch, refCap); + String strValue = conversionMap.get(value.toString()); + if (strValue == null) { + logger.error( + "Temperature value informed [{}] can't be converted based on the cap file. It mostly like a bug", + value); + return 0; + } + try { + return Integer.valueOf(strValue); + } catch (Exception ex) { + logger.error("Temperature value informed [{}] can't be parsed to number. It mostly like a bug", value, ex); + return 0; + } + } + + protected Integer encodeTempValue(ChannelUID ch, Integer value) { + FridgeCapability refCap; + try { + refCap = getCapabilities(); + } catch (LGThinqApiException e) { + logger.error("Error getting capability of the device. It's mostly like a bug", e); + return 0; + } + // temperature channels are little different. First we need to get the tempUnit in the first snapshot, + final Map conversionMap = getConversionMap(ch, refCap); + final Map invertedMap = new HashMap<>(); + conversionMap.forEach((k, v) -> { + invertedMap.put(v, k); + }); + + String strValue = invertedMap.get(value.toString()); + if (strValue == null) { + logger.error("Temperature value informed can't be converted based on the cap file. It mostly like a bug"); + return 0; + } + try { + return Integer.valueOf(strValue); + } catch (Exception ex) { + logger.error("Temperature value converted can't be cast to Integer. It mostly like a bug", ex); + return 0; + } + } + + private Map getConversionMap(ChannelUID ch, FridgeCapability refCap) { + Map conversionMap; + if (fridgeTempChannelUID.equals(ch)) { + conversionMap = RE_TEMP_UNIT_FAHRENHEIT.equals(tempUnit) ? refCap.getFridgeTempFMap() + : refCap.getFridgeTempCMap(); + } else if (freezerTempChannelUID.equals(ch)) { + conversionMap = RE_TEMP_UNIT_FAHRENHEIT.equals(tempUnit) ? refCap.getFreezerTempFMap() + : refCap.getFreezerTempCMap(); + } else { + throw new IllegalStateException("Conversion Map Channel temperature not mapped. It's most likely a bug"); + } + return conversionMap; + } + + @Override + public LGThinQApiClientService getLgThinQAPIClientService() { + return lgThinqFridgeApiClientService; + } + + @Override + protected Logger getLogger() { + return logger; + } + + protected DeviceTypes getDeviceType() { + return DeviceTypes.AIR_CONDITIONER; + } + + @Override + public String getDeviceAlias() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_DEVICE_ALIAS)); + } + + @Override + public String getDeviceUriJsonConfig() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_MODEL_URL_INFO)); + } + + @Override + public void onDeviceRemoved() { + } + + @Override + public void onDeviceDisconnected() { + } + + @Override + public void updateChannelDynStateDescription() throws LGThinqApiException { + FridgeCapability cap = getCapabilities(); + manageDynChannel(icePlusChannelUID, CHANNEL_FR_ICE_PLUS, CoreItemFactory.SWITCH, + !cap.getIcePlusMap().isEmpty()); + manageDynChannel(expressFreezeModeChannelUID, CHANNEL_FR_EXPRESS_FREEZE_MODE, CoreItemFactory.STRING, + !cap.getExpressFreezeModeMap().isEmpty()); + manageDynChannel(expressCoolModeChannelUID, CHANNEL_FR_EXPRESS_COOL_MODE, CoreItemFactory.SWITCH, + cap.isExpressCoolModePresent()); + manageDynChannel(vacationModeChannelUID, CHANNEL_FR_VACATION_MODE, CoreItemFactory.SWITCH, + cap.isEcoFriendlyModePresent()); + + Unit unTemp = getTemperatureUnit(getLastShot()); + if (SIUnits.CELSIUS.equals(unTemp)) { + loadChannelTempStateOption(cap.getFridgeTempCMap(), fridgeTempChannelUID, unTemp); + loadChannelTempStateOption(cap.getFreezerTempCMap(), freezerTempChannelUID, unTemp); + } else { + loadChannelTempStateOption(cap.getFridgeTempFMap(), fridgeTempChannelUID, unTemp); + loadChannelTempStateOption(cap.getFreezerTempFMap(), freezerTempChannelUID, unTemp); + } + loadChannelStateOption(cap.getActiveSavingMap(), activeSavingChannelUID); + loadChannelStateOption(cap.getExpressFreezeModeMap(), expressFreezeModeChannelUID); + loadChannelStateOption(cap.getActiveSavingMap(), activeSavingChannelUID); + loadChannelStateOption(cap.getSmartSavingMap(), smartSavingModeChannelUID); + loadChannelStateOption(cap.getTempUnitMap(), tempUnitUID); + loadChannelStateOption(CAP_RE_FRESH_AIR_FILTER_MAP, freshAirFilterChannelUID); + loadChannelStateOption(CAP_RE_WATER_FILTER, waterFilterChannelUID); + } + + private void loadChannelStateOption(Map cap, ChannelUID channelUID) { + final List faOptions = new ArrayList<>(); + cap.forEach((k, v) -> faOptions.add(new StateOption(k, v))); + stateDescriptionProvider.setStateOptions(channelUID, faOptions); + } + + private void loadChannelTempStateOption(Map cap, ChannelUID channelUID, Unit unTemp) { + final List faOptions = new ArrayList<>(); + cap.forEach((k, v) -> { + try { + QuantityType t = new QuantityType<>(Integer.valueOf(v), unTemp); + faOptions.add(new StateOption(t.toString(), t.toString())); + } catch (NumberFormatException ex) { + logger.debug("Error converting invalid temperature number: {}. This can be safely ignored", v); + } + }); + stateDescriptionProvider.setStateOptions(channelUID, faOptions); + } + + @Override + protected void processCommand(AsyncCommandParams params) throws LGThinqApiException { + FridgeCanonicalSnapshot lastShot = getLastShot(); + Map cmdSnap = lastShot.getRawData(); + Command command = params.command; + String simpleChannelUID; + simpleChannelUID = getSimpleChannelUID(params.channelUID); + switch (simpleChannelUID) { + case CHANNEL_FR_FREEZER_TEMP_ID: + case CHANNEL_FR_FRIDGE_TEMP_ID: { + int targetTemp; + if (command instanceof DecimalType dCmd) { + targetTemp = dCmd.intValue(); + } else if (command instanceof QuantityType qCmd) { + targetTemp = qCmd.intValue(); + } else { + logger.warn("Received command different of Numeric in TargetTemp Channel. Ignoring"); + break; + } + + if (CHANNEL_FR_FRIDGE_TEMP_ID.equals(simpleChannelUID)) { + targetTemp = encodeTempValue(fridgeTempChannelUID, targetTemp); + lgThinqFridgeApiClientService.setFridgeTemperature(getBridgeId(), getDeviceId(), getCapabilities(), + targetTemp, lastShot.getTempUnit(), cmdSnap); + } else { + targetTemp = encodeTempValue(freezerTempChannelUID, targetTemp); + lgThinqFridgeApiClientService.setFreezerTemperature(getBridgeId(), getDeviceId(), getCapabilities(), + targetTemp, lastShot.getTempUnit(), cmdSnap); + } + break; + } + case CHANNEL_FR_ICE_PLUS: { + if (command instanceof OnOffType ooCmd) { + lgThinqFridgeApiClientService.setIcePlus(getBridgeId(), getDeviceId(), getCapabilities(), + ooCmd == OnOffType.ON, cmdSnap); + } else { + logger.warn("Received command different of OnOff in IcePlus Channel. It's mostly like a bug"); + } + break; + } + case CHANNEL_FR_EXPRESS_FREEZE_MODE: { + String targetExpressMode; + if (command instanceof StringType stCmd) { + targetExpressMode = stCmd.toString(); + } else { + logger.warn("Received command different of String in ExpressMode Channel. It's mostly like a bug"); + break; + } + + lgThinqFridgeApiClientService.setExpressMode(getBridgeId(), getDeviceId(), targetExpressMode); + break; + } + case CHANNEL_FR_EXPRESS_COOL_MODE: { + if (command instanceof OnOffType ooCmd) { + lgThinqFridgeApiClientService.setExpressCoolMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON); + } else { + logger.warn( + "Received command different of OnOffType in ExpressCoolMode Channel. It's mostly like a bug"); + } + break; + } + case CHANNEL_FR_VACATION_MODE: { + if (command instanceof OnOffType ooCmd) { + lgThinqFridgeApiClientService.setEcoFriendlyMode(getBridgeId(), getDeviceId(), + ooCmd == OnOffType.ON); + } else { + logger.warn( + "Received command different of OnOffType in VacationMode Channel. It's most likely a bug"); + } + break; + } + default: { + logger.warn("Command {} to the channel {} not supported. Ignored.", command, params.channelUID); + } + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQWasherDryerHandler.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQWasherDryerHandler.java new file mode 100644 index 00000000000..0f87c97faca --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/internal/handler/LGThinQWasherDryerHandler.java @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.internal.handler; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.internal.LGThinQStateDescriptionProvider; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQWMApiClientService; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseFunction; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseType; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshot; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.types.Command; +import org.openhab.core.types.StateOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinQWasherDryerHandler} Handle Washer/Dryer And Washer Dryer Towers things + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQWasherDryerHandler + extends LGThinQAbstractDeviceHandler { + + public final ChannelGroupUID channelGroupRemoteStartUID; + public final ChannelGroupUID channelGroupDashboardUID; + private final LGThinQStateDescriptionProvider stateDescriptionProvider; + private final ChannelUID courseChannelUID; + private final ChannelUID remoteStartStopChannelUID; + private final ChannelUID remainTimeChannelUID; + private final ChannelUID delayTimeChannelUID; + private final ChannelUID spinChannelUID; + private final ChannelUID rinseChannelUID; + private final ChannelUID stateChannelUID; + private final ChannelUID processStateChannelUID; + private final ChannelUID childLockChannelUID; + private final ChannelUID dryLevelChannelUID; + private final ChannelUID temperatureChannelUID; + private final ChannelUID doorLockChannelUID; + private final ChannelUID standByModeChannelUID; + private final ChannelUID remoteStartFlagChannelUID; + private final ChannelUID remoteStartCourseChannelUID; + private final List remoteStartEnabledChannels = new CopyOnWriteArrayList<>(); + + private final Logger logger = LoggerFactory.getLogger(LGThinQWasherDryerHandler.class); + private final LGThinQWMApiClientService lgThinqWMApiClientService; + + public LGThinQWasherDryerHandler(Thing thing, LGThinQStateDescriptionProvider stateDescriptionProvider, + ItemChannelLinkRegistry itemChannelLinkRegistry, HttpClientFactory httpClientFactory) { + super(thing, stateDescriptionProvider, itemChannelLinkRegistry); + this.stateDescriptionProvider = stateDescriptionProvider; + lgThinqWMApiClientService = LGThinQApiClientServiceFactory.newWMApiClientService(lgPlatformType, + httpClientFactory); + channelGroupRemoteStartUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_WMD_REMOTE_START_GRP_ID); + channelGroupDashboardUID = new ChannelGroupUID(getThing().getUID(), CHANNEL_DASHBOARD_GRP_ID); + courseChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_COURSE_ID); + dryLevelChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_DRY_LEVEL_ID); + stateChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_STATE_ID); + processStateChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_PROCESS_STATE_ID); + remainTimeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_REMAIN_TIME_ID); + delayTimeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_DELAY_TIME_ID); + temperatureChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_TEMP_LEVEL_ID); + doorLockChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_DOOR_LOCK_ID); + childLockChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_CHILD_LOCK_ID); + rinseChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_RINSE_ID); + spinChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_SPIN_ID); + standByModeChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_STAND_BY_ID); + remoteStartFlagChannelUID = new ChannelUID(channelGroupDashboardUID, CHANNEL_WMD_REMOTE_START_ID); + remoteStartStopChannelUID = new ChannelUID(channelGroupRemoteStartUID, CHANNEL_WMD_REMOTE_START_START_STOP); + remoteStartCourseChannelUID = new ChannelUID(channelGroupRemoteStartUID, CHANNEL_WMD_REMOTE_COURSE); + } + + @Override + protected void initializeThing(@Nullable ThingStatus bridgeStatus) { + super.initializeThing(bridgeStatus); + ThingBuilder builder = editThing() + .withoutChannels(this.getThing().getChannelsOfGroup(channelGroupRemoteStartUID.getId())); + updateThing(builder.build()); + remoteStartEnabledChannels.clear(); + } + + private void loadOptionsCourse(WasherDryerCapability cap, ChannelUID courseChannel) { + List optionsCourses = new ArrayList<>(); + cap.getCourses().forEach((k, v) -> optionsCourses.add(new StateOption(k, emptyIfNull(v.getCourseName())))); + stateDescriptionProvider.setStateOptions(courseChannel, optionsCourses); + } + + @Override + public void updateChannelDynStateDescription() throws LGThinqApiException { + WasherDryerCapability wmCap = getCapabilities(); + + List options = new ArrayList<>(); + wmCap.getStateFeat().getValuesMapping() + .forEach((k, v) -> options.add(new StateOption(k, keyIfValueNotFound(CAP_WMD_STATE, v)))); + stateDescriptionProvider.setStateOptions(stateChannelUID, options); + + loadOptionsCourse(wmCap, courseChannelUID); + + List optionsTemp = new ArrayList<>(); + wmCap.getTemperatureFeat().getValuesMapping() + .forEach((k, v) -> optionsTemp.add(new StateOption(k, keyIfValueNotFound(CAP_WMD_TEMPERATURE, v)))); + stateDescriptionProvider.setStateOptions(temperatureChannelUID, optionsTemp); + + List optionsDoor = new ArrayList<>(); + optionsDoor.add(new StateOption("0", "Unlocked")); + optionsDoor.add(new StateOption("1", "Locked")); + stateDescriptionProvider.setStateOptions(doorLockChannelUID, optionsDoor); + + List optionsSpin = new ArrayList<>(); + wmCap.getSpinFeat().getValuesMapping() + .forEach((k, v) -> optionsSpin.add(new StateOption(k, keyIfValueNotFound(CAP_WM_SPIN, v)))); + stateDescriptionProvider.setStateOptions(spinChannelUID, optionsSpin); + + List optionsRinse = new ArrayList<>(); + wmCap.getRinseFeat().getValuesMapping() + .forEach((k, v) -> optionsRinse.add(new StateOption(k, keyIfValueNotFound(CAP_WM_RINSE, v)))); + stateDescriptionProvider.setStateOptions(rinseChannelUID, optionsRinse); + + List optionsPre = new ArrayList<>(); + wmCap.getProcessState().getValuesMapping() + .forEach((k, v) -> optionsPre.add(new StateOption(k, keyIfValueNotFound(CAP_WMD_PROCESS_STATE, v)))); + stateDescriptionProvider.setStateOptions(processStateChannelUID, optionsPre); + + List optionsChildLock = new ArrayList<>(); + optionsChildLock.add(new StateOption("CHILDLOCK_OFF", "Unlocked")); + optionsChildLock.add(new StateOption("CHILDLOCK_ON", "Locked")); + stateDescriptionProvider.setStateOptions(childLockChannelUID, optionsChildLock); + + List optionsDryLevel = new ArrayList<>(); + wmCap.getDryLevel().getValuesMapping() + .forEach((k, v) -> optionsDryLevel.add(new StateOption(k, keyIfValueNotFound(CAP_DR_DRY_LEVEL, v)))); + stateDescriptionProvider.setStateOptions(dryLevelChannelUID, optionsDryLevel); + } + + @Override + public LGThinQApiClientService getLgThinQAPIClientService() { + return lgThinqWMApiClientService; + } + + @Override + protected Logger getLogger() { + return logger; + } + + @Override + protected void updateDeviceChannels(WasherDryerSnapshot shot) throws LGThinqApiException { + updateState(CHANNEL_DASHBOARD_GRP_WITH_SEP + CHANNEL_AC_POWER_ID, + OnOffType.from(DevicePowerState.DV_POWER_ON == shot.getPowerStatus())); + updateState(stateChannelUID, new StringType(shot.getState())); + updateState(processStateChannelUID, new StringType(shot.getProcessState())); + updateState(dryLevelChannelUID, new StringType(shot.getDryLevel())); + updateState(childLockChannelUID, new StringType(shot.getChildLock())); + updateState(courseChannelUID, new StringType(shot.getCourse())); + updateState(temperatureChannelUID, new StringType(shot.getTemperatureLevel())); + updateState(doorLockChannelUID, new StringType(shot.getDoorLock())); + updateState(remainTimeChannelUID, new StringType(shot.getRemainingTime())); + updateState(delayTimeChannelUID, new StringType(shot.getReserveTime())); + updateState(standByModeChannelUID, OnOffType.from(shot.isStandBy())); + updateState(remoteStartFlagChannelUID, OnOffType.from(shot.isRemoteStartEnabled())); + updateState(spinChannelUID, new StringType(shot.getSpin())); + updateState(rinseChannelUID, new StringType(shot.getRinse())); + Channel rsStartStopChannel = getThing().getChannel(remoteStartStopChannelUID); + final List dynChannels = new ArrayList<>(); + // only can have remote start channel is the WM is not in sleep mode, and remote start is enabled. + if (shot.isRemoteStartEnabled() && !shot.isStandBy()) { + ThingHandlerCallback callback = getCallback(); + if (rsStartStopChannel == null && callback != null) { + // === creating channel LaunchRemote + dynChannels.add( + createDynChannel(CHANNEL_WMD_REMOTE_START_START_STOP, remoteStartStopChannelUID, "Switch")); + dynChannels.add(createDynChannel(CHANNEL_WMD_REMOTE_COURSE, remoteStartCourseChannelUID, "String")); + // Just enabled remote start. Then is Off + updateState(remoteStartStopChannelUID, OnOffType.OFF); + // === creating selectable channels for the Course (if any) + WasherDryerCapability cap = getCapabilities(); + loadOptionsCourse(cap, remoteStartCourseChannelUID); + updateState(remoteStartCourseChannelUID, new StringType(cap.getDefaultCourseId())); + + CourseDefinition courseDef = cap.getCourses().get(cap.getDefaultCourseId()); + if (WMD_COURSE_NOT_SELECTED_VALUE.equals(shot.getSmartCourse()) && courseDef != null) { + // only create selectable channels if the course is not a smart course. Smart courses have + // already predefined + // the functions values + for (CourseFunction f : courseDef.getFunctions()) { + if (!f.isSelectable()) { + // only for selectable features + continue; + } + // handle well know dynamic fields + FeatureDefinition fd = cap.getFeatureDefinition(f.getValue()); + ChannelUID targetChannel; + ChannelUID refChannel; + if (!FeatureDefinition.NULL_DEFINITION.equals(fd)) { + targetChannel = new ChannelUID(channelGroupRemoteStartUID, fd.getChannelId()); + refChannel = new ChannelUID(channelGroupDashboardUID, fd.getRefChannelId()); + dynChannels.add(createDynChannel(fd.getChannelId(), targetChannel, + translateFeatureToItemType(fd.getDataType()))); + if (CAP_WM_DICT_V2.containsKey(f.getValue())) { + // if the function has translation dictionary (I hope so), then the values in + // the selectable channel will be translated to something more readable + List options = new ArrayList<>(); + for (String v : f.getSelectableValues()) { + Map values = CAP_WM_DICT_V2.get(f.getValue()); + if (values != null) { + // Canonical Value is the KEY (@...) that represents a constant in the + // definition + // that can be translated to a human description + String canonicalValue = Objects.requireNonNullElse(fd.getValuesMapping().get(v), + v); + options.add(new StateOption(v, keyIfValueNotFound(values, canonicalValue))); + stateDescriptionProvider.setStateOptions(targetChannel, options); + } + } + } + // update state with the default referenced channel + updateState(targetChannel, new StringType(getItemLinkedValue(refChannel))); + } + } + } + + remoteStartEnabledChannels.addAll(dynChannels); + } + } else if (!remoteStartEnabledChannels.isEmpty()) { + ThingBuilder builder = editThing().withoutChannels(remoteStartEnabledChannels); + updateThing(builder.build()); + remoteStartEnabledChannels.clear(); + } + } + + @Override + protected DeviceTypes getDeviceType() { + if (THING_TYPE_WASHING_MACHINE.equals(getThing().getThingTypeUID())) { + return DeviceTypes.WASHERDRYER_MACHINE; + } else if (THING_TYPE_WASHING_TOWER.equals(getThing().getThingTypeUID())) { + return DeviceTypes.WASHER_TOWER; + } else if (THING_TYPE_DRYER.equals(getThing().getThingTypeUID())) { + return DeviceTypes.WASHER_TOWER; + } else { + throw new IllegalArgumentException( + "DeviceTypeUuid [" + getThing().getThingTypeUID() + "] not expected for WashingTower/Machine"); + } + } + + private Map getRemoteStartData() throws LGThinqApiException { + WasherDryerSnapshot lastShot = getLastShot(); + if (lastShot.getRawData().isEmpty()) { + return lastShot.getRawData(); + } + String selectedCourse = getItemLinkedValue(remoteStartCourseChannelUID); + if (selectedCourse == null) { + logger.warn("Remote Start Channel must be linked to proceed with remote start."); + return Collections.emptyMap(); + } + WasherDryerCapability cap = getCapabilities(); + Map rawData = lastShot.getRawData(); + Map data = new HashMap<>(); + CommandDefinition cmd = cap.getCommandsDefinition().get(cap.getCommandRemoteStart()); + if (cmd == null) { + logger.error("Command for Remote Start not found in the Washer descriptor. It's most likely a bug"); + return Collections.emptyMap(); + } + Map cmdData = cmd.getData(); + // 1st - copy snapshot data to command + cmdData.forEach((k, v) -> { + data.put(k, rawData.getOrDefault(k, v)); + }); + // 2nd - replace remote start data with selected course values + CourseDefinition selCourseDef = cap.getCourses().get(selectedCourse); + if (selCourseDef != null) { + selCourseDef.getFunctions().forEach(f -> { + data.put(f.getValue(), f.getDefaultValue()); + }); + } + String smartCourse = lastShot.getSmartCourse(); + data.put(cap.getDefaultCourseFieldName(), selectedCourse); + data.put(cap.getDefaultSmartCourseFeatName(), smartCourse); + CourseType courseType = Objects + .requireNonNull(cap.getCourses().get("NOT_SELECTED".equals(smartCourse) ? selectedCourse : smartCourse), + "NOT_SELECTED should be hardcoded. It is most likely a bug") + .getCourseType(); + data.put("courseType", courseType.getValue()); + // 3rd - replace custom selectable features with channel's ones. + for (Channel c : remoteStartEnabledChannels) { + String value = Objects.requireNonNullElse(getItemLinkedValue(c.getUID()), ""); + String simpleChannelUID = getSimpleChannelUID(c.getUID().getId()); + switch (simpleChannelUID) { + case CHANNEL_WMD_REMOTE_START_RINSE: + data.put(cap.getRinseFeat().getName(), value); + break; + case CHANNEL_WMD_REMOTE_START_TEMP: + data.put(cap.getTemperatureFeat().getName(), value); + break; + case CHANNEL_WMD_REMOTE_START_SPIN: + data.put(cap.getSpinFeat().getName(), value); + break; + default: + logger.warn("channel [{}] not mapped for this binding. It is most likely a bug.", simpleChannelUID); + } + } + + return data; + } + + @Override + protected void processCommand(LGThinQAbstractDeviceHandler.AsyncCommandParams params) throws LGThinqApiException { + WasherDryerSnapshot lastShot = getLastShot(); + Command command = params.command; + String simpleChannelUID; + simpleChannelUID = getSimpleChannelUID(params.channelUID); + switch (simpleChannelUID) { + case CHANNEL_WMD_REMOTE_START_START_STOP: { + if (command instanceof OnOffType ooCmd) { + if (ooCmd == OnOffType.ON) { + if (!lastShot.isStandBy()) { + lgThinqWMApiClientService.remoteStart(getBridgeId(), getCapabilities(), getDeviceId(), + getRemoteStartData()); + } else { + logger.warn( + "WM is in StandBy mode. Command START can't be sent to Remote Start channel. Ignoring"); + } + } else { + logger.warn("Command Remote Start OFF not implemented yet"); + } + } else { + logger.warn("Received command different of StringType in Remote Start Channel. Ignoring"); + } + break; + } + case CHANNEL_WMD_STAND_BY_ID: { + if (command instanceof OnOffType ooCmd) { + lgThinqWMApiClientService.wakeUp(getBridgeId(), getDeviceId(), ooCmd == OnOffType.ON); + } else { + logger.warn("Received command different of OnOffType in StandBy Channel. Ignoring"); + } + break; + } + default: { + logger.warn("Command {} to the channel {} not supported. Ignored.", command, params.channelUID); + } + } + } + + @Override + public String getDeviceAlias() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_DEVICE_ALIAS)); + } + + @Override + public String getDeviceUriJsonConfig() { + return emptyIfNull(getThing().getProperties().get(PROP_INFO_MODEL_URL_INFO)); + } + + @Override + public void onDeviceRemoved() { + } + + /** + * Put the channels in default state if the device is disconnected or gone. + */ + @Override + public void onDeviceDisconnected() { + updateState(CHANNEL_AC_POWER_ID, OnOffType.OFF); + updateState(CHANNEL_WMD_STATE_ID, new StringType(WMD_POWER_OFF_VALUE)); + updateState(CHANNEL_WMD_COURSE_ID, new StringType("NOT_SELECTED")); + updateState(CHANNEL_WMD_SMART_COURSE_ID, new StringType("NOT_SELECTED")); + updateState(CHANNEL_WMD_TEMP_LEVEL_ID, new StringType("NOT_SELECTED")); + updateState(CHANNEL_WMD_DOOR_LOCK_ID, new StringType("DOOR_LOCK_OFF")); + updateState(CHANNEL_WMD_REMAIN_TIME_ID, new StringType("00:00")); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGServicesConstants.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGServicesConstants.java new file mode 100644 index 00000000000..74f3338b8ba --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGServicesConstants.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static java.util.Map.entry; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The LGServicesConstants constants class for lg services + * + * @author Nemer Daud - Initial Contribution + */ +@NonNullByDefault +public class LGServicesConstants { + // Extended Info Attribute Constants + public static final String CAP_EXTRA_ATTR_INSTANT_POWER = "InOutInstantPower"; + public static final String CAP_EXTRA_ATTR_FILTER_MAX_TIME_TO_USE = "ChangePeriod"; + public static final String CAP_EXTRA_ATTR_FILTER_USED_TIME = "UseTime"; + public static final String LG_ROOT_TAG_V1 = "lgedmRoot"; + public static final String LG_API_V1_CONTROL_OP = "rti/rtiControl"; + // === LG API protocol constants + public static final String LG_API_API_KEY_V2 = "VGhpblEyLjAgU0VSVklDRQ=="; + public static final String LG_API_APPLICATION_KEY = "6V1V8H2BN5P9ZQGOI5DAQ92YZBDO3EK9"; + public static final String LG_API_APP_LEVEL = "PRD"; + public static final String LG_API_APP_OS = "ANDROID"; + public static final String LG_API_APP_TYPE = "NUTS"; + public static final String LG_API_APP_VER = "5.0.2800"; + // the client id is a SHA512 hash of the phone MFR,MODEL,SERIAL, + // and the build id of the thinq app it can also just be a random + // string, we use the same client id used for oauth + public static final String LG_API_CLIENT_ID = "LGAO221A02"; + public static final String LG_API_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss +0000"; + public static final String LG_API_GATEWAY_SERVICE_PATH_V2 = "/v1/service/application/gateway-uri"; + public static final String LG_API_GATEWAY_URL_V2 = "https://route.lgthinq.com:46030" + + LG_API_GATEWAY_SERVICE_PATH_V2; + public static final String LG_API_MESSAGE_ID = "wideq"; + public static final String LG_API_OAUTH_CLIENT_KEY = "LGAO722A02"; + public static final String LG_API_OAUTH_SEARCH_KEY_PATH = "/searchKey"; + public static final String LG_API_OAUTH_SECRET_KEY = "c053c2a6ddeb7ad97cb0eed0dcb31cf8"; + public static final String LG_API_PLATFORM_TYPE_V1 = "thinq1"; + public static final String LG_API_PLATFORM_TYPE_V2 = "thinq2"; + public static final String LG_API_PRE_LOGIN_PATH = "/preLogin"; + public static final String LG_API_SVC_CODE = "SVC202"; + public static final String LG_API_SVC_PHASE = "OP"; + public static final String LG_API_V1_MON_DATA_PATH = "rti/rtiResult"; + public static final String LG_API_V1_START_MON_PATH = "rti/rtiMon"; + public static final String LG_API_V2_API_KEY = "VGhpblEyLjAgU0VSVklDRQ=="; + public static final String LG_API_V2_APP_LEVEL = "PRD"; + public static final String LG_API_V2_APP_OS = "ANDROID"; + public static final String LG_API_V2_APP_TYPE = "NUTS"; + public static final String LG_API_V2_APP_VER = "5.0.2800"; + public static final String LG_API_V2_AUTH_PATH = "/oauth/1.0/oauth2/token"; + public static final String LG_API_V2_CTRL_DEVICE_CONFIG_PATH = "service/devices/%s/%s"; + public static final String LG_API_V2_DEVICE_CONFIG_PATH = "service/devices/"; + public static final String LG_API_V2_EMP_SESS_PATH = "/emp/oauth2/token/empsession"; + public static final String LG_API_V2_EMP_SESS_URL = "https://emp-oauth.lgecloud.com" + LG_API_V2_EMP_SESS_PATH; + public static final String LG_API_V2_LS_PATH = "/service/application/dashboard"; + public static final String LG_API_V2_SESSION_LOGIN_PATH = "/emp/v2.0/account/session/"; + public static final String LG_API_V2_SVC_PHASE = "OP"; + public static final String LG_API_V2_USER_INFO = "/users/profile"; + public static final Double FREEZER_TEMPERATURE_IGNORE_VALUE = 255.0; + public static final Double FRIDGE_TEMPERATURE_IGNORE_VALUE = 255.0; + public static final String RE_TEMP_UNIT_CELSIUS = "CELSIUS"; + public static final String RE_TEMP_UNIT_CELSIUS_SYMBOL = "°C"; + public static final Set RE_CELSIUS_UNIT_VALUES = Set.of("01", "1", "C", "CELSIUS", + RE_TEMP_UNIT_CELSIUS_SYMBOL); + public static final String RE_TEMP_UNIT_FAHRENHEIT = "FAHRENHEIT"; + public static final String RE_TEMP_UNIT_FAHRENHEIT_SYMBOL = "°F"; + public static final Map CAP_RE_TEMP_UNIT_V2_MAP = Map.of(RE_TEMP_UNIT_CELSIUS, + RE_TEMP_UNIT_CELSIUS_SYMBOL, RE_TEMP_UNIT_FAHRENHEIT, RE_TEMP_UNIT_FAHRENHEIT_SYMBOL); + public static final Set RE_FAHRENHEIT_UNIT_VALUES = Set.of("02", "2", "F", "FAHRENHEIT", + RE_TEMP_UNIT_FAHRENHEIT_SYMBOL); + public static final Set RE_DOOR_OPEN_VALUES = Set.of("1", "01", "OPEN"); + public static final Set RE_DOOR_CLOSE_VALUES = Set.of("0", "00", "CLOSE"); + public static final String RE_SNAPSHOT_NODE_V2 = "refState"; + public static final String RE_SET_CONTROL_COMMAND_NAME_V1 = "SetControl"; + public static final Map CAP_RE_SMART_SAVING_MODE = Map.of("@CP_TERM_USE_NOT_W", "Disabled", + "@RE_SMARTSAVING_MODE_NIGHT_W", "Night Mode", "@RE_SMARTSAVING_MODE_CUSTOM_W", "Custom Mode"); + public static final Map CAP_RE_ON_OFF = Map.of("@CP_OFF_EN_W", "Off", "@CP_ON_EN_W", "On"); + public static final Map CAP_RE_LABEL_ON_OFF = Map.of("OFF", "Off", "ON", "On", "IGNORE", + "Not Available"); + public static final Map CAP_RE_LABEL_CLOSE_OPEN = Map.of("CLOSE", "Closed", "OPEN", "Open", + "IGNORE", "Not Available"); + public static final Map CAP_RE_EXPRESS_FREEZE_MODES = Map.of("@CP_OFF_EN_W", "Express Mode Off", + "@CP_ON_EN_W", "Express Freeze On", "@RE_MAIN_SPEED_FREEZE_TERM_W", "Rapid Freeze On"); + public static final Map CAP_RE_FRESH_AIR_FILTER_MAP = Map.ofEntries(/* v1 */ entry("1", "Off"), + entry("2", "Auto Mode"), entry("3", "Power Mode"), entry("4", "Replace Filter"), + /* v2 */ entry("OFF", "Off"), entry("AUTO", "Auto Mode"), entry("POWER", "Power Mode"), + entry("REPLACE", "Replace Filter"), entry("SMART_STORAGE_POWER", "Smart Storage Power"), + entry("SMART_STORAGE_OFF", "Smart Storage Off"), entry("SMART_STORAGE_ON", "Smart Storage On"), + entry("IGNORE", "Not Available")); + public static final Map CAP_RE_SMART_SAVING_V2_MODE = Map.of("OFF", "Off", "NIGHT_ON", "Night Mode", + "CUSTOM_ON", "Custom Mode", "SMARTGRID_DR_ON", "Demand Response", "SMARTGRID_DD_ON", "Delay Defrost", + "IGNORE", "Not Available"); + public static final Map CAP_RE_WATER_FILTER = Map.ofEntries(entry("0_MONTH", "0 Month Used"), + entry("0", "0 Month Used"), entry("1_MONTH", "1 Month Used"), entry("1", "1 Month Used"), + entry("2_MONTH", "2 Month Used"), entry("2", "2 Month Used"), entry("3_MONTH", "3 Month Used"), + entry("3", "3 Month Used"), entry("4_MONTH", "4 Month Used"), entry("4", "4 Month Used"), + entry("5_MONTH", "5 Month Used"), entry("5", "5 Month Used"), entry("6_MONTH", "6 Month Used"), + entry("6", "6 Month Used"), entry("7_MONTH", "7 Month Used"), entry("8_MONTH", "8 Month Used"), + entry("9_MONTH", "9 Month Used"), entry("10_MONTH", "10 Month Used"), entry("11_MONTH", "11 Month Used"), + entry("12_MONTH", "12 Month Used"), entry("IGNORE", "Not Available")); + public static final String CAP_RE_WATER_FILTER_USED_POSTFIX = "Month(s) Used"; + // === Device Definition/Capability Constants + public static final String CAP_ACHP_OP_MODE_COOL_KEY = "@AC_MAIN_OPERATION_MODE_COOL_W"; + public static final String CAP_ACHP_OP_MODE_HEAT_KEY = "@AC_MAIN_OPERATION_MODE_HEAT_W"; + public static final Map CAP_AC_OP_MODE = Map.of(CAP_ACHP_OP_MODE_COOL_KEY, "Cool", + "@AC_MAIN_OPERATION_MODE_DRY_W", "Dry", "@AC_MAIN_OPERATION_MODE_FAN_W", "Fan", CAP_ACHP_OP_MODE_HEAT_KEY, + "Heat", "@AC_MAIN_OPERATION_MODE_AIRCLEAN_W", "Air Clean", "@AC_MAIN_OPERATION_MODE_ACO_W", "Auto", + "@AC_MAIN_OPERATION_MODE_AI_W", "AI", "@AC_MAIN_OPERATION_MODE_ENERGY_SAVING_W", "Eco", + "@AC_MAIN_OPERATION_MODE_AROMA_W", "Aroma", "@AC_MAIN_OPERATION_MODE_ANTIBUGS_W", "Anti Bugs"); + public static final Map CAP_AC_STEP_UP_DOWN_MODE = Map.of("@OFF", "Off", "@1", "Upper", "@2", "Up", + "@3", "Middle Up", "@4", "Middle Down", "@5", "Down", "@6", "Far Down", "@100", "Circular"); + public static final Map CAP_AC_STEP_LEFT_RIGHT_MODE = Map.of("@OFF", "Off", "@1", "Lefter", "@2", + "Left", "@3", "Middle", "@4", "Right", "@5", "Righter", "@13", "Left to Middle", "@35", "Middle to Right", + "@100", "Circular"); + // Sub Modes support + public static final String CAP_AC_SUB_MODE_COOL_JET = "@AC_MAIN_WIND_MODE_COOL_JET_W"; + public static final String CAP_AC_SUB_MODE_STEP_UP_DOWN = "@AC_MAIN_WIND_DIRECTION_STEP_UP_DOWN_W"; + public static final String CAP_AC_SUB_MODE_STEP_LEFT_RIGHT = "@AC_MAIN_WIND_DIRECTION_STEP_LEFT_RIGHT_W"; + public static final Map CAP_AC_FAN_SPEED = Map.ofEntries( + entry("@AC_MAIN_WIND_STRENGTH_SLOW_W", "Slow"), entry("@AC_MAIN_WIND_STRENGTH_SLOW_LOW_W", "Slower"), + entry("@AC_MAIN_WIND_STRENGTH_LOW_W", "Low"), entry("@AC_MAIN_WIND_STRENGTH_LOW_MID_W", "Low Mid"), + entry("@AC_MAIN_WIND_STRENGTH_MID_W", "Mid"), entry("@AC_MAIN_WIND_STRENGTH_MID_HIGH_W", "Mid High"), + entry("@AC_MAIN_WIND_STRENGTH_HIGH_W", "High"), entry("@AC_MAIN_WIND_STRENGTH_POWER_W", "Power"), + entry("@AC_MAIN_WIND_STRENGTH_AUTO_W", "Auto"), entry("@AC_MAIN_WIND_STRENGTH_NATURE_W", "Auto"), + entry("@AC_MAIN_WIND_STRENGTH_LOW_RIGHT_W", "Right Low"), + entry("@AC_MAIN_WIND_STRENGTH_MID_RIGHT_W", "Right Mid"), + entry("@AC_MAIN_WIND_STRENGTH_HIGH_RIGHT_W", "Right High"), + entry("@AC_MAIN_WIND_STRENGTH_LOW_LEFT_W", "Left Low"), + entry("@AC_MAIN_WIND_STRENGTH_MID_LEFT_W", "Left Mid"), + entry("@AC_MAIN_WIND_STRENGTH_HIGH_LEFT_W", "Left High")); + public static final Map CAP_AC_COOL_JET = Map.of("@COOL_JET", "Cool Jet"); + public static final Double CAP_HP_AIR_SWITCH = 0.0; + public static final Double CAP_HP_WATER_SWITCH = 1.0; + // ======= RAC MODES + public static final String CAP_AC_AUTODRY = "@AUTODRY"; + public static final String CAP_AC_ENERGYSAVING = "@ENERGYSAVING"; + public static final String CAP_AC_AIRCLEAN = "@AIRCLEAN"; + // ==================== + public static final String CAP_AC_COMMAND_OFF = "@OFF"; + public static final String CAP_AC_COMMAND_ON = "@ON"; + public static final String CAP_AC_AIR_CLEAN_COMMAND_ON = "@AC_MAIN_AIRCLEAN_ON_W"; + public static final String CAP_AC_AIR_CLEAN_COMMAND_OFF = "@AC_MAIN_AIRCLEAN_OFF_W"; + public static final String WMD_COURSE_NOT_SELECTED_VALUE = "NOT_SELECTED"; + public static final String WMD_POWER_OFF_VALUE = "POWEROFF"; + public static final String WMD_SNAPSHOT_WASHER_DRYER_NODE_V2 = "washerDryer"; + public static final String WM_LOST_WASHING_STATE_KEY = "WASHING"; + public static final String WM_LOST_WASHING_STATE_VALUE = "@WM_STATE_WASHING_W"; + public static final Map CAP_WMD_STATE = Map.ofEntries(entry("@WM_STATE_POWER_OFF_W", "Off"), + entry("@WM_STATE_INITIAL_W", "Initial"), entry("@WM_STATE_PAUSE_W", "Pause"), + entry("@WM_STATE_RESERVE_W", "Reserved"), entry("@WM_STATE_DETECTING_W", "Detecting"), + entry("@WM_STATE_RUNNING_W", "Running"), entry("@WM_STATE_RINSING_W", "Rinsing"), + entry("@WM_STATE_SPINNING_W", "Spinning"), entry("@WM_STATE_COOLDOWN_W", "Cool Down"), + entry("@WM_STATE_RINSEHOLD_W", "Rinse Hold"), entry("@WM_STATE_WASH_REFRESHING_W", "Refreshing"), + entry("@WM_STATE_STEAMSOFTENING_W", "Steam Softening"), entry("@WM_STATE_END_W", "End"), + entry("@WM_STATE_DRYING_W", "Drying"), entry("@WM_STATE_DEMO_W", "Demonstration"), + entry("@WM_STATE_ADD_DRAIN_W", "Add Drain"), entry("@WM_STATE_LOAD_DISPLAY_W", "Loading Display"), + entry("@WM_STATE_FRESHCARE_W", "Refreshing"), entry("@WM_STATE_ERROR_AUTO_OFF_W", "Error Auto Off"), + entry("@WM_STATE_FROZEN_PREVENT_INITIAL_W", "Frozen Preventing"), + entry("@FROZEN_PREVENT_PAUSE", "Frozen Preventing Paused"), + entry("@FROZEN_PREVENT_RUNNING", "Frozen Preventing Running"), entry("@AUDIBLE_DIAGNOSIS", "Diagnosing"), + entry("@WM_STATE_ERROR_W", "Error"), + // This last one is not defined in the cap file + entry(WM_LOST_WASHING_STATE_VALUE, "Washing")); + public static final Map CAP_WMD_PROCESS_STATE = Map.ofEntries( + entry("@WM_STATE_DETECTING_W", "Detecting"), entry("@WM_STATE_STEAM_W", "Steam"), + entry("@WM_STATE_DRY_W", "Drying"), entry("@WM_STATE_COOLING_W", "Cooling"), + entry("@WM_STATE_ANTI_CREASE_W", "Anti Creasing"), entry("@WM_STATE_END_W", "End"), + entry("@WM_STATE_POWER_OFF_W", "Power Off"), entry("@WM_STATE_INITIAL_W", "Initializing"), + entry("@WM_STATE_PAUSE_W", "Paused"), entry("@WM_STATE_RESERVE_W", "Reserved"), + entry("@WM_STATE_RUNNING_W", "Running"), entry("@WM_STATE_RINSING_W", "Rising"), + entry("@WM_STATE_SPINNING_W", "@WM_STATE_DRYING_W"), entry("WM_STATE_COOLDOWN_W", "Cool Down"), + entry("@WM_STATE_RINSEHOLD_W", "Rinse Hold"), entry("@WM_STATE_WASH_REFRESHING_W", "Refreshing"), + entry("@WM_STATE_STEAMSOFTENING_W", "Steam Softening"), entry("@WM_STATE_ERROR_W", "Error")); + public static final Map CAP_DR_DRY_LEVEL = Map.ofEntries( + entry("@WM_DRY24_DRY_LEVEL_IRON_W", "Iron"), entry("@WM_DRY24_DRY_LEVEL_CUPBOARD_W", "Cupboard"), + entry("@WM_DRY24_DRY_LEVEL_EXTRA_W", "Extra")); + public static final Map CAP_WMD_TEMPERATURE = Map.ofEntries( + entry("@WM_TERM_NO_SELECT_W", "Not Selected"), entry("@WM_TITAN2_OPTION_TEMP_20_W", "20"), + entry("@WM_TITAN2_OPTION_TEMP_COLD_W", "Cold"), entry("@WM_TITAN2_OPTION_TEMP_30_W", "30"), + entry("@WM_TITAN2_OPTION_TEMP_40_W", "40"), entry("@WM_TITAN2_OPTION_TEMP_50_W", "50"), + entry("@WM_TITAN27_BIG_OPTION_TEMP_TAP_COLD_W", "Tap Cold"), + entry("@WM_TITAN27_BIG_OPTION_TEMP_COLD_W", "Cold"), + entry("@WM_TITAN27_BIG_OPTION_TEMP_ECO_WARM_W", "Eco Warm"), + entry("@WM_TITAN27_BIG_OPTION_TEMP_WARM_W", "Warm"), entry("@WM_TITAN27_BIG_OPTION_TEMP_HOT_W", "Hot"), + entry("@WM_TITAN27_BIG_OPTION_TEMP_EXTRA_HOT_W", "Extra Hot")); + public static final Map CAP_WM_SPIN = Map.ofEntries(entry("@WM_TERM_NO_SELECT_W", "Not Selected"), + entry("@WM_TITAN2_OPTION_SPIN_NO_SPIN_W", "No Spin"), entry("@WM_TITAN2_OPTION_SPIN_400_W", "400"), + entry("@WM_TITAN2_OPTION_SPIN_600_W", "600"), entry("@WM_TITAN2_OPTION_SPIN_700_W", "700"), + entry("@WM_TITAN2_OPTION_SPIN_800_W", "800"), entry("@WM_TITAN2_OPTION_SPIN_900_W", "900"), + entry("@WM_TITAN2_OPTION_SPIN_1000_W", "1000"), entry("@WM_TITAN2_OPTION_SPIN_1100_W", "1100"), + entry("@WM_TITAN2_OPTION_SPIN_1200_W", "1200"), entry("@WM_TITAN2_OPTION_SPIN_1400_W", "1400"), + entry("@WM_TITAN2_OPTION_SPIN_1600_W", "1600"), entry("@WM_TITAN2_OPTION_SPIN_MAX_W", "Max Spin"), + entry("@WM_TITAN27_BIG_OPTION_SPIN_NO_SPIN_W", "Drain Only"), + entry("@WM_TITAN27_BIG_OPTION_SPIN_LOW_W", "Low"), entry("@WM_TITAN27_BIG_OPTION_SPIN_MEDIUM_W", "Medium"), + entry("@WM_TITAN27_BIG_OPTION_SPIN_HIGH_W", "High"), + entry("@WM_TITAN27_BIG_OPTION_SPIN_EXTRA_HIGH_W", "Extra High")); + public static final Map CAP_WM_RINSE = Map.ofEntries(entry("@WM_TERM_NO_SELECT_W", "Not Selected"), + entry("@WM_TITAN2_OPTION_RINSE_NORMAL_W", "Normal"), entry("@WM_TITAN2_OPTION_RINSE_RINSE+_W", "Plus"), + entry("@WM_TITAN2_OPTION_RINSE_RINSE++_W", "Plus +"), + entry("@WM_TITAN2_OPTION_RINSE_NORMALHOLD_W", "Normal Hold"), + entry("@WM_TITAN2_OPTION_RINSE_RINSE+HOLD_W", "Plus Hold"), + entry("@WM_TITAN27_BIG_OPTION_EXTRA_RINSE_0_W", "Normal"), + entry("@WM_TITAN27_BIG_OPTION_EXTRA_RINSE_1_W", "Plus"), + entry("@WM_TITAN27_BIG_OPTION_EXTRA_RINSE_2_W", "Plus +"), + entry("@WM_TITAN27_BIG_OPTION_EXTRA_RINSE_3_W", "Plus ++")); + // This is the dictionary os course functions translations for V2 + public static final Map> CAP_WM_DICT_V2 = Map.of("spin", CAP_WM_SPIN, "rinse", + CAP_WM_RINSE, "temp", CAP_WMD_TEMPERATURE, "state", CAP_WMD_STATE); + public static final String WMD_COMMAND_REMOTE_START_V2 = "WMStart"; + /** + * ============ Dish Washer's Label/Feature Translation Constants ============= + */ + public static final String DW_SNAPSHOT_WASHER_DRYER_NODE_V2 = "dishwasher"; + public static final String DW_POWER_OFF_VALUE = "POWEROFF"; + public static final String DW_STATE_COMPLETE = "END"; + public static final Map CAP_DW_DOOR_STATE = Map.of("@CP_OFF_EN_W", "Close", "@CP_ON_EN_W", + "Opened"); + public static final Map CAP_DW_PROCESS_STATE = Map.ofEntries(entry("@DW_STATE_INITIAL_W", "None"), + entry("@DW_STATE_RESERVE_W", "Reserved"), entry("@DW_STATE_RUNNING_W", "Running"), + entry("@DW_STATE_RINSING_W", "Rising"), entry("@DW_STATE_DRYING_W", "Drying"), + entry("@DW_STATE_COMPLETE_W", "Complete"), entry("@DW_STATE_NIGHTDRY_W", "Night Dry"), + entry("@DW_STATE_CANCEL_W", "Cancelled")); + public static final Map CAP_DW_STATE = Map.ofEntries(entry("@DW_STATE_POWER_OFF_W", "Off"), + entry("@DW_STATE_INITIAL_W", "Initial"), entry("@DW_STATE_RUNNING_W", "Running"), + entry("@DW_STATE_PAUSE_W", "Paused"), entry("@DW_STATE_STANDBY_W", "Stand By"), + entry("@DW_STATE_COMPLETE_W", "Complete"), entry("@DW_STATE_POWER_FAIL_W", "Power Fail")); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiClientService.java new file mode 100644 index 00000000000..f2dfa868fb0 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiClientService.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACTargetTmp; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ExtendedDeviceInfo; + +/** + * The {@link LGThinQACApiClientService} interface provides a common abstraction for handling AC-related + * API interactions with LG ThinQ devices. It supports both protocol versions V1 and V2. + *

+ * This interface allows external components to change various air conditioner settings, such as + * operation mode, fan speed, temperature, and additional features like Jet Mode and Energy Saving Mode. + *

+ * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQACApiClientService extends LGThinQApiClientService { + + /** + * Changes the air conditioner's operation mode (e.g., Cool, Heat, Fan). + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param newOpMode The new operation mode to be set. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void changeOperationMode(String bridgeName, String deviceId, int newOpMode) throws LGThinqApiException; + + /** + * Adjusts the fan speed of the air conditioner. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param newFanSpeed The desired fan speed level. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void changeFanSpeed(String bridgeName, String deviceId, int newFanSpeed) throws LGThinqApiException; + + /** + * Adjusts the vertical orientation of the AC fan. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param currentSnap The current snapshot of AC device data. + * @param newStep The new vertical position. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void changeStepUpDown(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException; + + /** + * Adjusts the horizontal orientation of the AC fan. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param currentSnap The current snapshot of AC device data. + * @param newStep The new horizontal position. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void changeStepLeftRight(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException; + + /** + * Changes the target temperature of the air conditioner. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param newTargetTemp The new target temperature to be set. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void changeTargetTemperature(String bridgeName, String deviceId, ACTargetTmp newTargetTemp) + throws LGThinqApiException; + + /** + * Enables or disables the Jet Mode feature. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param modeOnOff The desired state ("on" to enable, "off" to disable). + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void turnCoolJetMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException; + + /** + * Enables or disables the Air Clean mode. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param modeOnOff The desired state ("on" to enable, "off" to disable). + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void turnAirCleanMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException; + + /** + * Enables or disables the Auto Dry feature. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param modeOnOff The desired state ("on" to enable, "off" to disable). + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void turnAutoDryMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException; + + /** + * Enables or disables the Energy Saving mode. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @param modeOnOff The desired state ("on" to enable, "off" to disable). + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + void turnEnergySavingMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException; + + /** + * Retrieves extended device information, such as energy consumption and filter status. + * + * @param bridgeName The name of the bridge managing the device connection. + * @param deviceId The unique ID of the LG ThinQ AC device. + * @return An {@link ExtendedDeviceInfo} object containing the extended data of the device. + * @throws LGThinqApiException If an error occurs while invoking the LG API. + */ + ExtendedDeviceInfo getExtendedDeviceInfo(String bridgeName, String deviceId) throws LGThinqApiException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV1ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV1ClientServiceImpl.java new file mode 100644 index 00000000000..59929c4611f --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV1ClientServiceImpl.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.io.IOException; +import java.util.Base64; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACTargetTmp; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ExtendedDeviceInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link LGThinQACApiV1ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQACApiV1ClientServiceImpl extends + LGThinQAbstractApiV1ClientService implements LGThinQACApiClientService { + + private final Logger logger = LoggerFactory.getLogger(LGThinQACApiV1ClientServiceImpl.class); + + protected LGThinQACApiV1ClientServiceImpl(HttpClient httpClient) { + super(ACCapability.class, ACCanonicalSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + /** + * Get snapshot data from the device. + * It works only for API V2 device versions! + * + * @param deviceId device ID for de desired V2 LG Thinq. + * @param capDef + * @return return map containing metamodel of settings and snapshot + */ + @Override + @Nullable + public ACCanonicalSnapshot getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) { + throw new UnsupportedOperationException("Method not supported in V1 API device."); + } + + private void readDataResultNodeToObject(String jsonResult, Object obj) throws IOException { + JsonNode node = objectMapper.readTree(jsonResult); + JsonNode data = node.path(LG_ROOT_TAG_V1).path("returnData"); + if (data.isTextual()) { + // analyses if its b64 or not + JsonNode format = node.path(LG_ROOT_TAG_V1).path("format"); + if ("B64".equals(format.textValue())) { + String dataStr = new String(Base64.getDecoder().decode(data.textValue())); + objectMapper.readerForUpdating(obj).readValue(dataStr); + } else { + objectMapper.readerForUpdating(obj).readValue(data.textValue()); + } + } else { + logger.warn("Data returned by LG API to get energy state is not present. Result:{}", node.toPrettyString()); + } + } + + @Override + public ExtendedDeviceInfo getExtendedDeviceInfo(String bridgeName, String deviceId) throws LGThinqApiException { + ExtendedDeviceInfo info = new ExtendedDeviceInfo(); + try { + RestResult resp = sendCommand(bridgeName, deviceId, LG_API_V1_CONTROL_OP, "Config", "Get", "", + "InOutInstantPower"); + handleGenericErrorResult(resp); + readDataResultNodeToObject(resp.getJsonResponse(), info); + + resp = sendCommand(bridgeName, deviceId, LG_API_V1_CONTROL_OP, "Config", "Get", "", "Filter"); + handleGenericErrorResult(resp); + readDataResultNodeToObject(resp.getJsonResponse(), info); + + return info; + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending command to LG API", e); + } + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) + throws LGThinqApiException { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", "Operation", + String.valueOf(newPowerState.commandValue())); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting device power", e); + } + } + + @Override + public void turnCoolJetMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "Jet", modeOnOff); + } + + public void turnAirCleanMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "AirClean", modeOnOff); + } + + public void turnAutoDryMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "AutoDry", modeOnOff); + } + + public void turnEnergySavingMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "PowerSave", modeOnOff); + } + + protected void turnGenericMode(String bridgeName, String deviceId, String modeName, String modeOnOff) + throws LGThinqApiException { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", modeName, modeOnOff); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting " + modeName + " mode", e); + } + } + + @Override + public void changeOperationMode(String bridgeName, String deviceId, int newOpMode) throws LGThinqApiException { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", "OpMode", "" + newOpMode); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + @Override + public void changeFanSpeed(String bridgeName, String deviceId, int newFanSpeed) throws LGThinqApiException { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", "WindStrength", + String.valueOf(newFanSpeed)); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting fan speed", e); + } + } + + @Override + public void changeStepUpDown(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException { + Map<@Nullable String, @Nullable Object> subModeFeatures = Map.of("Jet", currentSnap.getCoolJetMode().intValue(), + "PowerSave", currentSnap.getEnergySavingMode().intValue(), "WDirVStep", newStep, "WDirHStep", + (int) currentSnap.getStepLeftRightMode()); + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", subModeFeatures, null); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error stepUpDown", e); + } + } + + @Override + public void changeStepLeftRight(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException { + Map<@Nullable String, @Nullable Object> subModeFeatures = Map.of("Jet", currentSnap.getCoolJetMode().intValue(), + "PowerSave", currentSnap.getEnergySavingMode().intValue(), "WDirVStep", + (int) currentSnap.getStepUpDownMode(), "WDirHStep", newStep); + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", subModeFeatures, null); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error stepUpDown", e); + } + } + + @Override + public void changeTargetTemperature(String bridgeName, String deviceId, ACTargetTmp newTargetTemp) + throws LGThinqApiException { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "", "Control", "Set", "TempCfg", + String.valueOf(newTargetTemp.commandValue())); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting target temperature", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV2ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV2ClientServiceImpl.java new file mode 100644 index 00000000000..d0fadbb0dec --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQACApiV2ClientServiceImpl.java @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.io.IOException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACTargetTmp; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ExtendedDeviceInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQACApiV2ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQACApiV2ClientServiceImpl extends + LGThinQAbstractApiV2ClientService implements LGThinQACApiClientService { + + private final Logger logger = LoggerFactory.getLogger(LGThinQACApiV2ClientServiceImpl.class); + + protected LGThinQACApiV2ClientServiceImpl(HttpClient httpClient) { + super(ACCapability.class, ACCanonicalSnapshot.class, httpClient); + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) + throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Operation", "airState.operation", + newPowerState.commandValue()); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting device power", e); + } + } + + @Override + public void turnCoolJetMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "airState.wMode.jet", modeOnOff); + } + + public void turnAirCleanMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "airState.wMode.airClean", modeOnOff); + } + + public void turnAutoDryMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "airState.miscFuncState.autoDry", modeOnOff); + } + + public void turnEnergySavingMode(String bridgeName, String deviceId, String modeOnOff) throws LGThinqApiException { + turnGenericMode(bridgeName, deviceId, "airState.powerSave.basic", modeOnOff); + } + + protected void turnGenericMode(String bridgeName, String deviceId, String modeName, String modeOnOff) + throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Operation", modeName, + Integer.parseInt(modeOnOff)); + handleGenericErrorResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting cool jet mode", e); + } + } + + @Override + public void changeOperationMode(String bridgeName, String deviceId, int newOpMode) throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Set", "airState.opMode", newOpMode); + handleGenericErrorResult(resp); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + @Override + public void changeFanSpeed(String bridgeName, String deviceId, int newFanSpeed) throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Set", "airState.windStrength", + newFanSpeed); + handleGenericErrorResult(resp); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + @Override + public void changeStepUpDown(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Set", "airState.wDir.vStep", newStep); + handleGenericErrorResult(resp); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + @Override + public void changeStepLeftRight(String bridgeName, String deviceId, ACCanonicalSnapshot currentSnap, int newStep) + throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Set", "airState.wDir.hStep", newStep); + handleGenericErrorResult(resp); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + @Override + public void changeTargetTemperature(String bridgeName, String deviceId, ACTargetTmp newTargetTemp) + throws LGThinqApiException { + try { + RestResult resp = sendBasicControlCommands(bridgeName, deviceId, "Set", "airState.tempState.target", + newTargetTemp.commandValue()); + handleGenericErrorResult(resp); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error adjusting operation mode", e); + } + } + + /** + * Start monitor data form specific device. This is old one, works only on V1 API supported devices. + * + * @param deviceId Device ID + * @return Work1 to be uses to grab data during monitoring. + */ + @Override + public String startMonitor(String bridgeName, String deviceId) { + throw new UnsupportedOperationException("Not supported in V2 API."); + } + + @Override + public void stopMonitor(String bridgeName, String deviceId, String workId) { + throw new UnsupportedOperationException("Not supported in V2 API."); + } + + @Override + public @Nullable ACCanonicalSnapshot getMonitorData(String bridgeName, String deviceId, String workId, + DeviceTypes deviceType, ACCapability deviceCapability) { + throw new UnsupportedOperationException("Not supported in V2 API."); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + try { + RestResult resp = sendCommand(bridgeName, deviceId, "control", "allEventEnable", "Set", + "airState.mon.timeout", "70"); + handleGenericErrorResult(resp); + if (resp.getStatusCode() == 400) { + // Access Denied. Return false to indicate user don't have access to this functionality + return false; + } + } catch (Exception e) { + logger.debug("Can't execute Before Update command", e); + } + return true; + } + + /** + * Expect receiving json of format: { + * ... + * result: { + * data: { + * ... + * } + * ... + * } + * } + * Data node will be deserialized into the object informed + * + * @param jsonResult json result + * @param obj object to be updated + * @throws IOException if there are errors deserialization the jsonResult + */ + private void readDataResultNodeToObject(String jsonResult, Object obj) throws IOException { + JsonNode node = objectMapper.readTree(jsonResult); + JsonNode data = node.path("result").path("data"); + if (data.isObject()) { + objectMapper.readerForUpdating(obj).readValue(data); + } else { + logger.warn("Data returned by LG API to get energy state is not present. Result:{}", node.toPrettyString()); + } + } + + @Override + public ExtendedDeviceInfo getExtendedDeviceInfo(String bridgeName, String deviceId) throws LGThinqApiException { + ExtendedDeviceInfo info = new ExtendedDeviceInfo(); + try { + ObjectNode dataList = JsonNodeFactory.instance.objectNode(); + dataList.put("dataGetList", (Integer) null); + dataList.put("dataSetList", (Integer) null); + + RestResult resp = sendCommand(bridgeName, deviceId, "control-sync", "energyStateCtrl", "Get", + "airState.energy.totalCurrent", "null", dataList); + handleGenericErrorResult(resp); + readDataResultNodeToObject(resp.getJsonResponse(), info); + + ObjectNode dataGetList = JsonNodeFactory.instance.objectNode(); + dataGetList.putArray("dataGetList").add("airState.filterMngStates.useTime") + .add("airState.filterMngStates.maxTime"); + resp = sendCommand(bridgeName, deviceId, "control-sync", "filterMngStateCtrl", "Get", null, null, + dataGetList); + handleGenericErrorResult(resp); + readDataResultNodeToObject(resp.getJsonResponse(), info); + + return info; + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending command to LG API: " + e.getMessage(), e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiClientService.java new file mode 100644 index 00000000000..dab0cecd97b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiClientService.java @@ -0,0 +1,525 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.internal.LGThinQBindingConstants; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.api.RestUtils; +import org.openhab.binding.lgthinq.lgservices.api.TokenManager; +import org.openhab.binding.lgthinq.lgservices.api.TokenResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqAccessException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1MonitorExpiredException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1OfflineException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityFactory; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.LGDevice; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; +import org.openhab.binding.lgthinq.lgservices.model.SnapshotBuilderFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQAbstractApiClientService} - base class for all LG API client service. It's provide commons methods + * to communicate to the LG Cloud and exchange basic data. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@SuppressWarnings("unchecked") +public abstract class LGThinQAbstractApiClientService + implements LGThinQApiClientService { + protected final ObjectMapper objectMapper = new ObjectMapper(); + protected final TokenManager tokenManager; + protected final Class capabilityClass; + protected final Class snapshotClass; + protected final HttpClient httpClient; + private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiClientService.class); + private final String clientId = ""; + + protected LGThinQAbstractApiClientService(Class capabilityClass, Class snapshotClass, HttpClient httpClient) { + this.httpClient = httpClient; + this.tokenManager = new TokenManager(httpClient); + this.capabilityClass = capabilityClass; + this.snapshotClass = snapshotClass; + } + + protected static String getErrorCodeMessage(@Nullable String code) { + if (code == null) { + return ""; + } + ResultCodes resultCode = ResultCodes.fromCode(code); + return resultCode.getDescription(); + } + + /** + * Retrieves the client ID based on the provided user number. + * + * @param userNumber the user number to generate the client ID + * @return the generated client ID + */ + private String getClientId(String userNumber) { + if (!clientId.isEmpty()) { + return clientId; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String data = userNumber + Instant.now().toString(); + byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("SHA-256 algorithm not found", e); + } + } + + private String bytesToHex(byte[] bytes) { + StringBuilder hexString = new StringBuilder(); + for (byte b : bytes) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } + + Map getCommonHeaders(String language, String country, String accessToken, String userNumber) { + Map headers = new HashMap<>(); + headers.put("Accept", "application/json"); + headers.put("Content-type", "application/json;charset=UTF-8"); + headers.put("x-api-key", LG_API_V2_API_KEY); + headers.put("x-app-version", "LG ThinQ/5.0.28271"); + headers.put("x-client-id", getClientId(userNumber)); + headers.put("x-country-code", country); + headers.put("x-language-code", language); + headers.put("x-message-id", UUID.randomUUID().toString()); + headers.put("x-service-code", LG_API_SVC_CODE); + headers.put("x-service-phase", LG_API_V2_SVC_PHASE); + headers.put("x-thinq-app-level", LG_API_V2_APP_LEVEL); + headers.put("x-thinq-app-os", LG_API_V2_APP_OS); + headers.put("x-thinq-app-type", LG_API_V2_APP_TYPE); + headers.put("x-thinq-app-ver", LG_API_V2_APP_VER); + headers.put("x-thinq-app-logintype", "LGE"); + headers.put("x-origin", "app-native"); + headers.put("x-device-type", "601"); + + if (!accessToken.isBlank()) { + headers.put("x-emp-token", accessToken); + } + if (!userNumber.isBlank()) { + headers.put("x-user-no", userNumber); + } + return headers; + } + + /** + * Even using V2 URL, this endpoint support grab information about account devices from V1 and V2. + * + * @return list os LG Devices. + * @throws LGThinqApiException if some communication error occur. + */ + @Override + public List listAccountDevices(String bridgeName) throws LGThinqApiException { + try { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV2()).path(LG_API_V2_LS_PATH); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + RestResult resp = RestUtils.getCall(httpClient, builder.build().toURL().toString(), headers, null); + return handleListAccountDevicesResult(resp); + } catch (Exception e) { + throw new LGThinqApiException("Error listing account devices from LG Server API", e); + } + } + + @Override + public File loadDeviceCapability(String deviceId, String uri, boolean forceRecreate) throws LGThinqApiException { + File regFile = new File(String.format(getBaseCapConfigDataFile(), deviceId)); + try { + if (!regFile.isFile() || forceRecreate) { + try (InputStream in = new URI(uri).toURL().openStream()) { + Files.copy(in, regFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException | URISyntaxException e) { + throw new LGThinqApiException("Error reading IO interface", e); + } + return regFile; + } + + /** + * Get device settings and snapshot for a specific device. + * It works only for API V2 device versions! + * + * @param deviceId device ID for de desired V2 LG Thinq. + * @return return map containing metamodel of settings and snapshot + * @throws LGThinqApiException if some communication error occur. + */ + @Override + public Map getDeviceSettings(String bridgeName, String deviceId) throws LGThinqApiException { + try { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV2()) + .path(String.format("%s/%s", LG_API_V2_DEVICE_CONFIG_PATH, deviceId)); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + RestResult resp = RestUtils.getCall(httpClient, builder.build().toURL().toString(), headers, null); + return handleDeviceSettingsResult(resp); + } catch (LGThinqException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Errors list account devices from LG Server API", e); + } + } + + private Map handleDeviceSettingsResult(RestResult resp) throws LGThinqApiException { + return genericHandleDeviceSettingsResult(resp, objectMapper); + } + + /** + * Handles the result of device settings retrieved from an API call. + * + * @param resp The RestResult object containing the API response + * @param objectMapper The ObjectMapper to convert JSON to Java objects + * @return A Map containing the device settings + * @throws LGThinqApiException If an error occurs during handling the device settings result + */ + protected Map genericHandleDeviceSettingsResult(RestResult resp, ObjectMapper objectMapper) + throws LGThinqApiException { + Map deviceSettings; + Map respMap; + String resultCode; + if (resp.getStatusCode() != 200) { + if (resp.getStatusCode() == 400) { + logger.warn("Error calling device settings from LG Server API. HTTP Status: {}. The reason is: {}", + resp.getStatusCode(), ResultCodes.getReasonResponse(resp.getJsonResponse())); + throw new LGThinqAccessException(String.format( + "Error calling device settings from LG Server API. HTTP Status: %d. The reason is: %s", + resp.getStatusCode(), ResultCodes.getReasonResponse(resp.getJsonResponse()))); + } + try { + respMap = objectMapper.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + resultCode = respMap.get("resultCode"); + if (resultCode != null) { + throw new LGThinqApiException(String.format( + "Error calling device settings from LG Server API. The code is: %s and The reason is: %s", + resultCode, ResultCodes.fromCode(resultCode))); + } + } catch (JsonProcessingException e) { + // This exception doesn't matter, it's because response is not in json format. Logging raw response. + logger.trace( + "Error calling device settings from LG Server API. Response is not in json format. Ignoring...", + e); + } + throw new LGThinqApiException(String.format( + "Error calling device settings from LG Server API. The reason is:%s", resp.getJsonResponse())); + } else { + try { + deviceSettings = objectMapper.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + String code = Objects.requireNonNullElse((String) deviceSettings.get("resultCode"), ""); + if (!ResultCodes.OK.containsResultCode(code)) { + throw new LGThinqApiException(String.format( + "LG API report error processing the request -> resultCode=[{%s], message=[%s]", code, + getErrorCodeMessage(code))); + } + } catch (JsonProcessingException e) { + throw new IllegalStateException("Unknown error occurred deserializing json stream", e); + } + } + return Objects.requireNonNull((Map) deviceSettings.get("result"), + "Unexpected json result asking for Device Settings. Node 'result' no present"); + } + + private List handleListAccountDevicesResult(RestResult resp) throws LGThinqApiException { + Map devicesResult; + List devices; + if (resp.getStatusCode() != 200) { + if (resp.getStatusCode() == 400) { + logger.warn("Error calling device list from LG Server API. HTTP Status: {}. The reason is: {}", + resp.getStatusCode(), ResultCodes.getReasonResponse(resp.getJsonResponse())); + return Collections.emptyList(); + } + throw new LGThinqApiException( + String.format("Error calling device list from LG Server API. HTTP Status: %s. The reason is: %s", + resp.getStatusCode(), ResultCodes.getReasonResponse(resp.getJsonResponse()))); + } else { + try { + devicesResult = objectMapper.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + String code = Objects.requireNonNullElse((String) devicesResult.get("resultCode"), ""); + if (!ResultCodes.OK.containsResultCode(code)) { + throw new LGThinqApiException( + String.format("LG API report error processing the request -> resultCode=[%s], message=[%s]", + code, getErrorCodeMessage(code))); + } + List> items = (List>) ((Map) Objects + .requireNonNull(devicesResult.get("result"), "Not expected null here")).get("item"); + devices = objectMapper.convertValue(items, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Unknown error occurred deserializing json stream.", e); + } + } + + return devices; + } + + /** + * Get capability em registry/cache on file for next consult + * + * @param deviceId ID of the device + * @param uri URI of the config capability + * @return return simplified capability + * @throws LGThinqApiException If some error occurr + */ + public C getCapability(String deviceId, String uri, boolean forceRecreate) throws LGThinqApiException { + try { + File regFile = loadDeviceCapability(deviceId, uri, forceRecreate); + JsonNode rootNode = objectMapper.readTree(regFile); + return CapabilityFactory.getInstance().create(rootNode, capabilityClass); + } catch (IOException e) { + throw new LGThinqApiException("Error reading IO interface", e); + } catch (LGThinqException e) { + throw new LGThinqApiException("Error parsing capability registry", e); + } + } + + public S buildDefaultOfflineSnapshot() { + try { + // As I don't know the current device status, then I reset to default values. + + S shot = snapshotClass.getDeclaredConstructor().newInstance(); + shot.setPowerStatus(DevicePowerState.DV_POWER_OFF); + shot.setOnline(false); + return shot; + } catch (Exception ex) { + throw new IllegalStateException("Unexpected Error. The default constructor of this Snapshot wasn't found", + ex); + } + } + + public @Nullable S getMonitorData(String bridgeName, String deviceId, String workId, DeviceTypes deviceType, + C deviceCapability) throws LGThinqApiException, LGThinqDeviceV1MonitorExpiredException, IOException, + LGThinqUnmarshallException { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV1()).path(LG_API_V1_MON_DATA_PATH); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + String jsonData = String.format("{\n" + " \"lgedmRoot\":{\n" + " \"workList\":[\n" + " {\n" + + " \"deviceId\":\"%s\",\n" + " \"workId\":\"%s\"\n" + " }\n" + + " ]\n" + " }\n" + "}", deviceId, workId); + RestResult resp = RestUtils.postCall(httpClient, builder.build().toURL().toString(), headers, jsonData); + Map envelop; + // to unify the same behaviour then V2, this method handle Offline Exception and return a dummy shot with + // offline flag. + try { + envelop = handleGenericErrorResult(resp); + } catch (LGThinqDeviceV1OfflineException e) { + return buildDefaultOfflineSnapshot(); + } + Map workList = objectMapper + .convertValue(envelop.getOrDefault("workList", Collections.emptyMap()), new TypeReference<>() { + }); + if (workList.get("returnData") != null) { + if (logger.isDebugEnabled()) { + try { + objectMapper.writeValue(new File(String.format(LGThinQBindingConstants.getThinqUserDataFolder() + + File.separator + "thinq-%s-datatrace.json", deviceId)), workList); + } catch (IOException e) { + // Only debug since datatrace is a trace data. + logger.debug("Unexpected error saving data trace", e); + } + } + + if (!ResultCodes.OK.containsResultCode("" + workList.get("returnCode"))) { + String code = Objects.requireNonNullElse((String) workList.get("returnCode"), ""); + logger.debug("LG API report error processing the request -> resultCode=[{}], message=[{}]", code, + getErrorCodeMessage(code)); + LGThinqDeviceV1MonitorExpiredException e = new LGThinqDeviceV1MonitorExpiredException( + String.format("Monitor for device %s has expired. Please, refresh the monitor.", deviceId)); + logger.warn("{}", e.getMessage()); + throw e; + } + + String monDataB64 = (String) workList.get("returnData"); + String monData = new String(Base64.getDecoder().decode(monDataB64)); + S shot; + try { + if (MonitoringResultFormat.JSON_FORMAT.equals(deviceCapability.getMonitoringDataFormat())) { + shot = (S) SnapshotBuilderFactory.getInstance().getBuilder(snapshotClass).createFromJson(monData, + deviceType, deviceCapability); + } else if (MonitoringResultFormat.BINARY_FORMAT.equals(deviceCapability.getMonitoringDataFormat())) { + shot = (S) SnapshotBuilderFactory.getInstance().getBuilder(snapshotClass).createFromBinary(monData, + deviceCapability.getMonitoringBinaryProtocol(), deviceCapability); + } else { + throw new LGThinqApiException(String.format("Returned data format not supported: %s", + deviceCapability.getMonitoringDataFormat())); + } + shot.setOnline("E".equals(workList.get("deviceState"))); + } catch (LGThinqUnmarshallException ex) { + // error in the monitor Data returned. Device is irresponsible + logger.debug("Monitored data returned for the device {} is unreadable. Device is not connected", + deviceId); + throw ex; + } + return shot; + } else { + // no data available yet + return null; + } + } + + @Override + public void initializeDevice(String bridgeName, String deviceId) throws LGThinqApiException { + logger.debug("Initializing device [{}] from bridge [{}]", deviceId, bridgeName); + } + + /** + * Perform some routine before getting data device. Depending on the kind of the device, this is needed + * to update or prepare some informations before go to get the data. + * + * @return false if the device doesn't support pre-condition commands + */ + protected abstract boolean beforeGetDataDevice(String bridgeName, String deviceId); + + /** + * Get snapshot data from the device. + * It works only for API V2 device versions! + * + * @param deviceId device ID for de desired V2 LG Thinq. + * @param capDef + * @return return map containing metamodel of settings and snapshot + * @throws LGThinqApiException if some communication error occur. + */ + @Override + @Nullable + public S getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) throws LGThinqApiException { + // Exec pre-conditions (normally ask for update monitoring sensors of the device - temp and power) before call + // for data + if (capDef.isBeforeCommandSupported() && !beforeGetDataDevice(bridgeName, deviceId)) { + capDef.setBeforeCommandSupported(false); + } + + Map deviceSettings = getDeviceSettings(bridgeName, deviceId); + if (deviceSettings.get("snapshot") != null) { + Map snapMap = (Map) deviceSettings.get("snapshot"); + if (logger.isDebugEnabled()) { + try { + objectMapper.writeValue(new File(String.format(LGThinQBindingConstants.getThinqUserDataFolder() + + File.separator + "thinq-%s-datatrace.json", deviceId)), deviceSettings); + } catch (IOException e) { + logger.debug("Error saving data trace", e); + } + } + if (snapMap == null) { + // No snapshot value provided + return null; + } + S shot = (S) SnapshotBuilderFactory.getInstance().getBuilder(snapshotClass).createFromJson(deviceSettings, + capDef); + shot.setOnline((Boolean) snapMap.getOrDefault("online", Boolean.FALSE)); + return shot; + } + return null; + } + + /** + * Start monitor data form specific device. This is old one, works only on V1 API supported devices. + * + * @param deviceId Device ID + * @return Work1 to be uses to grab data during monitoring. + * @throws LGThinqApiException If some communication error occur. + */ + @Override + public String startMonitor(String bridgeName, String deviceId) throws LGThinqApiException, IOException { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV1()).path(LG_API_V1_START_MON_PATH); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + String workerId = UUID.randomUUID().toString(); + String jsonData = String.format(" { \"lgedmRoot\" : {" + "\"cmd\": \"Mon\"," + "\"cmdOpt\": \"Start\"," + + "\"deviceId\": \"%s\"," + "\"workId\": \"%s\"" + "} }", deviceId, workerId); + RestResult resp = RestUtils.postCall(httpClient, builder.build().toURL().toString(), headers, jsonData); + Map respMap = handleGenericErrorResult(resp); + if (respMap.isEmpty()) { + logger.debug( + "Unexpected StartMonitor json null result. Possible causes: 1) you are monitoring the device in LG App at same time, 2) temporary problems in the server. Try again later"); + } + return Objects.requireNonNull((String) handleGenericErrorResult(resp).get("workId"), + "Unexpected StartMonitor json result. Node 'workId' not present"); + } + + @Override + public void stopMonitor(String bridgeName, String deviceId, String workId) throws LGThinqApiException, IOException { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV1()).path(LG_API_V1_START_MON_PATH); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + String jsonData = String.format(" { \"lgedmRoot\" : {" + "\"cmd\": \"Mon\"," + "\"cmdOpt\": \"Stop\"," + + "\"deviceId\": \"%s\"," + "\"workId\": \"%s\"" + "} }", deviceId, workId); + RestResult resp = RestUtils.postCall(httpClient, builder.build().toURL().toString(), headers, jsonData); + handleGenericErrorResult(resp); + } + + protected Map getCommonV2Headers(String language, String country, String accessToken, + String userNumber) { + return getCommonHeaders(language, country, accessToken, userNumber); + } + + protected abstract RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, String keyName, String value) throws Exception; + + protected abstract RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, @Nullable String keyName, @Nullable String value, @Nullable ObjectNode extraNode) + throws Exception; + + protected abstract Map handleGenericErrorResult(@Nullable RestResult resp) + throws LGThinqApiException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV1ClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV1ClientService.java new file mode 100644 index 00000000000..13b78598c80 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV1ClientService.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.LG_API_V1_CONTROL_OP; + +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.api.RestUtils; +import org.openhab.binding.lgthinq.lgservices.api.TokenResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1OfflineException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQAbstractApiV1ClientService} - Specialized abstract class that implements methods and services to + * handle LG API V1 communication and convention. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class LGThinQAbstractApiV1ClientService + extends LGThinQAbstractApiClientService { + private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiV1ClientService.class); + + protected LGThinQAbstractApiV1ClientService(Class capabilityClass, Class snapshotClass, + HttpClient httpClient) { + super(capabilityClass, snapshotClass, httpClient); + } + + @Override + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, String keyName, String value) throws Exception { + return sendCommand(bridgeName, deviceId, controlPath, controlKey, command, keyName, value, null); + } + + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, Map<@Nullable String, @Nullable Object> keyValue, @Nullable ObjectNode extraNode) + throws Exception { + ObjectNode payloadNode = JsonNodeFactory.instance.objectNode(); + payloadNode.put("cmd", controlKey).put("cmdOpt", command); + keyValue.forEach((k, v) -> { + if (k == null || k.isEmpty()) { + // value is a simple text + if (v instanceof Integer i) { + payloadNode.put("value", i); + } else if (v instanceof Double d) { + payloadNode.put("value", d); + } else { + payloadNode.put("value", "" + v); + } + } else { + JsonNode valueNode = payloadNode.path("value"); + if (valueNode.isMissingNode()) { + valueNode = payloadNode.putObject("value"); + } + if (v instanceof Integer i) { + ((ObjectNode) valueNode).put(k, i); + } else if (v instanceof Double d) { + ((ObjectNode) valueNode).put(k, d); + } else { + ((ObjectNode) valueNode).put(k, "" + v); + } + } + }); + if (extraNode != null) { + payloadNode.setAll(extraNode); + } + return sendCommand(bridgeName, deviceId, payloadNode); + } + + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, @Nullable String keyName, @Nullable String value, @Nullable ObjectNode extraNode) + throws Exception { + Map<@Nullable String, @Nullable Object> values = new HashMap<>(1); + values.put(keyName, value); + return sendCommand(bridgeName, deviceId, controlPath, controlKey, command, values, extraNode); + } + + protected RestResult sendCommand(String bridgeName, String deviceId, Object cmdPayload) throws Exception { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV1()).path(LG_API_V1_CONTROL_OP); + Map headers = getCommonHeaders(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + ObjectNode payloadNode; + if (cmdPayload instanceof ObjectNode oNode) { + payloadNode = oNode.deepCopy(); + } else { + payloadNode = objectMapper.convertValue(cmdPayload, new TypeReference<>() { + }); + } + ObjectNode rootNode = JsonNodeFactory.instance.objectNode(); + ObjectNode bodyNode = JsonNodeFactory.instance.objectNode(); + bodyNode.put("deviceId", deviceId); + bodyNode.put("workId", UUID.randomUUID().toString()); + bodyNode.setAll(payloadNode); + rootNode.set("lgedmRoot", bodyNode); + String url = builder.build().toURL().toString(); + logger.debug("URL: {}, Post Payload:[{}]", url, rootNode.toPrettyString()); + RestResult resp = RestUtils.postCall(httpClient, url, headers, rootNode.toPrettyString()); + if (resp == null) { + logger.warn("Null result returned sending command to LG API V1"); + throw new LGThinqApiException("Null result returned sending command to LG API V1"); + } + return resp; + } + + @Override + protected Map handleGenericErrorResult(@Nullable RestResult resp) throws LGThinqApiException { + Map metaResult; + Map envelope = Collections.emptyMap(); + if (resp == null) { + return envelope; + } + if (resp.getStatusCode() != 200) { + if (resp.getStatusCode() == 400) { + logger.warn("Error returned by LG Server API. HTTP Status: {}. The reason is: {}", resp.getStatusCode(), + resp.getJsonResponse()); + } else { + throw new LGThinqApiException( + String.format("Error returned by LG Server API. HTTP Status: %s. The reason is: %s", + resp.getStatusCode(), resp.getJsonResponse())); + } + } else { + try { + metaResult = objectMapper.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + envelope = objectMapper.convertValue(metaResult.get("lgedmRoot"), new TypeReference<>() { + }); + String code = String.valueOf(envelope.get("returnCd")); + if (envelope.isEmpty()) { + throw new LGThinqApiException(String.format( + "Unexpected json body returned (without root node lgedmRoot): %s", resp.getJsonResponse())); + } else if (!ResultCodes.OK.containsResultCode(code)) { + if (ResultCodes.DEVICE_NOT_RESPONSE.containsResultCode("" + envelope.get("returnCd")) + || "D".equals(envelope.get("deviceState"))) { + logger.debug("LG API report error processing the request -> resultCode=[{}], message=[{}]", + code, getErrorCodeMessage(code)); + // Disconnected Device + throw new LGThinqDeviceV1OfflineException("Device is offline. No data available"); + } + throw new LGThinqApiException(String + .format("Status error executing endpoint. resultCode must be 0000, but was:%s", code)); + } + } catch (JsonProcessingException e) { + throw new IllegalStateException("Unknown error occurred deserializing json stream", e); + } + } + return envelope; + } + + /** + * Principal method to prepare the command to be sent to V1 Devices mainly when the command is generic, + * i.e, you can send a command structure to redefine any changeable feature of the device + * + * @param cmdDef command definition with template of the payload and data (binary or not) + * @param snapData snapshot data with features to be set in the device + * @return return the command structure. + * @throws JsonProcessingException unmarshall error. + */ + protected Map prepareCommandV1(CommandDefinition cmdDef, Map snapData) + throws JsonProcessingException { + // expected map ordered here + String dataStr = cmdDef.getDataTemplate(); + // Keep the order + for (Map.Entry e : snapData.entrySet()) { + String value = String.valueOf(e.getValue()); + dataStr = dataStr.replace("{{" + e.getKey() + "}}", value); + } + + return completeCommandDataNodeV1(cmdDef, dataStr); + } + + protected LinkedHashMap completeCommandDataNodeV1(CommandDefinition cmdDef, String dataStr) + throws JsonProcessingException { + LinkedHashMap data = objectMapper.readValue(cmdDef.getRawCommand(), new TypeReference<>() { + }); + logger.debug("Prepare command v1: {}", dataStr); + if (cmdDef.isBinary()) { + data.put("format", "B64"); + List list = objectMapper.readValue(dataStr, new TypeReference<>() { + }); + // convert the list of integer to a bytearray + + byte[] byteArray = new byte[list.size()]; + for (int i = 0; i < list.size(); i++) { + byteArray[i] = list.get(i).byteValue(); // Converte Integer para byte + } + data.put("data", new String(Base64.getEncoder().encode(byteArray))); + } else { + data.put("data", dataStr); + } + return data; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV2ClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV2ClientService.java new file mode 100644 index 00000000000..19891513bac --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQAbstractApiV2ClientService.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.LG_API_V2_CTRL_DEVICE_CONFIG_PATH; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.api.RestUtils; +import org.openhab.binding.lgthinq.lgservices.api.TokenResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQAbstractApiV2ClientService} - Specialized abstract class that implements methods and services to + * * handle LG API V2 communication and convention. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class LGThinQAbstractApiV2ClientService + extends LGThinQAbstractApiClientService { + private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiV2ClientService.class); + + protected LGThinQAbstractApiV2ClientService(Class capabilityClass, Class snapshotClass, + HttpClient httpClient) { + super(capabilityClass, snapshotClass, httpClient); + } + + @Override + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, String keyName, String value) throws Exception { + return sendCommand(bridgeName, deviceId, controlPath, controlKey, command, keyName, value, null); + } + + protected RestResult postCall(String bridgeName, String deviceId, String controlPath, String payload) + throws LGThinqApiException, IOException { + TokenResult token = tokenManager.getValidRegisteredToken(bridgeName); + UriBuilder builder = UriBuilder.fromUri(token.getGatewayInfo().getApiRootV2()) + .path(String.format(LG_API_V2_CTRL_DEVICE_CONFIG_PATH, deviceId, controlPath)); + Map headers = getCommonV2Headers(token.getGatewayInfo().getLanguage(), + token.getGatewayInfo().getCountry(), token.getAccessToken(), token.getUserInfo().getUserNumber()); + RestResult resp = RestUtils.postCall(httpClient, builder.build().toURL().toString(), headers, payload); + if (resp == null) { + logger.warn("Null result returned sending command to LG API V2: {}, {}, {}", deviceId, controlPath, + payload); + throw new LGThinqApiException("Null result returned sending command to LG API V2"); + } + return resp; + } + + @Override + public RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, @Nullable String keyName, @Nullable String value, @Nullable ObjectNode extraNode) + throws Exception { + ObjectNode payload = JsonNodeFactory.instance.objectNode(); + payload.put("ctrlKey", controlKey).put("command", command).put("dataKey", keyName).put("dataValue", value); + if (extraNode != null) { + payload.setAll(extraNode); + } + return postCall(bridgeName, deviceId, controlPath, payload.toPrettyString()); + } + + protected RestResult sendBasicControlCommands(String bridgeName, String deviceId, String command, String keyName, + int value) throws Exception { + return sendCommand(bridgeName, deviceId, "control-sync", "basicCtrl", command, keyName, String.valueOf(value)); + } + + @Override + protected Map handleGenericErrorResult(@Nullable RestResult resp) throws LGThinqApiException { + Map metaResult; + if (resp == null) { + return Collections.emptyMap(); + } + if (resp.getStatusCode() != 200) { + if (resp.getStatusCode() == 400) { + if (logger.isDebugEnabled()) { + logger.warn("Error returned by LG Server API. HTTP Status: {}. The reason is: {}\n {}", + resp.getStatusCode(), resp.getJsonResponse(), Thread.currentThread().getStackTrace()); + } else { + logger.warn("Error returned by LG Server API. HTTP Status: {}. The reason is: {}", + resp.getStatusCode(), resp.getJsonResponse()); + } + return Collections.emptyMap(); + } else { + throw new LGThinqApiException( + String.format("Error returned by LG Server API. HTTP Status: %s. The reason is: %s", + resp.getStatusCode(), resp.getJsonResponse())); + } + } else { + try { + metaResult = objectMapper.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + String code = (String) metaResult.get("resultCode"); + if (!ResultCodes.OK.containsResultCode(String.valueOf(metaResult.get("resultCode")))) { + throw new LGThinqApiException( + String.format("LG API report error processing the request -> resultCode=[%s], message=[%s]", + code, getErrorCodeMessage(code))); + } + return metaResult; + } catch (JsonProcessingException e) { + throw new IllegalStateException("Unknown error occurred deserializing json stream", e); + } + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientService.java new file mode 100644 index 00000000000..2ca398ff205 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientService.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqDeviceV1MonitorExpiredException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.LGDevice; +import org.openhab.binding.lgthinq.lgservices.model.SnapshotDefinition; + +/** + * The {@link LGThinQApiClientService} interface defines the core methods for managing LG ThinQ devices + * via the LG Cloud API. It provides functionalities for retrieving device metadata, controlling power states, + * monitoring device status, and handling device capabilities. + * + * @param The type representing the capability definition for a device. + * @param The type representing a snapshot definition of device data. + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQApiClientService { + + /** + * Retrieves a list of all devices registered under the LG account. + * + * @param bridgeName The name of the bridge managing the devices. + * @return A list of {@link LGDevice} representing the registered devices. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + List listAccountDevices(String bridgeName) throws LGThinqApiException; + + /** + * Retrieves device metadata, including settings and capabilities. + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @return A map containing device settings and metadata. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + Map getDeviceSettings(String bridgeName, String deviceId) throws LGThinqApiException; + + /** + * Initializes a device, preparing it for interaction. + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + void initializeDevice(String bridgeName, String deviceId) throws LGThinqApiException; + + /** + * Retrieves the latest data snapshot from the device, including sensor readings and state values. + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @param capDef The capability definition of the device. + * @return A snapshot containing the device's current data. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + @Nullable + S getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) throws LGThinqApiException; + + /** + * Toggles the power state of the device (on/off). + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @param newPowerState The desired power state. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) throws LGThinqApiException; + + /** + * Starts a monitoring session for data collection (only applicable for protocol V1). + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @return The monitor session ID. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + * @throws IOException If an error occurs while handling device configuration files. + */ + String startMonitor(String bridgeName, String deviceId) throws LGThinqApiException, IOException; + + /** + * Retrieves the capability definition of the device. + * + * @param deviceId The unique ID of the LG ThinQ device. + * @param uri The URI containing the XML descriptor of the device. + * @param forceRecreate Whether to force recreation of the cached capability file. + * @return The capability definition of the device. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + C getCapability(String deviceId, String uri, boolean forceRecreate) throws LGThinqApiException; + + /** + * Builds a default snapshot to maintain data integrity when the device is offline. + * + * @return A default snapshot representing offline device data. + */ + S buildDefaultOfflineSnapshot(); + + /** + * Loads device capabilities from a cached file or retrieves them from the API if necessary. + * + * @param deviceId The unique ID of the LG ThinQ device. + * @param uri The URI used to retrieve capability data if the file is missing. + * @param forceRecreate Whether to force recreation of the cached file. + * @return A file containing the device capability data. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + */ + File loadDeviceCapability(String deviceId, String uri, boolean forceRecreate) throws LGThinqApiException; + + /** + * Stops a previously started monitoring session. + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @param workId The monitor session ID. + * @throws LGThinqException If an error occurs while stopping the monitor. + * @throws IOException If an error occurs while handling device configuration files. + */ + void stopMonitor(String bridgeName, String deviceId, String workId) throws LGThinqException, IOException; + + /** + * Retrieves data collected from an active monitoring session. + * + * @param bridgeName The name of the bridge managing the device. + * @param deviceId The unique ID of the LG ThinQ device. + * @param workerId The monitoring session ID. + * @param deviceType The type of device being monitored. + * @param deviceCapability The capability definition of the device. + * @return A snapshot containing the collected data. + * @throws LGThinqApiException If an error occurs while accessing the LG API. + * @throws LGThinqDeviceV1MonitorExpiredException If the monitoring session has expired. + * @throws IOException If an error occurs while accessing cached token files. + * @throws LGThinqUnmarshallException If an error occurs while parsing collected data. + */ + @Nullable + S getMonitorData(String bridgeName, String deviceId, String workerId, DeviceTypes deviceType, C deviceCapability) + throws LGThinqApiException, LGThinqDeviceV1MonitorExpiredException, IOException, LGThinqUnmarshallException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientServiceFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientServiceFactory.java new file mode 100644 index 00000000000..d81cbe03bc1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQApiClientServiceFactory.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.LG_API_PLATFORM_TYPE_V1; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapability; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.core.io.net.http.HttpClientFactory; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Creates specialized API clients. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQApiClientServiceFactory { + + public static LGThinQGeneralApiClientService newGeneralApiClientService(HttpClientFactory httpClientFactory) { + return new LGThinQGeneralApiClientService(httpClientFactory.getCommonHttpClient()); + } + + public static LGThinQACApiClientService newACApiClientService(String lgPlatformType, + HttpClientFactory httpClientFactory) { + return lgPlatformType.equals(LG_API_PLATFORM_TYPE_V1) + ? new LGThinQACApiV1ClientServiceImpl(httpClientFactory.getCommonHttpClient()) + : new LGThinQACApiV2ClientServiceImpl(httpClientFactory.getCommonHttpClient()); + } + + public static LGThinQFridgeApiClientService newFridgeApiClientService(String lgPlatformType, + HttpClientFactory httpClientFactory) { + return lgPlatformType.equals(LG_API_PLATFORM_TYPE_V1) + ? new LGThinQFridgeApiV1ClientServiceImpl(httpClientFactory.getCommonHttpClient()) + : new LGThinQFridgeApiV2ClientServiceImpl(httpClientFactory.getCommonHttpClient()); + } + + public static LGThinQWMApiClientService newWMApiClientService(String lgPlatformType, + HttpClientFactory httpClientFactory) { + return lgPlatformType.equals(LG_API_PLATFORM_TYPE_V1) + ? new LGThinQWMApiV1ClientServiceImpl(httpClientFactory.getCommonHttpClient()) + : new LGThinQWMApiV2ClientServiceImpl(httpClientFactory.getCommonHttpClient()); + } + + public static LGThinQDishWasherApiClientService newDishWasherApiClientService(String lgPlatformType, + HttpClientFactory httpClientFactory) { + return lgPlatformType.equals(LG_API_PLATFORM_TYPE_V1) + ? new LGThinQDishWasherApiV1ClientServiceImpl(httpClientFactory.getCommonHttpClient()) + : new LGThinQDishWasherApiV2ClientServiceImpl(httpClientFactory.getCommonHttpClient()); + } + + public static final class LGThinQGeneralApiClientService + extends LGThinQAbstractApiClientService { + + private LGThinQGeneralApiClientService(HttpClient httpClient) { + super(GenericCapability.class, AbstractSnapshotDefinition.class, httpClient); + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException(); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + throw new UnsupportedOperationException(); + } + + @Override + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, String keyName, String value) { + throw new UnsupportedOperationException(); + } + + @Override + protected RestResult sendCommand(String bridgeName, String deviceId, String controlPath, String controlKey, + String command, @Nullable String keyName, @Nullable String value, @Nullable ObjectNode extraNode) { + throw new UnsupportedOperationException(); + } + + @Override + protected Map handleGenericErrorResult(@Nullable RestResult resp) { + throw new UnsupportedOperationException(); + } + } + + private static final class GenericCapability extends AbstractCapability { + + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiClientService.java new file mode 100644 index 00000000000..9bdc38eb002 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiClientService.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshot; + +/** + * {@link LGThinQDishWasherApiClientService} provides specific methods for interacting with LG ThinQ dishwashers. + * It extends the {@link LGThinQApiClientService} to inherit core functionalities while adding specialized methods + * for dishwashers. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQDishWasherApiClientService + extends LGThinQApiClientService { + + /** + * Initiates a remote start operation for the dishwasher. + * + * @param bridgeName The name of the bridge managing the device. + * @param cap The capability definition of the dishwasher. + * @param deviceId The unique identifier of the LG ThinQ dishwasher. + * @param data A map containing the required parameters for remote start. + */ + void remoteStart(String bridgeName, DishWasherCapability cap, String deviceId, Map data); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV1ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV1ClientServiceImpl.java new file mode 100644 index 00000000000..b41ba0879a3 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV1ClientServiceImpl.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshot; + +/** + * The {@link LGThinQDishWasherApiV1ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQDishWasherApiV1ClientServiceImpl + extends LGThinQAbstractApiV1ClientService + implements LGThinQDishWasherApiClientService { + + protected LGThinQDishWasherApiV1ClientServiceImpl(HttpClient httpClient) { + super(DishWasherCapability.class, DishWasherSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Not Supported for this device"); + } + + @Override + @Nullable + public DishWasherSnapshot getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) { + throw new UnsupportedOperationException("Method not supported in V1 API device."); + } + + @Override + public void remoteStart(String bridgeName, DishWasherCapability cap, String deviceId, Map data) { + throw new UnsupportedOperationException("Not implemented yet"); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV2ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV2ClientServiceImpl.java new file mode 100644 index 00000000000..bad3ff26d84 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQDishWasherApiV2ClientServiceImpl.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshot; + +/** + * The {@link LGThinQDishWasherApiV2ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQDishWasherApiV2ClientServiceImpl + extends LGThinQAbstractApiV2ClientService + implements LGThinQDishWasherApiClientService { + + protected LGThinQDishWasherApiV2ClientServiceImpl(HttpClient httpClient) { + super(DishWasherCapability.class, DishWasherSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Unsupported for this device"); + } + + @Override + public void remoteStart(String bridgeName, DishWasherCapability cap, String deviceId, Map data) { + throw new UnsupportedOperationException("Not implemented yet"); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiClientService.java new file mode 100644 index 00000000000..af5732f3d8d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiClientService.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapability; + +/** + * {@link LGThinQFridgeApiClientService} defines methods for interacting with LG ThinQ refrigerator devices. + * It extends {@link LGThinQApiClientService} to provide core functionalities while adding refrigerator-specific + * operations. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQFridgeApiClientService + extends LGThinQApiClientService { + + /** + * Sets the refrigerator temperature. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG refrigerator. + * @param fridgeCapability The capabilities definition of the refrigerator. + * @param targetTemperatureIndex The desired temperature index. + * @param tempUnit The unit of temperature measurement. + * @param snapCmdData Optional snapshot template for the temperature command. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setFridgeTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException; + + /** + * Sets the freezer temperature. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG freezer. + * @param fridgeCapability The capabilities definition of the freezer. + * @param targetTemperatureIndex The desired temperature index. + * @param tempUnit The unit of temperature measurement. + * @param snapCmdData Optional snapshot template for the temperature command. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setFreezerTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException; + + /** + * Activates or deactivates the Express Mode. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG refrigerator. + * @param expressModeIndex The desired express mode setting. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setExpressMode(String bridgeId, String deviceId, String expressModeIndex) throws LGThinqApiException; + + /** + * Enables or disables the Express Cool Mode. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG refrigerator. + * @param enable {@code true} to enable Express Cool Mode, {@code false} to disable. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setExpressCoolMode(String bridgeId, String deviceId, boolean enable) throws LGThinqApiException; + + /** + * Enables or disables the Eco-Friendly Mode. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG refrigerator. + * @param enable {@code true} to enable Eco Mode, {@code false} to disable. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setEcoFriendlyMode(String bridgeId, String deviceId, boolean enable) throws LGThinqApiException; + + /** + * Enables or disables the Ice Plus feature. + * + * @param bridgeId The bridge ID managing the device. + * @param deviceId The unique identifier of the LG refrigerator. + * @param fridgeCapability The capabilities definition of the refrigerator. + * @param enable {@code true} to enable Ice Plus, {@code false} to disable. + * @param snapCmdData A map containing the snapshot template for the Ice Plus command. + * @throws LGThinqApiException If an error occurs while communicating with the LG API. + */ + void setIcePlus(String bridgeId, String deviceId, FridgeCapability fridgeCapability, boolean enable, + Map snapCmdData) throws LGThinqApiException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV1ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV1ClientServiceImpl.java new file mode 100644 index 00000000000..8f3df31eab4 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV1ClientServiceImpl.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.RE_SET_CONTROL_COMMAND_NAME_V1; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapability; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link LGThinQFridgeApiV1ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQFridgeApiV1ClientServiceImpl + extends LGThinQAbstractApiV1ClientService + implements LGThinQFridgeApiClientService { + private final Logger logger = LoggerFactory.getLogger(LGThinQFridgeApiV1ClientServiceImpl.class); + + protected LGThinQFridgeApiV1ClientServiceImpl(HttpClient httpClient) { + super(FridgeCapability.class, FridgeCanonicalSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Not implemented yet."); + } + + @Override + @Nullable + public FridgeCanonicalSnapshot getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) { + throw new UnsupportedOperationException("Method not supported in V1 API device."); + } + + @Override + public void setFridgeTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException { + if (snapCmdData != null) { + snapCmdData.put("TempRefrigerator", targetTemperatureIndex); + setControlCommand(bridgeId, deviceId, fridgeCapability, snapCmdData); + } else { + logger.warn("Snapshot Command Data is null"); + } + } + + @Override + public void setFreezerTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException { + if (snapCmdData != null) { + snapCmdData.put("TempFreezer", targetTemperatureIndex); + setControlCommand(bridgeId, deviceId, fridgeCapability, snapCmdData); + } else { + logger.warn("Snapshot command is null"); + } + } + + @Override + public void setExpressMode(String bridgeId, String deviceId, String expressModeIndex) { + throw new UnsupportedOperationException("V1 Fridge doesn't support ExpressMode feature. It mostly like a bug"); + } + + @Override + public void setExpressCoolMode(String bridgeId, String deviceId, boolean trueOnFalseOff) { + throw new UnsupportedOperationException( + "V1 Fridge doesn't support ExpressCoolMode feature. It mostly like a bug"); + } + + @Override + public void setEcoFriendlyMode(String bridgeId, String deviceId, boolean trueOnFalseOff) { + throw new UnsupportedOperationException( + "V1 Fridge doesn't support ExpressCoolMode feature. It mostly like a bug"); + } + + @Override + public void setIcePlus(String bridgeId, String deviceId, FridgeCapability fridgeCapability, boolean trueOnFalseOff, + Map snapCmdData) throws LGThinqApiException { + snapCmdData.put("IcePlus", trueOnFalseOff ? 1 : 0); + setControlCommand(bridgeId, deviceId, fridgeCapability, snapCmdData); + } + + private void setControlCommand(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + @Nullable Map snapCmdData) throws LGThinqApiException { + try { + CommandDefinition cmdSetControlDef = fridgeCapability.getCommandsDefinition() + .get(RE_SET_CONTROL_COMMAND_NAME_V1); + if (cmdSetControlDef == null) { + logger.warn("No command definition found for set control command. Ignoring command"); + return; + } + if (snapCmdData == null) { + logger.error("Snapshot to complete command was not send. It's mostly like a bug"); + return; + } + Map cmdPayload = prepareCommandV1(cmdSetControlDef, snapCmdData); + logger.debug("setControl Payload:[{}]", cmdPayload); + RestResult result = sendCommand(bridgeId, deviceId, cmdPayload); + handleGenericErrorResult(result); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending remote start", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV2ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV2ClientServiceImpl.java new file mode 100644 index 00000000000..b99fbb7b302 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQFridgeApiV2ClientServiceImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapability; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQFridgeApiV2ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQFridgeApiV2ClientServiceImpl + extends LGThinQAbstractApiV2ClientService + implements LGThinQFridgeApiClientService { + + protected LGThinQFridgeApiV2ClientServiceImpl(HttpClient httpClient) { + super(FridgeCapability.class, FridgeCanonicalSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Not implemented yet."); + } + + @Override + public void setFridgeTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException { + setTemperature("fridgeTemp", bridgeId, deviceId, targetTemperatureIndex, tempUnit); + } + + @Override + public void setFreezerTemperature(String bridgeId, String deviceId, FridgeCapability fridgeCapability, + Integer targetTemperatureIndex, String tempUnit, @Nullable Map snapCmdData) + throws LGThinqApiException { + setTemperature("freezerTemp", bridgeId, deviceId, targetTemperatureIndex, tempUnit); + } + + @Override + public void setExpressMode(String bridgeId, String deviceId, String expressMode) throws LGThinqApiException { + sendSimpleDataSetListCommand(bridgeId, deviceId, "expressMode", expressMode); + } + + private void sendSimpleDataSetListCommand(String bridgeId, String deviceId, String feature, String value) + throws LGThinqApiException { + ObjectNode dataSetList = JsonNodeFactory.instance.objectNode(); + ObjectNode nodeData = dataSetList.putObject("dataSetList").putObject("refState"); + nodeData.put(feature, value); + try { + RestResult result = sendCommand(bridgeId, deviceId, "control-sync", "basicCtrl", "Set", null, null, + dataSetList); + handleGenericErrorResult(result); + } catch (Exception e) { + throw new LGThinqApiException("Error sending command", e); + } + } + + @Override + public void setExpressCoolMode(String bridgeId, String deviceId, boolean trueOnFalseOff) + throws LGThinqApiException { + sendSimpleDataSetListCommand(bridgeId, deviceId, "expressFridge", trueOnFalseOff ? "ON" : "OFF"); + } + + @Override + public void setEcoFriendlyMode(String bridgeId, String deviceId, boolean trueOnFalseOff) + throws LGThinqApiException { + sendSimpleDataSetListCommand(bridgeId, deviceId, "ecoFriendly", trueOnFalseOff ? "ON" : "OFF"); + } + + @Override + public void setIcePlus(String bridgeId, String deviceId, FridgeCapability fridgeCapability, boolean trueOnFalseOff, + Map snapCmdData) { + throw new UnsupportedOperationException("V2 Fridge doesn't support IcePlus feature. It mostly like a bug"); + } + + private void setTemperature(String tempFeature, String bridgeId, String deviceId, Integer targetTemperature, + String tempUnit) throws LGThinqApiException { + ObjectNode dataSetList = JsonNodeFactory.instance.objectNode(); + ObjectNode nodeData = dataSetList.putObject("dataSetList").putObject("refState"); + nodeData.put(tempFeature, targetTemperature).put("tempUnit", tempUnit); + try { + RestResult result = sendCommand(bridgeId, deviceId, "control-sync", "basicCtrl", "Set", null, null, + dataSetList); + handleGenericErrorResult(result); + } catch (Exception e) { + throw new LGThinqApiException("Error sending command", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiClientService.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiClientService.java new file mode 100644 index 00000000000..01e5957f372 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiClientService.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshot; + +/** + * Represents an API client service for LG ThinQ Washer/Dryer devices. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface LGThinQWMApiClientService extends LGThinQApiClientService { + /** + * Start the LG ThinQ Washer/Dryer device remotely using the specified capability and data. + * + * @param bridgeName The name of the bridge connected to the device + * @param cap The WasherDryerCapability object representing the capabilities of the device + * @param deviceId The ID of the LG ThinQ device + * @param data A Map containing key-value pairs of data to be used for starting the device + * @throws LGThinqApiException if an error occurs while trying to start the device remotely + */ + void remoteStart(String bridgeName, WasherDryerCapability cap, String deviceId, Map data) + throws LGThinqApiException; + + /** + * Controls the wake-up feature of the LG ThinQ Washer/Dryer device. + * + * @param bridgeName The name of the bridge connected to the device + * @param deviceId The ID of the LG device + * @param wakeUp Boolean value indicating whether to wake up the device + * @throws LGThinqApiException if an error occurs while trying to wake up the device + */ + void wakeUp(String bridgeName, String deviceId, Boolean wakeUp) throws LGThinqApiException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV1ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV1ClientServiceImpl.java new file mode 100644 index 00000000000..28c6d1ef5af --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV1ClientServiceImpl.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; + +/** + * The {@link LGThinQWMApiV1ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQWMApiV1ClientServiceImpl + extends LGThinQAbstractApiV1ClientService + implements LGThinQWMApiClientService { + private final Logger logger = LoggerFactory.getLogger(LGThinQWMApiV1ClientServiceImpl.class); + + protected LGThinQWMApiV1ClientServiceImpl(HttpClient httpClient) { + super(WasherDryerCapability.class, WasherDryerSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + // there's no before settings to send command + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Not implemented yet."); + } + + @Override + @Nullable + public WasherDryerSnapshot getDeviceData(String bridgeName, String deviceId, CapabilityDefinition capDef) { + throw new UnsupportedOperationException("Method not supported in V1 API device."); + } + + @Override + public void remoteStart(String bridgeName, WasherDryerCapability cap, String deviceId, Map data) + throws LGThinqApiException { + try { + CommandDefinition cmdStartDef = cap.getCommandsDefinition().get(cap.getCommandRemoteStart()); + if (cmdStartDef == null) { + logger.warn("No command definition found for remote start v1. Ignoring command"); + return; + } + Map cmdPayload = prepareCommandV1(cmdStartDef, data); + logger.debug("token Payload:[{}]", cmdPayload); + RestResult result = sendCommand(bridgeName, deviceId, cmdPayload); + handleGenericErrorResult(result); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending remote start", e); + } + } + + @Override + public void wakeUp(String bridgeName, String deviceId, Boolean wakeUp) throws LGThinqApiException { + try { + RestResult result = sendCommand(bridgeName, deviceId, "", "Control", "Operation", "", "WakeUp"); + handleGenericErrorResult(result); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending remote start", e); + } + } + + @Override + protected Map prepareCommandV1(CommandDefinition cmdDef, Map snapData) + throws JsonProcessingException { + // expected map ordered here + String dataStr = cmdDef.getDataTemplate(); + for (Map.Entry e : snapData.entrySet()) { + String value = String.valueOf(e.getValue()); + if ("Start".equals(cmdDef.getCmdOptValue()) && e.getKey().equals("Option2")) { + // For some reason, option2 fills only InitialBit with 1. + value = "1"; + } + dataStr = dataStr.replace("{{" + e.getKey() + "}}", value); + } + // Keep the order + LinkedHashMap cmd = completeCommandDataNodeV1(cmdDef, dataStr); + cmd.remove("encode"); + + return cmd; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV2ClientServiceImpl.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV2ClientServiceImpl.java new file mode 100644 index 00000000000..719c5365d12 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/LGThinQWMApiV2ClientServiceImpl.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.WMD_COMMAND_REMOTE_START_V2; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.RestResult; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapability; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshot; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link LGThinQWMApiV2ClientServiceImpl} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinQWMApiV2ClientServiceImpl + extends LGThinQAbstractApiV2ClientService + implements LGThinQWMApiClientService { + + protected LGThinQWMApiV2ClientServiceImpl(HttpClient httpClient) { + super(WasherDryerCapability.class, WasherDryerSnapshot.class, httpClient); + } + + @Override + protected boolean beforeGetDataDevice(String bridgeName, String deviceId) { + return false; + } + + @Override + public void turnDevicePower(String bridgeName, String deviceId, DevicePowerState newPowerState) { + throw new UnsupportedOperationException("Not implemented yet."); + } + + @Override + public void remoteStart(String bridgeName, WasherDryerCapability cap, String deviceId, Map data) + throws LGThinqApiException { + try { + ObjectNode dataSetList = JsonNodeFactory.instance.objectNode(); + ObjectNode nodeData = dataSetList.putObject("dataSetList").putObject("washerDryer"); + // 1 - mount nodeData template + CommandDefinition cdStart = cap.getCommandsDefinition().get(WMD_COMMAND_REMOTE_START_V2); + if (cdStart == null) { + throw new LGThinqApiException( + "Command WMStart doesn't defined in cap. Do the Device support Remote Start ?"); + } + // remove data values (based on command template values) that it's not the real name + data.remove("course"); + data.remove("SmartCourse"); + for (Map.Entry value : data.entrySet()) { + Object v = value.getValue(); + if (v instanceof Double d) { + nodeData.put(value.getKey(), d); + } else if (v instanceof Integer i) { + nodeData.put(value.getKey(), i); + } else { + nodeData.put(value.getKey(), value.getValue().toString()); + } + } + + RestResult result = sendCommand(bridgeName, deviceId, "control-sync", WMD_COMMAND_REMOTE_START_V2, "Set", + null, null, dataSetList); + handleGenericErrorResult(result); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending remote start", e); + } + } + + @Override + public void wakeUp(String bridgeName, String deviceId, Boolean wakeUp) throws LGThinqApiException { + try { + ObjectNode dataSetList = JsonNodeFactory.instance.objectNode(); + dataSetList.putObject("dataSetList").putObject("washerDryer").put("controlDataType", "WAKEUP") + .put("controlDataValueLength", wakeUp ? "1" : "0"); + + RestResult result = sendCommand(bridgeName, deviceId, "control-sync", "WMWakeup", "Set", null, null, + dataSetList); + handleGenericErrorResult(result); + } catch (LGThinqApiException e) { + throw e; + } catch (Exception e) { + throw new LGThinqApiException("Error sending remote start", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqCanonicalModelUtil.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqCanonicalModelUtil.java new file mode 100644 index 00000000000..8f89cda7ce8 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqCanonicalModelUtil.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.api.model.GatewayResult; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link LGThinqCanonicalModelUtil} class - Utilities to help communication with LG API + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqCanonicalModelUtil { + public static ObjectMapper mapper = new ObjectMapper(); + + /** + * Get structured result from the LG Authentication Gateway + * + * @param rawJson RAW Json to process + * @return Structured Object returned from the API + * @throws IOException If some error happen procession token from file. + */ + public static GatewayResult getGatewayResult(String rawJson) throws IOException { + Map map = mapper.readValue(rawJson, new TypeReference<>() { + }); + @SuppressWarnings("unchecked") + Map content = (Map) map.get("result"); + String resultCode = (String) map.get("resultCode"); + if (content == null || content.isEmpty()) { + throw new IllegalArgumentException("Unexpected result. Gateway Content Result is null"); + } else if (resultCode == null) { + throw new IllegalArgumentException("Unexpected result. resultCode code is null"); + } + + return new GatewayResult(Objects.requireNonNull(resultCode, "Expected resultCode field in json"), "", + Objects.requireNonNull(content.get("rtiUri"), "Expected rtiUri field in json"), + Objects.requireNonNull(content.get("thinq1Uri"), "Expected thinq1Uri field in json"), + Objects.requireNonNull(content.get("thinq2Uri"), "Expected thinq2Uri field in json"), + Objects.requireNonNull(content.get("empUri"), "Expected empUri field in json"), + Objects.requireNonNull(content.get("empTermsUri"), "Expected empTermsUri field in json"), "", + Objects.requireNonNull(content.get("empSpxUri"), "Expected empSpxUri field in json")); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqGateway.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqGateway.java new file mode 100644 index 00000000000..d9ae680000c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqGateway.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.io.Serial; +import java.io.Serializable; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.api.model.GatewayResult; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * The {@link LGThinqGateway} hold information about the LG Gateway + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqGateway implements Serializable { + @Serial + private static final long serialVersionUID = 202409261421L; + private String empBaseUri = ""; + private String loginBaseUri = ""; + private String apiRootV1 = ""; + private String apiRootV2 = ""; + private String authBase = ""; + private String language = ""; + private String country = ""; + private String username = ""; + private String password = ""; + private String alternativeEmpServer = ""; + private int accountVersion; + + public LGThinqGateway() { + } + + public LGThinqGateway(GatewayResult gwResult, String language, String country, String alternativeEmpServer) { + this.apiRootV2 = gwResult.getThinq2Uri(); + this.apiRootV1 = gwResult.getThinq1Uri(); + this.loginBaseUri = gwResult.getEmpSpxUri(); + this.authBase = gwResult.getEmpUri(); + this.empBaseUri = gwResult.getEmpTermsUri(); + this.language = language; + this.country = country; + this.alternativeEmpServer = alternativeEmpServer; + } + + @JsonIgnore + public String getTokenSessionEmpUrl() { + return alternativeEmpServer.isBlank() ? LG_API_V2_EMP_SESS_URL : alternativeEmpServer + LG_API_V2_EMP_SESS_PATH; + } + + public String getEmpBaseUri() { + return empBaseUri; + } + + public void setEmpBaseUri(String empBaseUri) { + this.empBaseUri = empBaseUri; + } + + public int getAccountVersion() { + return accountVersion; + } + + public String getApiRootV2() { + return apiRootV2; + } + + public void setApiRootV2(String apiRootV2) { + this.apiRootV2 = apiRootV2; + } + + public String getAuthBase() { + return authBase; + } + + public void setAuthBase(String authBase) { + this.authBase = authBase; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getLoginBaseUri() { + return loginBaseUri; + } + + public void setLoginBaseUri(String loginBaseUri) { + this.loginBaseUri = loginBaseUri; + } + + public String getApiRootV1() { + return apiRootV1; + } + + public void setApiRootV1(String apiRootV1) { + this.apiRootV1 = apiRootV1; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public String toString() { + return "LGThinqGateway{" + "empBaseUri='" + empBaseUri + '\'' + ", loginBaseUri='" + loginBaseUri + '\'' + + ", apiRootV1='" + apiRootV1 + '\'' + ", apiRootV2='" + apiRootV2 + '\'' + ", authBase='" + authBase + + '\'' + ", language='" + language + '\'' + ", country='" + country + '\'' + ", username='" + username + + '\'' + ", password='" + (!password.isEmpty() ? "******" : "") + '\'' + + ", alternativeEmpServer='" + alternativeEmpServer + '\'' + ", accountVersion=" + accountVersion + '}'; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqOauthEmpAuthenticator.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqOauthEmpAuthenticator.java new file mode 100644 index 00000000000..e777abd5dfb --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/LGThinqOauthEmpAuthenticator.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TimeZone; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.api.model.GatewayResult; +import org.openhab.binding.lgthinq.lgservices.errors.RefreshTokenException; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link LGThinqOauthEmpAuthenticator} main service to authenticate against LG Emp Server via Oauth + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqOauthEmpAuthenticator { + + private static final Map OAUTH_SEARCH_KEY_QUERY_PARAMS = new LinkedHashMap<>(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static { + OAUTH_SEARCH_KEY_QUERY_PARAMS.put("key_name", "OAUTH_SECRETKEY"); + OAUTH_SEARCH_KEY_QUERY_PARAMS.put("sever_type", "OP"); + } + + private final Logger logger = LoggerFactory.getLogger(LGThinqOauthEmpAuthenticator.class); + private final HttpClient httpClient; + + public LGThinqOauthEmpAuthenticator(HttpClient httpClient) { + this.httpClient = httpClient; + } + + private Map getGatewayRestHeader(String language, String country) { + return Map.ofEntries(new AbstractMap.SimpleEntry("Accept", "application/json"), + new AbstractMap.SimpleEntry<>("x-api-key", LG_API_API_KEY_V2), + new AbstractMap.SimpleEntry<>("x-country-code", country), + new AbstractMap.SimpleEntry<>("x-client-id", LG_API_CLIENT_ID), + new AbstractMap.SimpleEntry<>("x-language-code", language), + new AbstractMap.SimpleEntry<>("x-message-id", LG_API_MESSAGE_ID), + new AbstractMap.SimpleEntry<>("x-service-code", LG_API_SVC_CODE), + new AbstractMap.SimpleEntry<>("x-service-phase", LG_API_SVC_PHASE), + new AbstractMap.SimpleEntry<>("x-thinq-app-level", LG_API_APP_LEVEL), + new AbstractMap.SimpleEntry<>("x-thinq-app-os", LG_API_APP_OS), + new AbstractMap.SimpleEntry<>("x-thinq-app-type", LG_API_APP_TYPE), + new AbstractMap.SimpleEntry<>("x-thinq-app-ver", LG_API_APP_VER)); + } + + private Map getLoginHeader(LGThinqGateway gw) { + Map headers = new HashMap<>(); + headers.put("Connection", "keep-alive"); + headers.put("X-Device-Language-Type", "IETF"); + headers.put("X-Application-Key", "6V1V8H2BN5P9ZQGOI5DAQ92YZBDO3EK9"); + headers.put("X-Client-App-Key", "LGAO221A02"); + headers.put("X-Lge-Svccode", "SVC709"); + headers.put("X-Device-Type", "M01"); + headers.put("X-Device-Platform", "ADR"); + headers.put("X-Device-Publish-Flag", "Y"); + headers.put("X-Device-Country", gw.getCountry()); + headers.put("X-Device-Language", gw.getLanguage()); + headers.put("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + headers.put("Access-Control-Allow-Origin", "*"); + headers.put("Accept-Encoding", "gzip, deflate, br"); + headers.put("Accept-Language", "en-US,en;q=0.9,pt-BR;q=0.8,pt;q=0.7"); + headers.put("Accept", "application/json"); + return headers; + } + + LGThinqGateway discoverGatewayConfiguration(String gwUrl, String language, String country, + String alternativeEmpServer) throws IOException { + Map header = getGatewayRestHeader(language, country); + RestResult result; + result = RestUtils.getCall(httpClient, gwUrl, header, null); + + if (result.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("Expected HTTP OK return, but received result core:[%s] - error message:[%s]", + result.getJsonResponse(), ResultCodes.getReasonResponse(result.getJsonResponse()))); + } else { + GatewayResult gwResult = LGThinqCanonicalModelUtil.getGatewayResult(result.getJsonResponse()); + ResultCodes resultCode = ResultCodes.fromCode(gwResult.getReturnedCode()); + if (ResultCodes.OK != resultCode) { + throw new IllegalStateException(String.format( + "Result from LGThinq Gateway from Authentication URL was unexpected. ResultCode: %s, with message:%s, Error Description:%s", + gwResult.getReturnedCode(), gwResult.getReturnedMessage(), resultCode.getDescription())); + } + + return new LGThinqGateway(gwResult, language, country, alternativeEmpServer); + } + } + + PreLoginResult preLoginUser(LGThinqGateway gw, String username, String password) throws IOException { + String encPwd = RestUtils.getPreLoginEncPwd(password); + Map headers = getLoginHeader(gw); + // 1) Doing preLogin -> getting the password key + String preLoginUrl = gw.getLoginBaseUri() + LG_API_PRE_LOGIN_PATH; + Map formData = Map.of("user_auth2", encPwd, "log_param", String.format( + "login request / user_id : %s / " + "third_party : null / svc_list : SVC202,SVC710 / 3rd_service : ", + username)); + RestResult resp = RestUtils.postCall(httpClient, preLoginUrl, headers, formData); + if (resp == null) { + throw new IllegalStateException("Error login into account. Null data returned"); + } else if (resp.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("Error preLogin into account: The reason is: %s", resp.getJsonResponse())); + } + + Map preLoginResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + logger.debug("encrypted_pw={}, signature={}, tStamp={}", preLoginResult.get("encrypted_pw"), + preLoginResult.get("signature"), preLoginResult.get("tStamp")); + return new PreLoginResult(username, + Objects.requireNonNull(preLoginResult.get("signature"), + "Unexpected login json result. Node 'signature' not found"), + Objects.requireNonNull(preLoginResult.get("tStamp"), + "Unexpected login json result. Node 'signature' not found"), + Objects.requireNonNull(preLoginResult.get("encrypted_pw"), + "Unexpected login json result. Node 'signature' not found")); + } + + LoginAccountResult loginUser(LGThinqGateway gw, PreLoginResult preLoginResult) throws IOException { + // 2 - Login with username and hashed password + Map headers = getLoginHeader(gw); + headers.put("X-Signature", preLoginResult.signature()); + headers.put("X-Timestamp", preLoginResult.timestamp()); + Map formData = Map.of("user_auth2", preLoginResult.encryptedPwd(), + "password_hash_prameter_flag", "Y", "svc_list", "SVC202,SVC710"); // SVC202=LG SmartHome, SVC710=EMP + // OAuth + String loginUrl = gw.getEmpBaseUri() + LG_API_V2_SESSION_LOGIN_PATH + + URLEncoder.encode(preLoginResult.username(), StandardCharsets.UTF_8); + RestResult resp = RestUtils.postCall(httpClient, loginUrl, headers, formData); + if (resp == null) { + throw new IllegalStateException("Error loggin into acccount. Null data returned"); + } else if (resp.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("Error login into account. The reason is: %s", resp.getJsonResponse())); + } + Map loginResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + @SuppressWarnings("unchecked") + Map accountResult = (Map) loginResult.get("account"); + if (accountResult == null) { + throw new IllegalStateException("Error getting account from Login"); + } + return new LoginAccountResult( + Objects.requireNonNull(accountResult.get("userIDType"), + "Unexpected account json result. 'userIDType' not found"), + Objects.requireNonNull(accountResult.get("userID"), + "Unexpected account json result. 'userID' not found"), + Objects.requireNonNull(accountResult.get("country"), + "Unexpected account json result. 'country' not found"), + Objects.requireNonNull(accountResult.get("loginSessionID"), + "Unexpected account json result. 'loginSessionID' not found")); + } + + private String getCurrentTimestamp() { + SimpleDateFormat sdf = new SimpleDateFormat(LG_API_DATE_FORMAT, Locale.US); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf.format(new Date()); + } + + TokenResult getToken(LGThinqGateway gw, LoginAccountResult accountResult) throws IOException { + // 3 - get secret key from emp signature + String empSearchKeyUrl = gw.getLoginBaseUri() + LG_API_OAUTH_SEARCH_KEY_PATH; + + RestResult resp = RestUtils.getCall(httpClient, empSearchKeyUrl, null, OAUTH_SEARCH_KEY_QUERY_PARAMS); + if (resp.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("Error loggin into acccount. The reason is:%s", resp.getJsonResponse())); + } + Map secretResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + String secretKey = Objects.requireNonNull(secretResult.get("returnData"), + "Unexpected json returned. Expected 'returnData' node here"); + logger.debug("Secret found:{}", secretResult.get("returnData")); + + // 4 - get OAuth Token Key from EMP API + Map empData = new LinkedHashMap<>(); + empData.put("account_type", accountResult.userIdType()); + empData.put("client_id", LG_API_CLIENT_ID); + empData.put("country_code", accountResult.country()); + empData.put("username", accountResult.userId()); + String timestamp = getCurrentTimestamp(); + + byte[] oauthSig = RestUtils.getTokenSignature(gw.getTokenSessionEmpUrl(), secretKey, empData, timestamp); + + Map oauthEmpHeaders = getOauthEmpHeaders(accountResult, timestamp, oauthSig); + logger.debug("===> Localized timestamp used: [{}]", timestamp); + logger.debug("===> signature created: [{}]", new String(oauthSig)); + resp = RestUtils.postCall(httpClient, gw.getTokenSessionEmpUrl(), oauthEmpHeaders, empData); + return handleTokenResult(resp); + } + + private Map getOauthEmpHeaders(LoginAccountResult accountResult, String timestamp, + byte[] oauthSig) { + Map oauthEmpHeaders = new LinkedHashMap<>(); + oauthEmpHeaders.put("lgemp-x-app-key", LG_API_OAUTH_CLIENT_KEY); + oauthEmpHeaders.put("lgemp-x-date", timestamp); + oauthEmpHeaders.put("lgemp-x-session-key", accountResult.loginSessionId()); + oauthEmpHeaders.put("lgemp-x-signature", new String(oauthSig)); + oauthEmpHeaders.put("Accept", "application/json"); + oauthEmpHeaders.put("X-Device-Type", "M01"); + oauthEmpHeaders.put("X-Device-Platform", "ADR"); + oauthEmpHeaders.put("Content-Type", "application/x-www-form-urlencoded"); + oauthEmpHeaders.put("Access-Control-Allow-Origin", "*"); + oauthEmpHeaders.put("Accept-Encoding", "gzip, deflate, br"); + oauthEmpHeaders.put("Accept-Language", "en-US,en;q=0.9"); + oauthEmpHeaders.put("User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44"); + return oauthEmpHeaders; + } + + UserInfo getUserInfo(TokenResult token) throws IOException { + UriBuilder builder = UriBuilder.fromUri(token.getOauthBackendUrl()).path(LG_API_V2_USER_INFO); + String oauthUrl = builder.build().toURL().toString(); + String timestamp = getCurrentTimestamp(); + byte[] oauthSig = RestUtils.getTokenSignature(oauthUrl, LG_API_OAUTH_SECRET_KEY, Collections.emptyMap(), + timestamp); + Map headers = Map.of("Accept", "application/json", "Authorization", + String.format("Bearer %s", token.getAccessToken()), "X-Lge-Svccode", LG_API_SVC_CODE, + "X-Application-Key", LG_API_APPLICATION_KEY, "lgemp-x-app-key", LG_API_CLIENT_ID, "X-Device-Type", + "M01", "X-Device-Platform", "ADR", "x-lge-oauth-date", timestamp, "x-lge-oauth-signature", + new String(oauthSig)); + RestResult resp = RestUtils.getCall(httpClient, oauthUrl, headers, null); + + return handleAccountInfoResult(resp); + } + + private UserInfo handleAccountInfoResult(RestResult resp) throws IOException { + Map result = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + if (resp.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("LG API returned error when trying to get user account information. The reason is:%s", + resp.getJsonResponse())); + } else if (result.get("account") == null + || ((Map) result.getOrDefault("account", Collections.emptyMap())).get("userNo") == null) { + throw new IllegalStateException("Error retrieving the account user information from access token"); + } + @SuppressWarnings("unchecked") + Map accountInfo = (Map) result.getOrDefault("account", Collections.emptyMap()); + + return new UserInfo( + Objects.requireNonNullElse(accountInfo.get("userNo"), + "Unexpected result. userID must be present in json result"), + Objects.requireNonNull(accountInfo.get("userID"), + "Unexpected result. userID must be present in json result"), + Objects.requireNonNull(accountInfo.get("userIDType"), + "Unexpected result. userIDType must be present in json result"), + Objects.requireNonNullElse(accountInfo.get("displayUserID"), "")); + } + + TokenResult doRefreshToken(TokenResult currentToken) throws IOException, RefreshTokenException { + UriBuilder builder = UriBuilder.fromUri(currentToken.getOauthBackendUrl()).path(LG_API_V2_AUTH_PATH); + String oauthUrl = builder.build().toURL().toString(); + String timestamp = getCurrentTimestamp(); + + Map formData = new LinkedHashMap<>(); + formData.put("grant_type", "refresh_token"); + formData.put("refresh_token", currentToken.getRefreshToken()); + + byte[] oauthSig = RestUtils.getTokenSignature(oauthUrl, LG_API_OAUTH_SECRET_KEY, formData, timestamp); + + Map headers = Map.of("x-lge-appkey", LG_API_CLIENT_ID, "x-lge-oauth-signature", + new String(oauthSig), "x-lge-oauth-date", timestamp, "Accept", "application/json"); + + RestResult resp = RestUtils.postCall(httpClient, oauthUrl, headers, formData); + return handleRefreshTokenResult(resp, currentToken); + } + + private TokenResult handleTokenResult(@Nullable RestResult resp) throws IOException { + Map tokenResult; + if (resp == null) { + throw new IllegalStateException("Error getting oauth token. Null data returned"); + } + if (resp.getStatusCode() != 200) { + throw new IllegalStateException( + String.format("Error getting oauth token. HTTP Status Code is:%s, The reason is:%s", + resp.getStatusCode(), resp.getJsonResponse())); + } else { + tokenResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + Integer status = (Integer) tokenResult.get("status"); + if ((status != null && !"1".equals("" + status)) || tokenResult.get("expires_in") == null) { + throw new IllegalStateException(String.format("Status error getting token:%s", tokenResult)); + } + } + + return new TokenResult( + Objects.requireNonNull((String) tokenResult.get("access_token"), + "Unexpected result. access_token must be present in json result"), + Objects.requireNonNull((String) tokenResult.get("refresh_token"), + "Unexpected result. refresh_token must be present in json result"), + Integer.parseInt(Objects.requireNonNull((String) tokenResult.get("expires_in"), + "Unexpected result. expires_in must be present in json result")), + new Date(), Objects.requireNonNull((String) tokenResult.get("oauth2_backend_url"), + "Unexpected result. oauth2_backend_url must be present in json result")); + } + + private TokenResult handleRefreshTokenResult(@Nullable RestResult resp, TokenResult currentToken) + throws IOException, RefreshTokenException { + Map tokenResult; + if (resp == null) { + throw new RefreshTokenException("Error getting oauth token. Null data returned"); + } + if (resp.getStatusCode() != 200) { + throw new RefreshTokenException( + String.format("Error getting oauth token. HTTP Status Code is:%s, The reason is:%s", + resp.getStatusCode(), resp.getJsonResponse())); + } else { + tokenResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() { + }); + if (tokenResult.get("access_token") == null || tokenResult.get("expires_in") == null) { + throw new RefreshTokenException(String.format("Status error get refresh token info:%s", tokenResult)); + } + } + + currentToken.setAccessToken(Objects.requireNonNull(tokenResult.get("access_token"), + "Unexpected error. Access Token must ever been provided by LG API")); + currentToken.setGeneratedTime(new Date()); + currentToken.setExpiresIn(Integer.parseInt(Objects.requireNonNull(tokenResult.get("expires_in"), + "Unexpected error. Access Token must ever been provided by LG API"))); + return currentToken; + } + + record PreLoginResult(String username, String signature, String timestamp, String encryptedPwd) { + } + + record LoginAccountResult(String userIdType, String userId, String country, String loginSessionId) { + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestResult.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestResult.java new file mode 100644 index 00000000000..72d051746cf --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestResult.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link RestResult} result from rest calls + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class RestResult { + private final String jsonResponse; + private final int resultCode; + + public RestResult(String jsonResponse, int resultCode) { + this.jsonResponse = jsonResponse; + this.resultCode = resultCode; + } + + public String getJsonResponse() { + return jsonResponse; + } + + public int getStatusCode() { + return resultCode; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestUtils.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestUtils.java new file mode 100644 index 00000000000..dd8bb910477 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/RestUtils.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentProvider; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.FormContentProvider; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.util.Fields; +import org.openhab.core.i18n.CommunicationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link RestUtils} rest utilities + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class RestUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(RestUtils.class); + + public static String getPreLoginEncPwd(String pwdToEnc) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + LOGGER.warn("The required algorithm is not available.", e); + throw new IllegalStateException("Unexpected error. SHA-512 algorithm must exists in JDK distribution", e); + } + digest.reset(); + digest.update(pwdToEnc.getBytes(StandardCharsets.UTF_8)); + + return String.format("%0128x", new BigInteger(1, digest.digest())); + } + + public static byte[] getOauth2Sig(String messageSign, String secret) { + byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8); + SecretKeySpec signingKey = new SecretKeySpec(secretBytes, HMAC_SHA1_ALGORITHM); + + try { + Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM); + mac.init(signingKey); + return Base64.getEncoder().encode(mac.doFinal(messageSign.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + LOGGER.debug("Unexpected error. SHA1 algorithm must exists in JDK distribution.", e); + throw new IllegalStateException("Unexpected error. SHA1 algorithm must exists in JDK distribution", e); + } catch (InvalidKeyException e) { + LOGGER.debug("Unexpected error.", e); + throw new IllegalStateException("Unexpected error.", e); + } + } + + public static byte[] getTokenSignature(String authUrl, String secretKey, Map empData, + String timestamp) { + UriBuilder builder = UriBuilder.fromUri(authUrl); + empData.forEach(builder::queryParam); + + URI reqUri = builder.build(); + String signUrl = !empData.isEmpty() ? reqUri.getPath() + "?" + reqUri.getRawQuery() : reqUri.getPath(); + String messageToSign = String.format("%s\n%s", signUrl, timestamp); + return getOauth2Sig(messageToSign, secretKey); + } + + public static RestResult getCall(HttpClient httpClient, String encodedUrl, @Nullable Map headers, + @Nullable Map params) { + Request request = httpClient.newRequest(encodedUrl).method("GET"); + if (params != null) { + params.forEach(request::param); + } + if (headers != null) { + headers.forEach(request::header); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("GET request: {}", request.getURI()); + } + try { + ContentResponse response = request.send(); + + LOGGER.trace("GET response: {}", response.getContentAsString()); + + return new RestResult(response.getContentAsString(), response.getStatus()); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + LOGGER.debug("Exception occurred during GET execution: {}", e.getMessage(), e); + throw new CommunicationException(e); + } + } + + @Nullable + public static RestResult postCall(HttpClient httpClient, String encodedUrl, Map headers, + String jsonData) { + return postCall(httpClient, encodedUrl, headers, new StringContentProvider(jsonData)); + } + + @Nullable + public static RestResult postCall(HttpClient httpClient, String encodedUrl, Map headers, + Map formParams) { + Fields fields = new Fields(); + formParams.forEach(fields::put); + return postCall(httpClient, encodedUrl, headers, new FormContentProvider(fields)); + } + + @Nullable + private static RestResult postCall(HttpClient httpClient, String encodedUrl, Map headers, + ContentProvider contentProvider) { + try { + Request request = httpClient.newRequest(encodedUrl).method("POST").content(contentProvider).timeout(10, + TimeUnit.SECONDS); + headers.forEach(request::header); + LOGGER.trace("POST request to URI: {}", request.getURI()); + + ContentResponse response = request.content(contentProvider).timeout(10, TimeUnit.SECONDS).send(); + + LOGGER.trace("POST response: {}", response.getContentAsString()); + + return new RestResult(response.getContentAsString(), response.getStatus()); + } catch (TimeoutException e) { + LOGGER.warn("Timeout reading post call result from LG API", e); // In SocketTimeout cases I'm considering + // that I have no response on time. Then, I + // return null data + // forcing caller to retry. + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CommunicationException(e); + } catch (ExecutionException e) { + throw new CommunicationException(e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenManager.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenManager.java new file mode 100644 index 00000000000..c221388f5b6 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenManager.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.io.File; +import java.io.IOException; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.lgthinq.lgservices.errors.AccountLoginException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqGatewayException; +import org.openhab.binding.lgthinq.lgservices.errors.PreLoginException; +import org.openhab.binding.lgthinq.lgservices.errors.RefreshTokenException; +import org.openhab.binding.lgthinq.lgservices.errors.TokenException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link TokenManager} Principal facade to manage all token handles + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class TokenManager { + private static final int EXPIRICY_TOLERANCE_SEC = 60; + private final Logger logger = LoggerFactory.getLogger(TokenManager.class); + private final LGThinqOauthEmpAuthenticator authenticator; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map tokenCached = new ConcurrentHashMap<>(); + + public TokenManager(HttpClient httpClient) { + authenticator = new LGThinqOauthEmpAuthenticator(httpClient); + } + + public boolean isTokenExpired(TokenResult token) { + Calendar c = Calendar.getInstance(); + c.setTime(token.getGeneratedTime()); + c.add(Calendar.SECOND, token.getExpiresIn() - EXPIRICY_TOLERANCE_SEC); + Date expiricyDate = c.getTime(); + return expiricyDate.before(new Date()); + } + + public TokenResult refreshToken(String bridgeName, TokenResult currentToken) throws RefreshTokenException { + try { + TokenResult token = authenticator.doRefreshToken(currentToken); + objectMapper.writeValue(new File(getConfigDataFileName(bridgeName)), token); + return token; + } catch (IOException e) { + throw new RefreshTokenException("Error refreshing LGThinq token", e); + } + } + + private String getConfigDataFileName(String bridgeName) { + return String.format(getThinqConnectionDataFile(), bridgeName); + } + + public boolean isOauthTokenRegistered(String bridgeName) { + File tokenFile = new File(getConfigDataFileName(bridgeName)); + return tokenFile.isFile(); + } + + private String getGatewayUrl(String alternativeGtwServer) { + return alternativeGtwServer.isBlank() ? LG_API_GATEWAY_URL_V2 + : (alternativeGtwServer + LG_API_GATEWAY_SERVICE_PATH_V2); + } + + public void oauthFirstRegistration(String bridgeName, String language, String country, String username, + String password, String alternativeGtwServer) + throws LGThinqGatewayException, PreLoginException, AccountLoginException, TokenException, IOException { + LGThinqGateway gw; + LGThinqOauthEmpAuthenticator.PreLoginResult preLogin; + LGThinqOauthEmpAuthenticator.LoginAccountResult accountLogin; + TokenResult token; + UserInfo userInfo; + try { + gw = authenticator.discoverGatewayConfiguration(getGatewayUrl(alternativeGtwServer), language, country, + alternativeGtwServer); + } catch (Exception ex) { + throw new LGThinqGatewayException("Error trying to discover the LG Gateway Setting for the region informed", + ex); + } + + try { + preLogin = authenticator.preLoginUser(gw, username, password); + } catch (Exception ex) { + throw new PreLoginException("Error doing pre-login of the user in the Emp LG Server", ex); + } + try { + accountLogin = authenticator.loginUser(gw, preLogin); + } catch (Exception ex) { + throw new AccountLoginException("Error doing user's account login on the Emp LG Server", ex); + } + try { + token = authenticator.getToken(gw, accountLogin); + } catch (Exception ex) { + throw new TokenException("Error getting Token", ex); + } + try { + userInfo = authenticator.getUserInfo(token); + token.setUserInfo(userInfo); + token.setGatewayInfo(gw); + } catch (Exception ex) { + throw new TokenException("Error getting UserInfo from Token", ex); + } + + // persist the token information generated in file + objectMapper.writeValue(new File(getConfigDataFileName(bridgeName)), token); + } + + public TokenResult getValidRegisteredToken(String bridgeName) throws IOException, RefreshTokenException { + TokenResult validToken; + TokenResult bridgeToken = tokenCached.get(bridgeName); + if (bridgeToken == null) { + bridgeToken = Objects.requireNonNull( + objectMapper.readValue(new File(getConfigDataFileName(bridgeName)), TokenResult.class), + "Unexpected. Never null here"); + } + + if (!isValidToken(bridgeToken)) { + throw new RefreshTokenException( + "Token is not valid. Try to delete token file and disable/enable bridge to restart authentication process"); + } else { + tokenCached.put(bridgeName, bridgeToken); + } + + validToken = Objects.requireNonNull(bridgeToken, "Unexpected. Never null here"); + if (isTokenExpired(validToken)) { + validToken = refreshToken(bridgeName, validToken); + } + return validToken; + } + + private boolean isValidToken(@Nullable TokenResult token) { + return token != null && !token.getAccessToken().isBlank() && token.getExpiresIn() != 0 + && !token.getOauthBackendUrl().isBlank() && !token.getRefreshToken().isBlank(); + } + + /** + * Remove the toke file registered for the bridge. Must be called only if the bridge is removed + */ + public void cleanupTokenRegistry(String bridgeName) { + File f = new File(getConfigDataFileName(bridgeName)); + if (f.isFile()) { + if (!f.delete()) { + logger.warn("Can't delete token registry file {}", f.getName()); + } + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenResult.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenResult.java new file mode 100644 index 00000000000..502233abdbf --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/TokenResult.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link TokenResult} Hold information about token and related entities + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class TokenResult implements Serializable { + @Serial + private static final long serialVersionUID = 202409261447L; + private String accessToken = ""; + private String refreshToken = ""; + private int expiresIn; + private Date generatedTime = new Date(); + private String oauthBackendUrl = ""; + private UserInfo userInfo = new UserInfo(); + private LGThinqGateway gatewayInfo = new LGThinqGateway(); + + public TokenResult(String accessToken, String refreshToken, int expiresIn, Date generatedTime, + String ouathBackendUrl) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.generatedTime = generatedTime; + this.oauthBackendUrl = ouathBackendUrl; + } + + // This constructor will never be called by this. It only exists because of ObjectMapper instantiation needs + public TokenResult() { + } + + public LGThinqGateway getGatewayInfo() { + return gatewayInfo; + } + + public void setGatewayInfo(LGThinqGateway gatewayInfo) { + this.gatewayInfo = gatewayInfo; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public Date getGeneratedTime() { + return generatedTime; + } + + public void setGeneratedTime(Date generatedTime) { + this.generatedTime = generatedTime; + } + + public String getOauthBackendUrl() { + return oauthBackendUrl; + } + + public void setOauthBackendUrl(String ouathBackendUrl) { + this.oauthBackendUrl = ouathBackendUrl; + } + + public UserInfo getUserInfo() { + return userInfo; + } + + public void setUserInfo(UserInfo userInfo) { + this.userInfo = userInfo; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/UserInfo.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/UserInfo.java new file mode 100644 index 00000000000..abf8d47c25c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/UserInfo.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api; + +import java.io.Serial; +import java.io.Serializable; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link UserInfo} User Info (registered in LG Account) + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class UserInfo implements Serializable { + @Serial + private static final long serialVersionUID = 202409261445L; + private String userNumber = ""; + private String userID = ""; + private String userIDType = ""; + private String displayUserID = ""; + + public UserInfo() { + } + + public UserInfo(String userNumber, String userID, String userIDType, String displayUserId) { + this.userNumber = userNumber; + this.userID = userID; + this.userIDType = userIDType; + this.displayUserID = displayUserId; + } + + public String getUserNumber() { + return userNumber; + } + + public void setUserNumber(String userNumber) { + this.userNumber = userNumber; + } + + public String getUserID() { + return userID; + } + + public void setUserID(String userID) { + this.userID = userID; + } + + public String getUserIDType() { + return userIDType; + } + + public void setUserIDType(String userIDType) { + this.userIDType = userIDType; + } + + public String getDisplayUserID() { + return displayUserID; + } + + public void setDisplayUserID(String displayUserID) { + this.displayUserID = displayUserID; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/GatewayResult.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/GatewayResult.java new file mode 100644 index 00000000000..a665bdebb6c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/GatewayResult.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link GatewayResult} class + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class GatewayResult extends HeaderResult { + private final String rtiUri; + private final String thinq1Uri; + private final String thinq2Uri; + private final String empUri; + private final String empTermsUri; + private final String oauthUri; + private final String empSpxUri; + + public GatewayResult(String resultCode, String resultMessage, String rtiUri, String thinq1Uri, String thinq2Uri, + String empUri, String empTermsUri, String oauthUri, String empSpxUri) { + super(resultCode, resultMessage); + this.rtiUri = rtiUri; + this.thinq1Uri = thinq1Uri; + this.thinq2Uri = thinq2Uri; + this.empUri = empUri; + this.empTermsUri = empTermsUri; + this.oauthUri = oauthUri; + this.empSpxUri = empSpxUri; + } + + public String getRtiUri() { + return rtiUri; + } + + public String getEmpTermsUri() { + return empTermsUri; + } + + public String getEmpSpxUri() { + return empSpxUri; + } + + public String getThinq1Uri() { + return thinq1Uri; + } + + public String getThinq2Uri() { + return thinq2Uri; + } + + public String getEmpUri() { + return empUri; + } + + public String getOauthUri() { + return oauthUri; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/HeaderResult.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/HeaderResult.java new file mode 100644 index 00000000000..764c5bede0c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/api/model/HeaderResult.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.api.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link HeaderResult} class + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class HeaderResult { + private final String returnedCode; + private final String returnedMessage; + + public HeaderResult(String returnedCode, String returnedMessage) { + this.returnedCode = returnedCode; + this.returnedMessage = returnedMessage; + } + + public String getReturnedCode() { + return returnedCode; + } + + public String getReturnedMessage() { + return returnedMessage; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/AccountLoginException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/AccountLoginException.java new file mode 100644 index 00000000000..7f17b313de7 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/AccountLoginException.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AccountLoginException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class AccountLoginException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public AccountLoginException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqAccessException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqAccessException.java new file mode 100644 index 00000000000..1dbb3502c4a --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqAccessException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; + +/** + * The LGThinqAccessException exception class that occurs when the LG API deny access to some service. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqAccessException extends LGThinqApiException { + @Serial + private static final long serialVersionUID = 1L; + + public LGThinqAccessException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqAccessException(String message, Throwable cause, ResultCodes reasonCode) { + super(message, cause, reasonCode); + } + + public LGThinqAccessException(String message) { + super(message); + } + + public LGThinqAccessException(String message, ResultCodes resultCode) { + super(message, resultCode); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiException.java new file mode 100644 index 00000000000..d3a97e45423 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiException.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.ResultCodes; + +/** + * The {@link LGThinqApiException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqApiException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261451L; + protected ResultCodes apiReasonCode = ResultCodes.UNKNOWN; + + public LGThinqApiException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqApiException(String message, Throwable cause, ResultCodes reasonCode) { + super(message, cause); + this.apiReasonCode = reasonCode; + } + + public LGThinqApiException(String message) { + super(message); + } + + public LGThinqApiException(String message, ResultCodes resultCode) { + super(message); + this.apiReasonCode = resultCode; + } + + public ResultCodes getApiReasonCode() { + return apiReasonCode; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiExhaustionException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiExhaustionException.java new file mode 100644 index 00000000000..112e781e2d2 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqApiExhaustionException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqApiExhaustionException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqApiExhaustionException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261451L; + + public LGThinqApiExhaustionException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqApiExhaustionException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1MonitorExpiredException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1MonitorExpiredException.java new file mode 100644 index 00000000000..ddc8eb4523b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1MonitorExpiredException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqDeviceV1MonitorExpiredException} - Normally caught by V1 API in monitoring device. + * After long-running moniotor, it indicates the need to refresh the monitor. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqDeviceV1MonitorExpiredException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public LGThinqDeviceV1MonitorExpiredException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqDeviceV1MonitorExpiredException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1OfflineException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1OfflineException.java new file mode 100644 index 00000000000..31ea6381c5b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqDeviceV1OfflineException.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqDeviceV1OfflineException} - Normally caught by V1 API in monitoring device. + * When the device is OFFLINE (away from internet), the API doesn't return data information and this + * exception is thrown to indicate that this device is offline for monitoring + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqDeviceV1OfflineException extends LGThinqApiException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public LGThinqDeviceV1OfflineException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqDeviceV1OfflineException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqException.java new file mode 100644 index 00000000000..f6bf927a0a4 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqException} Parent Exception for all exceptions of this module + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqException extends Exception { + @Serial + private static final long serialVersionUID = 202409261450L; + + public LGThinqException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqGatewayException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqGatewayException.java new file mode 100644 index 00000000000..bdd10f1eaab --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqGatewayException.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqGatewayException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqGatewayException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public LGThinqGatewayException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqUnmarshallException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqUnmarshallException.java new file mode 100644 index 00000000000..a4c5e283692 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/LGThinqUnmarshallException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGThinqUnmarshallException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class LGThinqUnmarshallException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public LGThinqUnmarshallException(String message, Throwable cause) { + super(message, cause); + } + + public LGThinqUnmarshallException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/PreLoginException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/PreLoginException.java new file mode 100644 index 00000000000..924f46461ab --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/PreLoginException.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link PreLoginException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class PreLoginException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public PreLoginException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/RefreshTokenException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/RefreshTokenException.java new file mode 100644 index 00000000000..84c1be68219 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/RefreshTokenException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link PreLoginException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class RefreshTokenException extends LGThinqApiException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public RefreshTokenException(String message, Throwable cause) { + super(message, cause); + } + + public RefreshTokenException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/TokenException.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/TokenException.java new file mode 100644 index 00000000000..7632b70b4d4 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/errors/TokenException.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.errors; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link PreLoginException} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class TokenException extends LGThinqException { + @Serial + private static final long serialVersionUID = 202409261450L; + + public TokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapability.java new file mode 100644 index 00000000000..cd95ebd8bbc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapability.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import static org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition.NULL_DEFINITION; + +import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * The {@link AbstractCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@SuppressWarnings("unchecked") +public abstract class AbstractCapability implements CapabilityDefinition { + final Class realClass; + // default result format + protected Map> featureDefinitionMap = new HashMap<>(); + protected String modelName = ""; + protected DeviceTypes deviceType = DeviceTypes.UNKNOWN; + protected LGAPIVerion version = LGAPIVerion.UNDEF; + // Define if the device supports sending setup commands before monitoring + // This is to control result 400 for some devices that doesn't support or permit setup commands before monitoring + boolean isBeforeCommandSupporter = true; + private MonitoringResultFormat monitoringDataFormat = MonitoringResultFormat.UNKNOWN_FORMAT; + private List monitoringBinaryProtocol = new ArrayList<>(); + private Map rawData = new HashMap<>(); + + protected AbstractCapability() { + this.realClass = (Class) ((ParameterizedType) Objects.requireNonNull(getClass().getGenericSuperclass())) + .getActualTypeArguments()[0]; + } + + public boolean isBeforeCommandSupported() { + return isBeforeCommandSupporter; + } + + public void setBeforeCommandSupported(boolean beforeCommandSupporter) { + isBeforeCommandSupporter = beforeCommandSupporter; + } + + @Override + public String getModelName() { + return modelName; + } + + @Override + public void setModelName(String modelName) { + this.modelName = modelName; + } + + @Override + public MonitoringResultFormat getMonitoringDataFormat() { + return monitoringDataFormat; + } + + @Override + public void setMonitoringDataFormat(MonitoringResultFormat monitoringDataFormat) { + this.monitoringDataFormat = monitoringDataFormat; + } + + public void setFeatureDefinitionMap(Map> featureDefinitionMap) { + this.featureDefinitionMap = featureDefinitionMap; + } + + @Override + public List getMonitoringBinaryProtocol() { + return monitoringBinaryProtocol; + } + + @Override + public void setMonitoringBinaryProtocol(List monitoringBinaryProtocol) { + this.monitoringBinaryProtocol = monitoringBinaryProtocol; + } + + @Override + public DeviceTypes getDeviceType() { + return deviceType; + } + + @Override + public void setDeviceType(DeviceTypes deviceType) { + this.deviceType = deviceType; + } + + @Override + public LGAPIVerion getDeviceVersion() { + return version; + } + + @Override + public void setDeviceVersion(LGAPIVerion version) { + this.version = version; + } + + @JsonIgnore + public Map getRawData() { + return rawData; + } + + public void setRawData(Map rawData) { + this.rawData = rawData; + } + + public Map> getFeatureValuesRawData() { + switch (getDeviceVersion()) { + case V1_0: + return Objects.requireNonNullElse((Map>) getRawData().get("Value"), + Collections.emptyMap()); + case V2_0: + return Objects.requireNonNullElse( + (Map>) getRawData().get("MonitoringValue"), Collections.emptyMap()); + default: + throw new IllegalStateException("Invalid version 'UNDEF' to get capability feature monitoring values"); + } + } + + @Override + public FeatureDefinition getFeatureDefinition(String featureName) { + Function f = featureDefinitionMap.get(featureName); + return f != null ? f.apply(realClass.cast(this)) : NULL_DEFINITION; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapabilityFactory.java new file mode 100644 index 00000000000..31b592d552e --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractCapabilityFactory.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; + +/** + * The {@link AbstractCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractCapabilityFactory { + protected final ObjectMapper mapper = new ObjectMapper(); + private final Logger logger = LoggerFactory.getLogger(AbstractCapabilityFactory.class); + + public T create(JsonNode rootNode) throws LGThinqException { + T cap = getCapabilityInstance(); + cap.setModelName(rootNode.path("Info").path("modelName").textValue()); + cap.setDeviceType(ModelUtils.getDeviceType(rootNode)); + cap.setDeviceVersion(ModelUtils.discoveryAPIVersion(rootNode)); + cap.setRawData(mapper.convertValue(rootNode, new TypeReference<>() { + })); + switch (cap.getDeviceVersion()) { + case V1_0: + // V1 has Monitoring node describing the protocol data format + JsonNode type = rootNode.path(getMonitoringNodeName()).path("type"); + if (!type.isMissingNode() && type.isTextual()) { + cap.setMonitoringDataFormat(MonitoringResultFormat.getFormatOf(type.textValue())); + } + break; + case V2_0: + // V2 doesn't have node describing the protocol because it's they unified Value (features) and + // Monitoring nodes in the MonitoringValue node + cap.setMonitoringDataFormat(MonitoringResultFormat.JSON_FORMAT); + break; + default: + cap.setMonitoringDataFormat(MonitoringResultFormat.UNKNOWN_FORMAT); + } + if (MonitoringResultFormat.BINARY_FORMAT.equals(cap.getMonitoringDataFormat())) { + // get MonitorProtocol + JsonNode protocol = rootNode.path(getMonitoringNodeName()).path("protocol"); + if (protocol.isArray()) { + ArrayNode pNode = (ArrayNode) protocol; + List protocols = mapper.convertValue(pNode, new TypeReference<>() { + }); + cap.setMonitoringBinaryProtocol(protocols); + } else { + if (protocol.isMissingNode()) { + logger.warn("protocol node is missing in the capability descriptor for a binary monitoring"); + } else { + logger.warn("protocol node is not and array in the capability descriptor for a binary monitoring "); + } + } + } + return cap; + } + + /** + * Return constant pointing to MonitoringNode. This node has information about monitoring response description, + * only present in V1 devices. If some device has different node name for this descriptor, please override + * it. + * + * @return Monitoring node name + */ + protected String getMonitoringNodeName() { + return "Monitoring"; + } + + protected abstract List getSupportedDeviceTypes(); + + protected abstract List getSupportedAPIVersions(); + + /** + * Return the feature definition, i.e, the definition of the device attributes that can be mapped to Channels. + * The targetChannelId is needed if you intend to get the destination channelId for that feature, typically for + * dynamic channels. + * + * @param featureName Name of the features: feature node name + * @param featuresNode The jsonNode containing the data definition of the feature + * @param targetChannelId The destination channelID, normally used when you want to create dynamic channels (outside + * xml) + * @param refChannelId + * @return the Feature definition. + */ + protected abstract FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId); + + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode) { + return newFeatureDefinition(featureName, featuresNode, null, null); + } + + protected abstract T getCapabilityInstance(); + + protected abstract Map getCommandsDefinition(JsonNode rootNode); + + /** + * General method to parse commands for average of V1 Thinq Devices. + * + * @param rootNode ControlWifi root node + * @return return map with commands definition + */ + protected Map getCommandsDefinitionV1(JsonNode rootNode) { + boolean isBinaryCommands = MonitoringResultFormat.BINARY_FORMAT.getFormat() + .equals(rootNode.path("ControlWifi").path("type").textValue()); + JsonNode commandNode = rootNode.path("ControlWifi").path("action"); + if (commandNode.isMissingNode()) { + logger.warn("No commands found in the devices's definition. This is most likely a bug."); + return Collections.emptyMap(); + } + Map commands = new HashMap<>(); + for (Iterator> it = commandNode.fields(); it.hasNext();) { + Map.Entry e = it.next(); + String commandName = e.getKey(); + CommandDefinition cd = new CommandDefinition(); + JsonNode thisCommandNode = e.getValue(); + JsonNode cmdField = thisCommandNode.path("cmd"); + if (cmdField.isMissingNode()) { + // command not supported + continue; + } + cd.setCommand(cmdField.textValue()); + // cd.setCmdOpt(thisCommandNode.path("cmdOpt").textValue()); + cd.setCmdOptValue(thisCommandNode.path("value").textValue()); + cd.setBinary(isBinaryCommands); + String strData = Objects.requireNonNullElse(thisCommandNode.path("data").textValue(), ""); + cd.setDataTemplate(strData); + cd.setRawCommand(thisCommandNode.toPrettyString()); + int reservedIndex = 0; + // keep the order + if (!strData.isEmpty()) { + Map data = new LinkedHashMap<>(); + for (String f : strData.split(",")) { + if (f.contains("{")) { + // it's a featured field + // create data entry with the key and blank value + data.put(f.replaceAll("[{\\[}\\]]", ""), ""); + } else { + // its a fixed reserved value + data.put("Reserved" + reservedIndex, f.replaceAll("[{\\[}\\]]", "")); + reservedIndex++; + } + } + cd.setData(data); + } + commands.put(commandName, cd); + } + return commands; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractSnapshotDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractSnapshotDefinition.java new file mode 100644 index 00000000000..471caa7ffc8 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/AbstractSnapshotDefinition.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * The {@link AbstractSnapshotDefinition} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractSnapshotDefinition implements SnapshotDefinition { + + protected final Map otherInfo = new HashMap<>(); + private Map rawData = new HashMap<>(); + + @JsonAnySetter + public void addOtherInfo(String propertyKey, Object value) { + this.otherInfo.put(propertyKey, value); + } + + @Nullable + public Object getOtherInfo(String propertyKey) { + return this.otherInfo.get(propertyKey); + } + + @JsonIgnore + public Map getRawData() { + return rawData; + } + + public void setRawData(Map rawData) { + this.rawData = rawData; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityDefinition.java new file mode 100644 index 00000000000..70597e5a256 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityDefinition.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Represents the definition of capabilities for an LG ThinQ device. + *

+ * This interface provides methods to retrieve and configure various + * device capabilities, including monitoring formats, protocols, and feature definitions. + * It serves as a contract for handling different LG ThinQ device capabilities. + *

+ * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface CapabilityDefinition { + + /** + * Checks if the device supports the "before command" capability. + * + * @return {@code true} if supported, {@code false} otherwise. + */ + boolean isBeforeCommandSupported(); + + /** + * Sets whether the device supports the "before command" capability. + * + * @param supports {@code true} to enable support, {@code false} to disable. + */ + void setBeforeCommandSupported(boolean supports); + + /** + * Retrieves the model name of the device. + * + * @return The model name as a {@link String}. + */ + String getModelName(); + + /** + * Sets the model name of the device. + * + * @param modelName The model name to set. + */ + void setModelName(String modelName); + + /** + * Retrieves the format of monitoring data for the device. + * + * @return A {@link MonitoringResultFormat} representing the format. + */ + MonitoringResultFormat getMonitoringDataFormat(); + + /** + * Sets the format of monitoring data for the device. + * + * @param monitoringDataFormat The monitoring data format to set. + */ + void setMonitoringDataFormat(MonitoringResultFormat monitoringDataFormat); + + /** + * Retrieves the list of monitoring binary protocols supported by the device. + * + * @return A {@link List} of {@link MonitoringBinaryProtocol} objects. + */ + List getMonitoringBinaryProtocol(); + + /** + * Sets the list of monitoring binary protocols supported by the device. + * + * @param monitoringBinaryProtocol The list of protocols to set. + */ + void setMonitoringBinaryProtocol(List monitoringBinaryProtocol); + + /** + * Retrieves the device type. + * + * @return The {@link DeviceTypes} representing the device type. + */ + DeviceTypes getDeviceType(); + + /** + * Sets the device type. + * + * @param deviceType The {@link DeviceTypes} to set. + */ + void setDeviceType(DeviceTypes deviceType); + + /** + * Retrieves the LG API version associated with the device. + * + * @return The {@link LGAPIVerion} of the device. + */ + LGAPIVerion getDeviceVersion(); + + /** + * Sets the LG API version associated with the device. + * + * @param version The {@link LGAPIVerion} to set. + */ + void setDeviceVersion(LGAPIVerion version); + + /** + * Retrieves the raw data associated with the device. + * + * @return A {@link Map} containing raw data values. + */ + Map getRawData(); + + /** + * Sets the raw data for the device. + * + * @param rawData A {@link Map} containing raw data values. + */ + void setRawData(Map rawData); + + /** + * Retrieves raw data values for each feature of the device. + * + * @return A {@link Map} where each key is a feature name and + * the value is another map of raw feature data. + */ + Map> getFeatureValuesRawData(); + + /** + * Retrieves the feature definition based on its name from the device's JSON definition. + *

+ * Example (for API v2): + * + *

+     * "MonitoringValue": {
+     *     "spin": {
+     *         "valueMapping": { ... }
+     *     }
+     * }
+     * 
+ *

+ * Calling {@code getFeatureDefinition("spin")} will return the corresponding + * {@link FeatureDefinition} object representing the "spin" feature. + *

+ * + * @param featureName The name of the feature in the JSON definition. + * @return A {@link FeatureDefinition} representing the specified feature. + */ + FeatureDefinition getFeatureDefinition(String featureName); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactory.java new file mode 100644 index 00000000000..1698a46ca9d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactory.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapabilityFactoryV1; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapabilityFactoryV2; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherCapabilityFactoryV2; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapabilityFactoryV1; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCapabilityFactoryV2; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapabilityFactoryV1; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapabilityFactoryV2; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Factory class responsible for creating {@link CapabilityDefinition} instances + * based on the device type and API version. + *

+ * This class follows the singleton pattern and maintains a registry of capability + * factories for various LG ThinQ devices. It dynamically assigns the correct + * {@link AbstractCapabilityFactory} based on the provided JSON node representing + * the device. + *

+ * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class CapabilityFactory { + + /** + * Singleton instance of {@code CapabilityFactory}. + */ + private static final CapabilityFactory INSTANCE = new CapabilityFactory(); + private final Logger logger = LoggerFactory.getLogger(CapabilityFactory.class); + /** + * A map that associates device types with their corresponding capability factories + * based on the API version. + */ + private final Map>> capabilityDeviceFactories = new HashMap<>(); + + /** + * Private constructor to initialize the factory registry. + *

+ * This constructor registers all available capability factories for different + * device types and API versions. + *

+ */ + private CapabilityFactory() { + List> factories = Arrays.asList(new ACCapabilityFactoryV1(), + new ACCapabilityFactoryV2(), new FridgeCapabilityFactoryV1(), new FridgeCapabilityFactoryV2(), + new WasherDryerCapabilityFactoryV1(), new WasherDryerCapabilityFactoryV2(), + new DishWasherCapabilityFactoryV2()); + + factories.forEach(factory -> { + factory.getSupportedDeviceTypes().forEach(deviceType -> { + Map> versionMap = capabilityDeviceFactories.get(deviceType); + if (versionMap == null) { + versionMap = new HashMap<>(); + } + for (LGAPIVerion version : factory.getSupportedAPIVersions()) { + versionMap.put(version, factory); + } + capabilityDeviceFactories.put(deviceType, versionMap); + }); + }); + } + + /** + * Retrieves the singleton instance of {@link CapabilityFactory}. + * + * @return The singleton instance. + */ + public static CapabilityFactory getInstance() { + return INSTANCE; + } + + /** + * Creates a capability definition for a given device type and API version. + *

+ * The method determines the device type and API version from the provided + * JSON node, then locates and invokes the appropriate factory to create + * the corresponding capability definition. + *

+ * + * @param The type of {@link CapabilityDefinition} expected. + * @param rootNode The JSON node containing device information. + * @param clazz The class type of the capability definition to be created. + * @return An instance of the specified {@link CapabilityDefinition} type. + * @throws LGThinqException If the capability creation fails. + * @throws IllegalStateException If no suitable factory is found for the given type and version. + */ + public C create(JsonNode rootNode, Class clazz) throws LGThinqException { + DeviceTypes type = ModelUtils.getDeviceType(rootNode); + LGAPIVerion version = ModelUtils.discoveryAPIVersion(rootNode); + logger.debug("Getting factory for device type: {} and version: {}", type.deviceTypeId(), version); + + Map> versionsFactory = capabilityDeviceFactories + .get(type); + if (versionsFactory == null || versionsFactory.isEmpty()) { + throw new IllegalStateException("Unexpected capability. The type " + type + " was not implemented yet"); + } + + AbstractCapabilityFactory factory = versionsFactory.get(version); + if (factory == null) { + throw new IllegalStateException( + "Unexpected capability. The type " + type + " and version " + version + " was not implemented yet"); + } + + return clazz.cast(factory.create(rootNode)); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CommandDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CommandDefinition.java new file mode 100644 index 00000000000..143663183cc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/CommandDefinition.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Represents a command definition used by the LG API, including details about the command, + * associated data, and optional configurations for the command. + * + *

+ * This class contains the following properties: + *

    + *
  • command: A string representing the command tag value that the API uses to launch + * the command service.
  • + *
  • data: A map holding additional data related to the command.
  • + *
  • cmdOptValue: An optional value used only for LG ThinQ V1 commands.
  • + *
  • isBinary: A boolean indicating whether the command operates in binary mode, + * used only for LG ThinQ V1 commands.
  • + *
  • dataTemplate: A template for data that needs to be sent to the LG API, + * as defined in the device specification.
  • + *
  • rawCommand: A raw representation of the command in text format, which includes + * the full command with placeholders and data used for sending requests to the LG API.
  • + *
+ *

+ * + *

+ * Usage example: A typical command definition might look like the following: + * + *

+ * {
+ *     "cmd": "Control",
+ *     "cmdOpt": "Operation",
+ *     "value": "Start",
+ *     "data": "[{{Course}},{{Wash}},{{SpinSpeed}},{{WaterTemp}},{{RinseOption}},0,{{Reserve_Time_H}},{{Reserve_Time_M}},{{LoadItem}},{{Option1}},{{Option2}},0,{{SmartCourse}},0]",
+ *     "encode": true
+ * }
+ * 
+ *

+ * + * @author Nemer Daud - Initial contribution + * @version 1.0 + */ +@NonNullByDefault +public class CommandDefinition { + + /** + * The command tag value used by the API to launch the command service. + */ + private String command = ""; + + /** + * A map containing additional data related to the command. + */ + private Map data = new HashMap<>(); + + /** + * An optional value used only for ThinQ V1 commands. + */ + private String cmdOptValue = ""; + + /** + * A flag indicating whether the command operates in binary mode. + * This is used only for ThinQ V1 commands. + */ + private boolean isBinary; + + /** + * The template in the device definition of data that must be sent to the LG API. + * It complements the command by providing the necessary data for the command execution. + */ + private String dataTemplate = ""; + + /** + * The raw command, as defined in the node command definition, which includes placeholders for data. + */ + private String rawCommand = ""; + + /** + * Gets the raw command. + * + * @return the raw command string + */ + public String getRawCommand() { + return rawCommand; + } + + /** + * Sets the raw command. + * + * @param rawCommand the raw command string + */ + public void setRawCommand(String rawCommand) { + this.rawCommand = rawCommand; + } + + /** + * Gets the command tag value. + * + * @return the command tag value + */ + public String getCommand() { + return command; + } + + /** + * Sets the command tag value. + * + * @param command the command tag value + */ + public void setCommand(String command) { + this.command = command; + } + + /** + * Gets the additional data associated with the command. + * + * @return a map of data associated with the command + */ + public Map getData() { + return data; + } + + /** + * Sets the additional data associated with the command. + * + * @param data a map of data to associate with the command + */ + public void setData(Map data) { + this.data = data; + } + + /** + * Gets the optional value used for ThinQ V1 commands. + * + * @return the cmdOpt value + */ + public String getCmdOptValue() { + return cmdOptValue; + } + + /** + * Sets the optional value used for ThinQ V1 commands. + * + * @param cmdOptValue the cmdOpt value + */ + public void setCmdOptValue(String cmdOptValue) { + this.cmdOptValue = cmdOptValue; + } + + /** + * Checks if the command operates in binary mode. + * + * @return true if the command is binary, false otherwise + */ + public boolean isBinary() { + return isBinary; + } + + /** + * Sets whether the command operates in binary mode. + * + * @param binary true if the command should operate in binary mode, false otherwise + */ + public void setBinary(boolean binary) { + isBinary = binary; + } + + /** + * Gets the data template that must be sent to the LG API. + * + * @return the data template string + */ + public String getDataTemplate() { + return dataTemplate; + } + + /** + * Sets the data template that must be sent to the LG API. + * + * @param dataTemplate the data template string + */ + public void setDataTemplate(String dataTemplate) { + this.dataTemplate = dataTemplate; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DefaultSnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DefaultSnapshotBuilder.java new file mode 100644 index 00000000000..f9493252952 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DefaultSnapshotBuilder.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.beans.BeanInfo; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * An abstract class representing a Default Snapshot Builder for creating different types of snapshots. + * + * @param The type parameter representing the Abstract Snapshot Definition + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class DefaultSnapshotBuilder implements SnapshotBuilder { + protected static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Map>> MODEL_CACHED_BITKEY_DEF = new HashMap<>(); + protected final Class snapClass; + private final Logger logger = LoggerFactory.getLogger(DefaultSnapshotBuilder.class); + + public DefaultSnapshotBuilder(Class clazz) { + snapClass = clazz; + } + + /** + * Create a Snapshot object from binary data using the provided monitoring binary protocols and capability + * definitions. + * + * @param binaryData The binary data to create the Snapshot from + * @param prot The list of MonitoringBinaryProtocol objects for defining how to parse the binary data + * @param capDef The CapabilityDefinition object for the Snapshot + * @return The created Snapshot object based on the binary data + * @throws LGThinqUnmarshallException if unmarshalling the binary data encounters an error + * @throws LGThinqApiException if any LG Thinq API related error occurs + */ + @Override + public S createFromBinary(String binaryData, List prot, CapabilityDefinition capDef) + throws LGThinqUnmarshallException, LGThinqApiException { + try { + Map snapValues = new HashMap<>(); + byte[] data = binaryData.getBytes(); + BeanInfo beanInfo = Introspector.getBeanInfo(snapClass); + S snap = snapClass.getConstructor().newInstance(); + PropertyDescriptor[] pds = beanInfo.getPropertyDescriptors(); + Map aliasesMethod = new HashMap<>(); + for (PropertyDescriptor property : pds) { + // all attributes of class. + Method m = property.getReadMethod(); + if (m == null) { + logger.warn("Property {} has no getter method. It's most likely a bug!", property.getName()); + continue; + } + JsonProperty jsonProAn = m.getAnnotation(JsonProperty.class); + if (jsonProAn != null) { + String value = jsonProAn.value(); + aliasesMethod.putIfAbsent(value, property); + } + JsonAlias jsonAliasAn = m.getAnnotation(JsonAlias.class); + if (jsonAliasAn != null) { + String[] values = jsonAliasAn.value(); + for (String v : values) { + aliasesMethod.putIfAbsent(v, property); + } + } + } + for (MonitoringBinaryProtocol protField : prot) { + if (protField.startByte + protField.length > data.length) { + // end of data. If have more fields in the protocol, will be ignored + break; + } + String fName = protField.fieldName; + int value = 0; + for (int i = protField.startByte; i < protField.startByte + protField.length; i++) { + value = (value << 8) + data[i]; + } + snapValues.put(fName, value); + PropertyDescriptor property = aliasesMethod.get(fName); + if (property != null) { + // found property. Get bit value + Method m = property.getWriteMethod(); + if (m.getParameters()[0].getType() == String.class) { + m.invoke(snap, String.valueOf(value)); + } else if (m.getParameters()[0].getType() == Double.class) { + m.invoke(snap, (double) value); + } else if (m.getParameters()[0].getType() == Integer.class) { + m.invoke(snap, value); + } else { + throw new IllegalArgumentException( + String.format("Parameter type not supported for this factory:%s", + m.getParameters()[0].getType().toString())); + } + } + } + snap.setRawData(snapValues); + return snap; + } catch (IntrospectionException | InvocationTargetException | InstantiationException | IllegalAccessException + | NoSuchMethodException e) { + throw new LGThinqUnmarshallException("Unexpected Error unmarshalling binary data", e); + } + } + + /** + * Create a Snapshot object from JSON data with the provided device type and capability definition. + * + * @param snapshotDataJson The JSON data to create the Snapshot from + * @param deviceType The DeviceTypes enum representing the type of device + * @param capDef The CapabilityDefinition object for the Snapshot + * @return The created Snapshot object + * @throws LGThinqUnmarshallException if unmarshalling the JSON data encounters an error + * @throws LGThinqApiException if any LG Thinq API related error occurs + */ + @Override + public S createFromJson(String snapshotDataJson, DeviceTypes deviceType, CapabilityDefinition capDef) + throws LGThinqUnmarshallException, LGThinqApiException { + try { + Map snapshotMap = MAPPER.readValue(snapshotDataJson, new TypeReference<>() { + }); + Map deviceSetting = new HashMap<>(); + deviceSetting.put("deviceType", deviceType.deviceTypeId()); + deviceSetting.put("snapshot", snapshotMap); + return createFromJson(deviceSetting, capDef); + } catch (JsonProcessingException e) { + throw new LGThinqUnmarshallException("Unexpected Error unmarshalling json to map", e); + } + } + + @Override + public S createFromJson(Map deviceSettings, CapabilityDefinition capDef) + throws LGThinqApiException { + Map snapMap = MAPPER.convertValue(deviceSettings.get("snapshot"), new TypeReference<>() { + }); + if (snapMap == null) { + throw new LGThinqApiException("snapshot node not present in device monitoring result."); + } + return getSnapshot(snapMap, capDef); + } + + /** + * Retrieves a snapshot object based on the provided map and capability definition. + * + * @param snapMap The map containing snapshot data + * @param capDef The CapabilityDefinition object defining the capabilities + * @return The retrieved snapshot object + */ + protected abstract S getSnapshot(Map snapMap, CapabilityDefinition capDef); + + /** + * Retrieves the DeviceTypes enum based on the deviceType field and deviceCode from the provided root map. + * + * @param rootMap The map containing the deviceType and deviceCode fields + * @return The DeviceTypes enum corresponding to the deviceType and deviceCode + */ + protected DeviceTypes getDeviceType(Map rootMap) { + Integer deviceTypeId = (Integer) rootMap.get("deviceType"); + // device code is only present in v2 devices snapshot. + String deviceCode = Objects.requireNonNullElse((String) rootMap.get("deviceCode"), ""); + Objects.requireNonNull(deviceTypeId, "Unexpected error. deviceType field not present in snapshot schema"); + return DeviceTypes.fromDeviceTypeId(deviceTypeId, deviceCode); + } + + /** + * Retrieves the bit key for the given key from the capability feature values map. + * + * @param key The key for which the bit key is needed + * @param capFeatureValues The map containing capability feature values + * @param cachedBitKey The cached bit key values map + * @return The bit key as a map containing 'option', 'startbit', and 'length' entries + */ + private Map getBitKey(String key, final Map> capFeatureValues, + final Map> cachedBitKey) { + // Define a local function to search for the bit key + Function>, Map> searchBitKey = data -> { + if (data.isEmpty()) { + return Collections.emptyMap(); + } + + for (int i = 1; i <= 3; i++) { + String optKey = "Option" + i; + Map option = data.get(optKey); + + if (option == null) { + continue; + } + + List> optionList = MAPPER.convertValue(option.get("option"), new TypeReference<>() { + }); + + if (optionList == null) { + continue; + } + + for (Map opt : optionList) { + String value = (String) opt.get("value"); + + if (key.equals(value)) { + Integer startBit = (Integer) opt.get("startbit"); + Integer length = (Integer) opt.getOrDefault("length", 1); + + if (startBit == null) { + return Collections.emptyMap(); + } + + Map bitKey = new HashMap<>(); + bitKey.put("option", optKey); + bitKey.put("startbit", startBit); + bitKey.put("length", length); + + return bitKey; + } + } + } + + return Collections.emptyMap(); + }; + + Map bitKey = cachedBitKey.get(key); + + if (bitKey == null) { + // cache the bitKey if it doesn't was fetched yet. + bitKey = searchBitKey.apply(capFeatureValues); + cachedBitKey.put(key, bitKey); + } + + return bitKey; + } + + /** + * Return the value related to the bit-value definition. It's used in Washer/Dryer V1 snapshot parser. + * It was here, in the parent, because maybe other devices need the same functionality. If not, + * We can transfer these methods to the WasherDryer Snapshot Builder. + * + * @param key Key trying to get the value + * @param snapRawValues snap raw value + * @param capDef capability + * @return return value associated or blank string + */ + protected String bitValue(String key, Map snapRawValues, final CapabilityDefinition capDef) { + // get the capability Values/MonitoringValues Map + // Look up the bit value for a specific key + if (snapRawValues.isEmpty()) { + logger.warn("No snapshot raw values provided. Corrupted data returned or bug"); + return ""; + } + Map> cachedBitKey = getSpecificCacheBitKey(capDef); + Map bitKey = this.getBitKey(key, capDef.getFeatureValuesRawData(), cachedBitKey); + if (bitKey.isEmpty()) { + logger.warn("BitKey {} not found in the Options feature values description capability. It's mostly a bug", + key); + return ""; + } + // Get the name of the option (Option1, Option2, etc) that contains the key (ex. LoadItem, RemoteStart) desired + String option = (String) bitKey.get("option"); + Object bitValueDef = snapRawValues.get(option); + if (bitValueDef == null) { + logger.warn("Value definition not found for the bitValue definition: {}. It's mostly a bug", option); + return ""; + } + String value = bitValueDef.toString(); + if (value.isEmpty()) { + return "0"; + } + + int bitValue = Integer.parseInt(value); + int startBit = (int) Objects.requireNonNull(bitKey.get("startbit"), "Not expected null here"); + int length = (int) bitKey.getOrDefault("length", 0); + int val = 0; + + for (int i = 0; i < length; i++) { + int bitIndex = (int) Math.pow(2, (startBit + i)); + int bit = (bitValue & bitIndex) != 0 ? 1 : 0; + val += bit * (int) Math.pow(2, i); + } + + return Integer.toString(val); + } + + /** + * Retrieves a specific cache bit key based on the provided CapabilityDefinition object. + * + * @param capDef The CapabilityDefinition object representing the device capabilities. + * @return A map containing the specific cache bit key for the given CapabilityDefinition. + */ + protected synchronized Map> getSpecificCacheBitKey(CapabilityDefinition capDef) { + return Objects + .requireNonNull(MODEL_CACHED_BITKEY_DEF.computeIfAbsent(capDef.getModelName(), k -> new HashMap<>())); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DevicePowerState.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DevicePowerState.java new file mode 100644 index 00000000000..d6cbae7b518 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DevicePowerState.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link DevicePowerState} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum DevicePowerState { + DV_POWER_ON(1), + DV_POWER_OFF(0), + DV_POWER_UNK(-1); + + private final int powerState; + + DevicePowerState(int i) { + powerState = i; + } + + public static DevicePowerState statusOf(@Nullable Integer value) { + return switch (value == null ? -1 : value) { + case 0 -> DV_POWER_OFF; + case 1, 256, 257 -> DV_POWER_ON; + default -> DV_POWER_UNK; + }; + } + + public static double valueOf(DevicePowerState dps) { + return dps.powerState; + } + + public double getValue() { + return powerState; + } + + /** + * Value of command (not state, but command to change the state of device) + * + * @return value of the command to reach the state + */ + public int commandValue() { + switch (this) { + case DV_POWER_ON: + return 257;// "@AC_MAIN_OPERATION_ALL_ON_W" + case DV_POWER_OFF: + return 0; // "@AC_MAIN_OPERATION_OFF_W" + default: + throw new IllegalArgumentException("Enum not accepted for command:" + this); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DeviceTypes.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DeviceTypes.java new file mode 100644 index 00000000000..aa017efdb42 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/DeviceTypes.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * An enumeration representing various device types along with their unique identifiers, acronyms, submodels, and thing + * type IDs. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum DeviceTypes { + AIR_CONDITIONER(401, "AC", "", "air-conditioner-401"), + HEAT_PUMP(401, "AC", "AWHP", "heatpump-401HP"), + WASHERDRYER_MACHINE(201, "WM", "", "washer-201"), + WASHER_TOWER(221, "WM", "", "washer-tower-221"), + DRYER(202, "DR", "Dryer", "dryer-202"), + DRYER_TOWER(222, "DR", "Dryer", "dryer-tower-222"), + FRIDGE(101, "REF", "Fridge", "fridge-101"), + DISH_WASHER(204, "DW", "DishWasher", "dishwasher-204"), + UNKNOWN(-1, "", "", ""); + + private final int deviceTypeId; + private final String deviceTypeAcron; + private final String deviceSubModel; + private final String thingTypeId; + + DeviceTypes(int i, String n, String submodel, String thingTypeId) { + this.deviceTypeId = i; + this.deviceTypeAcron = n; + this.deviceSubModel = submodel; + this.thingTypeId = thingTypeId; + } + + /** + * Returns the DeviceTypes enum based on the given device type ID and device code. + * + * @param deviceTypeId the device type ID to determine the corresponding DeviceTypes enum + * @param deviceCode the code of the device + * @return the DeviceTypes enum associated with the given device type ID and device code + */ + public static DeviceTypes fromDeviceTypeId(int deviceTypeId, String deviceCode) { + switch (deviceTypeId) { + case 401: + if ("AI05".equals(deviceCode)) { + return HEAT_PUMP; + } + return AIR_CONDITIONER; + case 201: + return WASHERDRYER_MACHINE; + case 221: + return WASHER_TOWER; + case 202: + return DRYER; + case 204: + return DISH_WASHER; + case 222: + return DRYER_TOWER; + case 101: + return FRIDGE; + default: + return UNKNOWN; + } + } + + /** + * Converts the device type acronym and model type to a corresponding DeviceTypes enum value. + * + * @param deviceTypeAcron The device type acronym. + * @param modelType The model type of the device. + * @return The DeviceTypes enum value corresponding to the device type acronym and model type. + */ + public static DeviceTypes fromDeviceTypeAcron(String deviceTypeAcron, String modelType) { + return switch (deviceTypeAcron) { + case "AC" -> { + if ("AWHP".equals(modelType)) { + yield HEAT_PUMP; + } + yield AIR_CONDITIONER; + } + case "WM" -> { + if ("Dryer".equals(modelType)) { + yield DRYER; + } + yield WASHERDRYER_MACHINE; + } + case "REF" -> FRIDGE; + case "DW" -> DISH_WASHER; + default -> UNKNOWN; + }; + } + + public String deviceTypeAcron() { + return deviceTypeAcron; + } + + public int deviceTypeId() { + return deviceTypeId; + } + + public String deviceSubModel() { + return deviceSubModel; + } + + public String thingTypeId() { + return thingTypeId; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDataType.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDataType.java new file mode 100644 index 00000000000..f2e3a2a7f57 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDataType.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link FeatureDataType} + * Feature is the values the device has to expose its sensor attributes + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum FeatureDataType { + ENUM, + RANGE, + BOOLEAN, + BIT, + REFERENCE, + UNDEF; + + public static FeatureDataType fromValue(String value) { + switch (value.toLowerCase()) { + case "enum": + return ENUM; + case "boolean": + return BOOLEAN; + case "bit": + return BIT; + case "range": + return RANGE; + case "reference": + return REFERENCE; + default: + return UNDEF; + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDefinition.java new file mode 100644 index 00000000000..a3d1497b580 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/FeatureDefinition.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link FeatureDefinition} defines the feature definitions extracted from the capability files in + * the MonitoringValue/Value session. All features are read-only by default. The factory must change-it if + * a specific one can be represented by a Writable Channel. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class FeatureDefinition { + public static final FeatureDefinition NULL_DEFINITION = new FeatureDefinition(); + String name = ""; + String channelId = ""; + String refChannelId = ""; + String label = ""; + Boolean readOnly = true; + FeatureDataType dataType = FeatureDataType.UNDEF; + Map valuesMapping = new HashMap<>(); + + /** + * Return the optional referenced channel Id. In some cases, the feature has a reference from another channel. + * In other words, in some cases, it copies or use value hold for other channels. + * + * @return the optional referenced field for this feature + */ + public String getRefChannelId() { + return refChannelId; + } + + /** + * Set the optional reference field for this channel In some cases, the feature has a reference from another + * channel. + * In other words, in some cases, it copies or use value hold for other channels. + * + * @param refChannelId the optional referenced field for this feature + */ + public void setRefChannelId(String refChannelId) { + this.refChannelId = refChannelId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public FeatureDataType getDataType() { + return dataType; + } + + public void setDataType(FeatureDataType dataType) { + this.dataType = dataType; + } + + public Boolean isReadOnly() { + return readOnly; + } + + public void setReadOnly(Boolean readOnly) { + this.readOnly = readOnly; + } + + public Map getValuesMapping() { + return valuesMapping; + } + + public void setValuesMapping(Map valuesMapping) { + this.valuesMapping = valuesMapping; + } + + public String getChannelId() { + return channelId; + } + + public void setChannelId(String channelId) { + this.channelId = channelId; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FeatureDefinition that = (FeatureDefinition) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGAPIVerion.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGAPIVerion.java new file mode 100644 index 00000000000..e7f44ad5e4f --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGAPIVerion.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LGAPIVerion} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum LGAPIVerion { + V1_0(1.0), + V2_0(2.0), + UNDEF(0.0); + + private final double version; + + LGAPIVerion(double v) { + version = v; + } + + public double getValue() { + return version; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGDevice.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGDevice.java new file mode 100644 index 00000000000..89e4bc48a05 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/LGDevice.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Class representing an LG device with various properties such as model name, device type, alias, device ID, platform + * type, online status, and more. + * + * @author Nemer Daud - Initial contribution + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@NonNullByDefault +public class LGDevice { + private String modelName = ""; + @JsonProperty("deviceType") + private int deviceTypeId; + private String deviceCode = ""; + private String alias = ""; + private String deviceId = ""; + private String platformType = ""; + private String modelJsonUri = ""; + private boolean online; + + public String getModelName() { + return modelName; + } + + public void setModelName(String modelName) { + this.modelName = modelName; + } + + @JsonIgnore + public DeviceTypes getDeviceType() { + return DeviceTypes.fromDeviceTypeId(deviceTypeId, deviceCode); + } + + public int getDeviceTypeId() { + return deviceTypeId; + } + + public void setDeviceTypeId(int deviceTypeId) { + this.deviceTypeId = deviceTypeId; + } + + public String getDeviceCode() { + return deviceCode; + } + + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + public String getModelJsonUri() { + return modelJsonUri; + } + + public void setModelJsonUri(String modelJsonUri) { + this.modelJsonUri = modelJsonUri; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getPlatformType() { + return platformType; + } + + public void setPlatformType(String platformType) { + this.platformType = platformType; + } + + public boolean isOnline() { + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ModelUtils.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ModelUtils.java new file mode 100644 index 00000000000..3472d8766c1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ModelUtils.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import static org.openhab.binding.lgthinq.lgservices.model.DeviceTypes.fromDeviceTypeAcron; + +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link ModelUtils} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class ModelUtils { + public static final ObjectMapper MAPPER = new ObjectMapper(); + + public static DeviceTypes getDeviceType(Map rootMap) { + Map infoMap = MAPPER.convertValue(rootMap.get("Info"), new TypeReference<>() { + }); + Objects.requireNonNull(infoMap, "Unexpected error. Info node not present in capability schema"); + String productType = infoMap.getOrDefault("productType", ""); + String modelType = infoMap.getOrDefault("modelType", ""); + Objects.requireNonNull(infoMap, "Unexpected error. ProductType attribute not present in capability schema"); + return fromDeviceTypeAcron(productType, modelType); + } + + public static DeviceTypes getDeviceType(JsonNode rootNode) { + Map mapper = MAPPER.convertValue(rootNode, new TypeReference<>() { + }); + return getDeviceType(mapper); + } + + public static LGAPIVerion discoveryAPIVersion(JsonNode rootNode) { + Map mapper = MAPPER.convertValue(rootNode, new TypeReference<>() { + }); + return discoveryAPIVersion(mapper); + } + + public static LGAPIVerion discoveryAPIVersion(Map rootMap) { + DeviceTypes type = getDeviceType(rootMap); + switch (type) { + case AIR_CONDITIONER: + case HEAT_PUMP: + Map valueNode = MAPPER.convertValue(rootMap.get("Value"), new TypeReference<>() { + }); + if (valueNode.containsKey("support.airState.opMode")) { + return LGAPIVerion.V2_0; + } else if (valueNode.containsKey("SupportOpMode")) { + return LGAPIVerion.V1_0; + } else { + throw new IllegalStateException( + "Unexpected error. Can't find key node attributes to determine ACCapability API version."); + } + + case WASHERDRYER_MACHINE: + case DRYER: + case FRIDGE: + if (rootMap.containsKey("Value")) { + return LGAPIVerion.V1_0; + } else if (rootMap.containsKey("MonitoringValue")) { + return LGAPIVerion.V2_0; + } else { + throw new IllegalStateException( + "Unexpected error. Can't find key node attributes to determine ACCapability API version."); + } + case DISH_WASHER: + return LGAPIVerion.V2_0; + default: + throw new IllegalStateException("Unexpected capability. The type " + type + " was not implemented yet"); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringBinaryProtocol.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringBinaryProtocol.java new file mode 100644 index 00000000000..28331f8a147 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringBinaryProtocol.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link MonitoringBinaryProtocol} + * + * @author Nemer Daud - Initial contribution + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@NonNullByDefault +public class MonitoringBinaryProtocol { + @JsonProperty("startByte") + public int startByte; + @JsonProperty("length") + public int length; + @JsonProperty("value") + public String fieldName = ""; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringResultFormat.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringResultFormat.java new file mode 100644 index 00000000000..ea6c1b581ce --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/MonitoringResultFormat.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link MonitoringResultFormat} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum MonitoringResultFormat { + JSON_FORMAT(""), + BINARY_FORMAT("BINARY(BYTE)"), + UNKNOWN_FORMAT("UNKNOWN_FORMAT"); + + final String format; + + MonitoringResultFormat(String format) { + this.format = format; + } + + public static MonitoringResultFormat getFormatOf(String formatValue) { + return switch (formatValue.toUpperCase()) { + case "BINARY(BYTE)" -> BINARY_FORMAT; + case "JSON" -> JSON_FORMAT; + default -> UNKNOWN_FORMAT; + }; + } + + public String getFormat() { + return format; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ResultCodes.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ResultCodes.java new file mode 100644 index 00000000000..f3a6d931bf1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/ResultCodes.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Enumeration representing various result codes that can be returned from the LG ThinQ service. + * Each result code is associated with a description and one or more error codes. + * + *

+ * These result codes provide information about the success or failure of a request to the LG API. + * Some result codes indicate specific errors, while others represent success or other types of issues. + *

+ * + *

+ * Usage Example: + * + *

+ * ResultCodes result = ResultCodes.fromCode("0000");
+ * System.out.println(result.getDescription()); // Outputs: "Success"
+ * 
+ *

+ * + * @author Nemer Daud - Initial contribution + * @version 1.0 + */ +@NonNullByDefault +public enum ResultCodes { + + /** + * Indicates that the device is offline. + */ + DEVICE_OFFLINE("Device Offline", "0106"), + + /** + * Indicates that the request was successful. + */ + OK("Success", "0000", "0001"), + + /** + * Indicates that the device did not respond. + */ + DEVICE_NOT_RESPONSE("Device Not Response", "0111", "0103", "0104", "0106"), + + /** + * Indicates a portal internal error. + */ + PORTAL_INTERWORKING_ERROR("Portal Internal Error", "0007"), + + /** + * Indicates that the login attempt failed due to duplication. + */ + LOGIN_DUPLICATED("Login Duplicated", "0004"), + + /** + * Indicates that an update to the agreement terms is required in the LG app. + */ + UPDATE_TERMS_NEEDED("Update Agreement Terms in LG App", "0110"), + + /** + * Indicates a failed login or session failure. + */ + LOGIN_FAILED("Login/Session Failed. Try to login and correct issues direct on LG Account Portal", "0102", "0114"), + + /** + * Indicates a base64 decoding/encoding error. + */ + BASE64_CODING_ERROR("Base64 Decoding/Encoding error", "9002", "9001"), + + /** + * Indicates that the command or service is not supported. + */ + NOT_SUPPORTED_CONTROL("Command/Control/Service is not supported", "0005", "0012", "8001"), + + /** + * Indicates an error while controlling the device. + */ + CONTROL_ERROR("Error in device control", "0105"), + + /** + * Indicates an LG server error or an invalid request. + */ + LG_SERVER_ERROR("LG Server Error/Invalid Request", "8101", "8102", "8103", "8104", "8105", "8106", "8107", "9003", + "9004", "9005", "9000", "8900", "0107"), + + /** + * Indicates a malformed or wrong payload in the request. + */ + PAYLOAD_ERROR("Malformed or Wrong Payload", "9999"), + + /** + * Indicates duplicated data or alias. + */ + DUPLICATED_DATA("Duplicated Data/Alias", "0008", "0013"), + + /** + * Indicates access denial. Suggests verifying the account and password in the LG Account Portal. + */ + ACCESS_DENIED("Access Denied. Verify your account/password in LG Account Portal.", "9006", "0011", "0113"), + + /** + * Indicates that the country is not supported. + */ + NOT_SUPPORTED_COUNTRY("Country not supported.", "8000"), + + /** + * Indicates a network failure or timeout. + */ + NETWORK_FAILED("Timeout/Network has failed.", "9020"), + + /** + * Indicates that the limit has been exceeded. + */ + LIMIT_EXCEEDED_ERROR("Limit has been exceeded", "0112"), + + /** + * Indicates that the customer number has expired. + */ + CUSTOMER_NUMBER_EXPIRED("Customer number has been expired", "0119"), + + /** + * Indicates that the customer data is invalid or does not exist. + */ + INVALID_CUSTOMER_DATA("Customer data is invalid or Data Doesn't exist.", "0010"), + + /** + * A general failure error. + */ + GENERAL_FAILURE("General Failure", "0100"), + + /** + * Indicates an invalid CSR (Certificate Signing Request). + */ + INVALID_CSR("Invalid CSR", "9010"), + + /** + * Indicates an invalid body or payload in the request. + */ + INVALID_PAYLOAD("Invalid Body/Payload", "0002"), + + /** + * Indicates an invalid customer number. + */ + INVALID_CUSTOMER_NUMBER("Invalid Customer Number", "0118", "120"), + + /** + * Indicates an invalid request header. + */ + INVALID_HEAD("Invalid Request Head", "0003"), + + /** + * Indicates an invalid push token. + */ + INVALID_PUSH_TOKEN("Invalid Push Token", "0301"), + + /** + * Indicates an invalid request. + */ + INVALID_REQUEST("Invalid request", "0116"), + + /** + * Indicates that Smart Care is not registered. + */ + NOT_REGISTERED_SMART_CARE("Smart Care not registered", "0121"), + + /** + * Indicates a device or group mismatch, or a device/model does not exist in the account. + */ + DEVICE_MISMATCH("Device/Group mismatch or device/model doesn't exist in your account.", "0115", "0006", "0009", + "0117", "0014"), + + /** + * Indicates that no information was found for the given arguments. + */ + NO_INFORMATION_FOUND("No information found for the arguments", "109", "108"), + + /** + * A generic error when processing a request. + */ + OTHER("Error processing request."), + + /** + * Represents an unknown result code. + */ + UNKNOWN("UNKNOWN", ""); + + /** + * A map of other error codes that do not directly map to predefined result codes. + */ + public static final Map OTHER_ERROR_CODE_RESPONSE = Map + .ofEntries(Map.entry("0109", "NO_INFORMATION_DR"), Map.entry("0108", "NO_INFORMATION_SLEEP_MODE")); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final String description; + private final List codes; + + /** + * Constructor for the enum. Initializes the description and associated error codes. + * + * @param description the description of the result code + * @param codes the error codes associated with the result code + */ + ResultCodes(String description, String... codes) { + this.codes = Arrays.asList(codes); + this.description = description; + } + + /** + * Gets the reason response for a given JSON response string. + * + * @param jsonResponse the JSON response string to process + * @return a formatted string containing the result code and its corresponding description + */ + public static String getReasonResponse(String jsonResponse) { + try { + JsonNode devicesResult = MAPPER.readValue(jsonResponse, new TypeReference<>() { + }); + String resultCode = devicesResult.path("resultCode").asText(); + return String.format("%s - %s", resultCode, fromCode(resultCode).description); + } catch (JsonProcessingException e) { + return ""; + } + } + + /** + * Returns the ResultCodes enum corresponding to the given error code. + * + * @param code the error code to map to a ResultCodes enum + * @return the ResultCodes enum corresponding to the provided code + */ + public static ResultCodes fromCode(String code) { + return switch (code) { + case "0000", "0001" -> OK; + case "0002" -> INVALID_PAYLOAD; + case "0003" -> INVALID_HEAD; + case "0110" -> UPDATE_TERMS_NEEDED; + case "0004" -> LOGIN_DUPLICATED; + case "0102", "0114" -> LOGIN_FAILED; + case "0100" -> GENERAL_FAILURE; + case "0116" -> INVALID_REQUEST; + case "0108", "0109" -> NO_INFORMATION_FOUND; + case "0115", "0006", "0009", "0117", "0014", "0101" -> DEVICE_MISMATCH; + case "0010" -> INVALID_CUSTOMER_DATA; + case "0112" -> LIMIT_EXCEEDED_ERROR; + case "0118", "0120" -> INVALID_CUSTOMER_NUMBER; + case "0121" -> NOT_REGISTERED_SMART_CARE; + case "0007" -> PORTAL_INTERWORKING_ERROR; + case "0008", "0013" -> DUPLICATED_DATA; + case "0005", "0012", "8001" -> NOT_SUPPORTED_CONTROL; + case "0111", "0103", "0104", "0106" -> DEVICE_NOT_RESPONSE; + case "0105" -> CONTROL_ERROR; + case "9001", "9002" -> BASE64_CODING_ERROR; + case "0107", "8101", "8102", "8203", "8204", "8205", "8206", "8207", "8900", "9000", "9003", "9004", + "9005" -> + LG_SERVER_ERROR; + case "9999" -> PAYLOAD_ERROR; + case "9006", "0011", "0113" -> ACCESS_DENIED; + case "9010" -> INVALID_CSR; + case "0301" -> INVALID_PUSH_TOKEN; + default -> { + if (OTHER_ERROR_CODE_RESPONSE.containsKey(code)) { + yield OTHER; + } + yield UNKNOWN; + } + }; + } + + /** + * Checks if the result code contains the specified code. + * + * @param code the error code to check + * @return true if the result code contains the specified code, false otherwise + */ + public boolean containsResultCode(String code) { + return codes.contains(code); + } + + /** + * Gets the description of the result code. + * + * @return the description of the result code + */ + public String getDescription() { + return description; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilder.java new file mode 100644 index 00000000000..c3d6a905552 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilder.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; + +/** + * Interface for building snapshots from various data sources, such as binary data or JSON data. + * This interface provides methods for creating snapshots based on different types of input data, + * which can be used to define and monitor devices in the LG ThinQ service. + * + *

+ * The implementation of this interface should be able to handle the deserialization of the + * binary and JSON data and return a snapshot representation that conforms to the provided + * {@link SnapshotDefinition}. + *

+ * + *

+ * Usage Example: + * + *

+ * SnapshotBuilder<MySnapshot> snapshotBuilder = new MySnapshotBuilder();
+ * MySnapshot snapshot = snapshotBuilder.createFromJson(jsonData, deviceType, capDef);
+ * 
+ *

+ * + * @param the type of snapshot to be created, extending {@link SnapshotDefinition} + * @author Nemer Daud - Initial contribution + * @version 1.0 + */ +@NonNullByDefault +public interface SnapshotBuilder { + + /** + * Creates a snapshot from binary data. + * + * @param binaryData the binary data to be deserialized into a snapshot + * @param prot a list of monitoring binary protocols used for parsing the binary data + * @param capDef the capability definition to be applied to the snapshot + * @return the created snapshot + * @throws LGThinqUnmarshallException if an error occurs during unmarshalling the binary data + * @throws LGThinqApiException if a general API error occurs + */ + S createFromBinary(String binaryData, List prot, CapabilityDefinition capDef) + throws LGThinqUnmarshallException, LGThinqApiException; + + /** + * Creates a snapshot from a JSON string representation of the snapshot data. + * + * @param snapshotDataJson the JSON string containing the snapshot data + * @param deviceType the type of the device associated with the snapshot + * @param capDef the capability definition to be applied to the snapshot + * @return the created snapshot + * @throws LGThinqUnmarshallException if an error occurs during unmarshalling the JSON data + * @throws LGThinqApiException if a general API error occurs + */ + S createFromJson(String snapshotDataJson, DeviceTypes deviceType, CapabilityDefinition capDef) + throws LGThinqUnmarshallException, LGThinqApiException; + + /** + * Creates a snapshot from a map of device settings. + * + * @param deviceSettings the map containing device-specific settings + * @param capDef the capability definition to be applied to the snapshot + * @return the created snapshot + * @throws LGThinqApiException if an error occurs during the API processing + */ + S createFromJson(Map deviceSettings, CapabilityDefinition capDef) throws LGThinqApiException; +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilderFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilderFactory.java new file mode 100644 index 00000000000..e707723ea48 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotBuilderFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher.DishWasherSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.fridge.FridgeSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerSnapshotBuilder; + +/** + * The {@link SnapshotBuilderFactory} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class SnapshotBuilderFactory { + private static final SnapshotBuilderFactory INSTANCE; + + static { + INSTANCE = new SnapshotBuilderFactory(); + } + + private final Map, SnapshotBuilder> internalBuilders = new HashMap<>(); + + private SnapshotBuilderFactory() { + } + + public static SnapshotBuilderFactory getInstance() { + return INSTANCE; + } + + public SnapshotBuilder getBuilder(Class snapDef) { + SnapshotBuilder result = internalBuilders.get(snapDef); + if (result == null) { + if (snapDef.equals(WasherDryerSnapshot.class)) { + result = new WasherDryerSnapshotBuilder(); + } else if (snapDef.equals(ACCanonicalSnapshot.class)) { + result = new ACSnapshotBuilder(); + } else if (snapDef.equals(FridgeCanonicalSnapshot.class)) { + result = new FridgeSnapshotBuilder(); + } else if (snapDef.equals(DishWasherSnapshot.class)) { + result = new DishWasherSnapshotBuilder(); + } else { + throw new IllegalStateException( + "Snapshot definition " + snapDef + " not supported by this Factory. It is most likely a bug"); + } + internalBuilders.put(snapDef, result); + } + return result; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotDefinition.java new file mode 100644 index 00000000000..f49af0c8bfc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/SnapshotDefinition.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Interface that represents the definition of a snapshot, which includes information about + * the power status and online state of a device. + * + *

+ * This interface is typically used to provide a representation of the current state of a + * device in the LG ThinQ service. It allows retrieving and updating the power status and online + * state of the device. + *

+ * + *

+ * Implementations of this interface should provide the actual data and logic for managing + * the power state and online status of a device. + *

+ * + *

+ * Usage Example: + * + *

+ * SnapshotDefinition snapshot = new MySnapshotImplementation();
+ * snapshot.setPowerStatus(DevicePowerState.ON);
+ * snapshot.setOnline(true);
+ * 
+ *

+ * + * @author Nemer Daud - Initial contribution + * @version 1.0 + */ +@NonNullByDefault +public interface SnapshotDefinition { + + /** + * Gets the power status of the device. + * + * @return the power status of the device as an instance of {@link DevicePowerState} + */ + DevicePowerState getPowerStatus(); + + /** + * Sets the power status of the device. + * + * @param value the power status to set as an instance of {@link DevicePowerState} + */ + void setPowerStatus(DevicePowerState value); + + /** + * Checks if the device is online. + * + * @return true if the device is online, false otherwise + */ + boolean isOnline(); + + /** + * Sets the online status of the device. + * + * @param online true if the device is online, false if it is offline + */ + void setOnline(boolean online); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCanonicalSnapshot.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCanonicalSnapshot.java new file mode 100644 index 00000000000..ccb3d8c3956 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCanonicalSnapshot.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link ACCanonicalSnapshot} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@JsonIgnoreProperties(ignoreUnknown = true) +public class ACCanonicalSnapshot extends AbstractSnapshotDefinition { + + // ============ FOR HEAT PUMP ONLY =============== + private double hpWaterTempCoolMin; + private double hpWaterTempCoolMax; + private double hpWaterTempHeatMin; + private double hpWaterTempHeatMax; + private double hpAirTempCoolMin; + private double hpAirTempCoolMax; + private double hpAirTempHeatMin; + private double hpAirTempHeatMax; + private double hpAirWaterTempSwitch = -1; + // =============================================== + + private int airWindStrength; + private double targetTemperature; + private double currentTemperature; + private double airCleanMode; + private double coolJetMode; + private double autoDryMode; + private double energySavingMode; + private double stepUpDownMode; + private double stepLeftRightMode; + + private int operationMode; + @Nullable + private Integer operation; + @JsonIgnore + private boolean online; + + private double energyConsumption; + + @JsonIgnore + public DevicePowerState getPowerStatus() { + return DevicePowerState.statusOf(operation); + } + + @JsonIgnore + public void setPowerStatus(DevicePowerState value) { + operation = (int) value.getValue(); + } + + @JsonIgnore + public ACFanSpeed getAcFanSpeed() { + return ACFanSpeed.statusOf(airWindStrength); + } + + @JsonProperty("airState.windStrength") + @JsonAlias("WindStrength") + public Integer getAirWindStrength() { + return airWindStrength; + } + + public void setAirWindStrength(Integer airWindStrength) { + this.airWindStrength = airWindStrength; + } + + @JsonProperty("airState.wMode.jet") + @JsonAlias("Jet") + public Double getCoolJetMode() { + return coolJetMode; + } + + public void setCoolJetMode(Double coolJetMode) { + this.coolJetMode = coolJetMode; + } + + @JsonProperty("airState.wMode.airClean") + @JsonAlias("AirClean") + public Double getAirCleanMode() { + return airCleanMode; + } + + public void setAirCleanMode(double airCleanMode) { + this.airCleanMode = airCleanMode; + } + + @JsonProperty("airState.miscFuncState.autoDry") + @JsonAlias("AutoDry") + public Double getAutoDryMode() { + return autoDryMode; + } + + public void setAutoDryMode(double autoDryMode) { + this.autoDryMode = autoDryMode; + } + + @JsonProperty("airState.powerSave.basic") + @JsonAlias("PowerSave") + public Double getEnergySavingMode() { + return energySavingMode; + } + + public void setEnergySavingMode(double energySavingMode) { + this.energySavingMode = energySavingMode; + } + + @JsonProperty("airState.energy.onCurrent") + public double getEnergyConsumption() { + return energyConsumption; + } + + public void setEnergyConsumption(double energyConsumption) { + this.energyConsumption = energyConsumption; + } + + @JsonProperty("airState.tempState.target") + @JsonAlias("TempCfg") + public Double getTargetTemperature() { + return targetTemperature; + } + + public void setTargetTemperature(Double targetTemperature) { + this.targetTemperature = targetTemperature; + } + + @JsonProperty("airState.tempState.current") + @JsonAlias("TempCur") + public Double getCurrentTemperature() { + return currentTemperature; + } + + public void setCurrentTemperature(Double currentTemperature) { + this.currentTemperature = currentTemperature; + } + + @JsonProperty("airState.opMode") + @JsonAlias("OpMode") + public Integer getOperationMode() { + return operationMode; + } + + public void setOperationMode(Integer operationMode) { + this.operationMode = operationMode; + } + + @Nullable + @JsonProperty("airState.operation") + @JsonAlias("Operation") + public Integer getOperation() { + return operation; + } + + public void setOperation(Integer operation) { + this.operation = operation; + } + + @JsonProperty("airState.wDir.vStep") + @JsonAlias("WDirVStep") + public double getStepUpDownMode() { + return stepUpDownMode; + } + + public void setStepUpDownMode(double stepUpDownMode) { + this.stepUpDownMode = stepUpDownMode; + } + + @JsonProperty("airState.wDir.hStep") + @JsonAlias("WDirHStep") + public double getStepLeftRightMode() { + return stepLeftRightMode; + } + + public void setStepLeftRightMode(double stepLeftRightMode) { + this.stepLeftRightMode = stepLeftRightMode; + } + + @JsonIgnore + public boolean isOnline() { + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + // ==================== For HP only + @JsonProperty("airState.tempState.waterTempCoolMin") + public double getHpWaterTempCoolMin() { + return hpWaterTempCoolMin; + } + + public void setHpWaterTempCoolMin(double hpWaterTempCoolMin) { + this.hpWaterTempCoolMin = hpWaterTempCoolMin; + } + + @JsonProperty("airState.tempState.waterTempCoolMax") + public double getHpWaterTempCoolMax() { + return hpWaterTempCoolMax; + } + + public void setHpWaterTempCoolMax(double hpWaterTempCoolMax) { + this.hpWaterTempCoolMax = hpWaterTempCoolMax; + } + + @JsonProperty("airState.tempState.waterTempHeatMin") + public double getHpWaterTempHeatMin() { + return hpWaterTempHeatMin; + } + + public void setHpWaterTempHeatMin(double hpWaterTempHeatMin) { + this.hpWaterTempHeatMin = hpWaterTempHeatMin; + } + + @JsonProperty("airState.tempState.waterTempHeatMax") + public double getHpWaterTempHeatMax() { + return hpWaterTempHeatMax; + } + + public void setHpWaterTempHeatMax(double hpWaterTempHeatMax) { + this.hpWaterTempHeatMax = hpWaterTempHeatMax; + } + + @JsonProperty("airState.tempState.airTempCoolMin") + public double getHpAirTempCoolMin() { + return hpAirTempCoolMin; + } + + public void setHpAirTempCoolMin(double hpAirTempCoolMin) { + this.hpAirTempCoolMin = hpAirTempCoolMin; + } + + @JsonProperty("airState.tempState.airTempCoolMax") + public double getHpAirTempCoolMax() { + return hpAirTempCoolMax; + } + + public void setHpAirTempCoolMax(double hpAirTempCoolMax) { + this.hpAirTempCoolMax = hpAirTempCoolMax; + } + + @JsonProperty("airState.tempState.airTempHeatMin") + public double getHpAirTempHeatMin() { + return hpAirTempHeatMin; + } + + public void setHpAirTempHeatMin(double hpAirTempHeatMin) { + this.hpAirTempHeatMin = hpAirTempHeatMin; + } + + @JsonProperty("airState.tempState.airTempHeatMax") + public double getHpAirTempHeatMax() { + return hpAirTempHeatMax; + } + + public void setHpAirTempHeatMax(double hpAirTempHeatMax) { + this.hpAirTempHeatMax = hpAirTempHeatMax; + } + + @JsonProperty("airState.miscFuncState.awhpTempSwitch") + public double getHpAirWaterTempSwitch() { + return hpAirWaterTempSwitch; + } + + public void setHpAirWaterTempSwitch(double hpAirWaterTempSwitch) { + this.hpAirWaterTempSwitch = hpAirWaterTempSwitch; + } + // =================================== + + @Override + public String toString() { + return "ACSnapShot{" + "airWindStrength=" + airWindStrength + ", targetTemperature=" + targetTemperature + + ", currentTemperature=" + currentTemperature + ", operationMode=" + operationMode + ", operation=" + + operation + ", acPowerStatus=" + getPowerStatus() + ", acFanSpeed=" + getAcFanSpeed() + ", acOpMode=" + + ", online=" + isOnline() + " }"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapability.java new file mode 100644 index 00000000000..300d01ca739 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapability.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import java.util.Collections; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapability; + +/** + * The {@link ACCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class ACCapability extends AbstractCapability { + + private Map opMod = Collections.emptyMap(); + private Map fanSpeed = Collections.emptyMap(); + private Map stepUpDown = Collections.emptyMap(); + private Map stepLeftRight = Collections.emptyMap(); + private boolean isJetModeAvailable; + private boolean isStepUpDownAvailable; + private boolean isStepLeftRightAvailable; + private boolean isEnergyMonitorAvailable; + private boolean isFilterMonitorAvailable; + private boolean isAutoDryModeAvailable; + private boolean isEnergySavingAvailable; + private boolean isAirCleanAvailable; + private String coolJetModeCommandOn = ""; + private String coolJetModeCommandOff = ""; + private String autoDryModeCommandOn = ""; + private String autoDryModeCommandOff = ""; + + private String energySavingModeCommandOn = ""; + private String energySavingModeCommandOff = ""; + + private String airCleanModeCommandOn = ""; + private String airCleanModeCommandOff = ""; + + public Map getStepLeftRight() { + return stepLeftRight; + } + + public void setStepLeftRight(Map stepLeftRight) { + this.stepLeftRight = stepLeftRight; + } + + public Map getStepUpDown() { + return stepUpDown; + } + + public void setStepUpDown(Map stepUpDown) { + this.stepUpDown = stepUpDown; + } + + public String getCoolJetModeCommandOff() { + return coolJetModeCommandOff; + } + + public void setCoolJetModeCommandOff(String coolJetModeCommandOff) { + this.coolJetModeCommandOff = coolJetModeCommandOff; + } + + public String getCoolJetModeCommandOn() { + return coolJetModeCommandOn; + } + + public void setCoolJetModeCommandOn(String coolJetModeCommandOn) { + this.coolJetModeCommandOn = coolJetModeCommandOn; + } + + public boolean isStepUpDownAvailable() { + return isStepUpDownAvailable; + } + + public void setStepUpDownAvailable(boolean stepUpDownAvailable) { + isStepUpDownAvailable = stepUpDownAvailable; + } + + public boolean isStepLeftRightAvailable() { + return isStepLeftRightAvailable; + } + + public void setStepLeftRightAvailable(boolean stepLeftRightAvailable) { + isStepLeftRightAvailable = stepLeftRightAvailable; + } + + public Map getOpMode() { + return opMod; + } + + public void setOpMod(Map opMod) { + this.opMod = opMod; + } + + public Map getFanSpeed() { + return fanSpeed; + } + + public void setFanSpeed(Map fanSpeed) { + this.fanSpeed = fanSpeed; + } + + public boolean isAutoDryModeAvailable() { + return isAutoDryModeAvailable; + } + + public void setAutoDryModeAvailable(boolean autoDryModeAvailable) { + isAutoDryModeAvailable = autoDryModeAvailable; + } + + public boolean isEnergySavingAvailable() { + return isEnergySavingAvailable; + } + + public void setEnergySavingAvailable(boolean energySavingAvailable) { + isEnergySavingAvailable = energySavingAvailable; + } + + public boolean isAirCleanAvailable() { + return isAirCleanAvailable; + } + + public void setAirCleanAvailable(boolean airCleanAvailable) { + isAirCleanAvailable = airCleanAvailable; + } + + public boolean isJetModeAvailable() { + return this.isJetModeAvailable; + } + + public void setJetModeAvailable(boolean jetModeAvailable) { + this.isJetModeAvailable = jetModeAvailable; + } + + public String getAutoDryModeCommandOn() { + return autoDryModeCommandOn; + } + + public void setAutoDryModeCommandOn(String autoDryModeCommandOn) { + this.autoDryModeCommandOn = autoDryModeCommandOn; + } + + public String getAutoDryModeCommandOff() { + return autoDryModeCommandOff; + } + + public void setAutoDryModeCommandOff(String autoDryModeCommandOff) { + this.autoDryModeCommandOff = autoDryModeCommandOff; + } + + public String getEnergySavingModeCommandOn() { + return energySavingModeCommandOn; + } + + public void setEnergySavingModeCommandOn(String energySavingModeCommandOn) { + this.energySavingModeCommandOn = energySavingModeCommandOn; + } + + public String getEnergySavingModeCommandOff() { + return energySavingModeCommandOff; + } + + public void setEnergySavingModeCommandOff(String energySavingModeCommandOff) { + this.energySavingModeCommandOff = energySavingModeCommandOff; + } + + public String getAirCleanModeCommandOn() { + return airCleanModeCommandOn; + } + + public void setAirCleanModeCommandOn(String airCleanModeCommandOn) { + this.airCleanModeCommandOn = airCleanModeCommandOn; + } + + public String getAirCleanModeCommandOff() { + return airCleanModeCommandOff; + } + + public void setAirCleanModeCommandOff(String airCleanModeCommandOff) { + this.airCleanModeCommandOff = airCleanModeCommandOff; + } + + public boolean isEnergyMonitorAvailable() { + return isEnergyMonitorAvailable; + } + + public void setEnergyMonitorAvailable(boolean energyMonitorAvailable) { + isEnergyMonitorAvailable = energyMonitorAvailable; + } + + public boolean isFilterMonitorAvailable() { + return isFilterMonitorAvailable; + } + + public void setFilterMonitorAvailable(boolean filterMonitorAvailable) { + isFilterMonitorAvailable = filterMonitorAvailable; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV1.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV1.java new file mode 100644 index 00000000000..0dc553a2529 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV1.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link ACCapabilityFactoryV1} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class ACCapabilityFactoryV1 extends AbstractACCapabilityFactory { + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V1_0); + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + return Collections.emptyMap(); + } + + @Override + protected Map extractFeatureOptions(JsonNode optionsNode) { + Map options = new HashMap<>(); + optionsNode.fields().forEachRemaining(o -> { + options.put(o.getKey(), o.getValue().asText()); + }); + return options; + } + + @Override + public ACCapability create(JsonNode rootNode) throws LGThinqException { + ACCapability cap = super.create(rootNode); + // set energy and filter availability (extended info) + cap.setEnergyMonitorAvailable( + !rootNode.path("ControlWifi").path("action").path("GetInOutInstantPower").isMissingNode()); + cap.setFilterMonitorAvailable( + !rootNode.path("ControlWifi").path("action").path("GetFilterUse").isMissingNode()); + return cap; + } + + @Override + protected String getDataTypeFeatureNodeName() { + return "type"; + } + + @Override + protected String getOpModeNodeName() { + return "OpMode"; + } + + @Override + protected String getFanSpeedNodeName() { + return "WindStrength"; + } + + @Override + protected String getSupOpModeNodeName() { + return "SupportOpMode"; + } + + @Override + protected String getSupFanSpeedNodeName() { + return "SupportWindStrength"; + } + + @Override + protected String getJetModeNodeName() { + return "Jet"; + } + + @Override + protected String getStepUpDownNodeName() { + return "WDirVStep"; + } + + @Override + protected String getStepLeftRightNodeName() { + return "WDirHStep"; + } + + @Override + protected String getSupSubRacModeNodeName() { + return "SupportRACSubMode"; + } + + @Override + protected String getSupRacModeNodeName() { + return "SupportRACMode"; + } + + @Override + protected String getAutoDryStateNodeName() { + return "AutoDry"; + } + + @Override + protected String getAirCleanStateNodeName() { + return "AirClean"; + } + + @Override + protected String getOptionsMapNodeName() { + return "option"; + } + + @Override + protected String getValuesNodeName() { + return "Value"; + } + + @Override + protected String getHpAirWaterSwitchNodeName() { + throw new UnsupportedOperationException("Heat Pump Thinq V1 not implemented yet! Ignoring node"); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV2.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV2.java new file mode 100644 index 00000000000..2c16252103d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACCapabilityFactoryV2.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link ACCapabilityFactoryV2} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class ACCapabilityFactoryV2 extends AbstractACCapabilityFactory { + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V2_0); + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + Map result = new HashMap<>(); + JsonNode controlDeviceNode = rootNode.path("ControlDevice"); + if (controlDeviceNode.isArray()) { + controlDeviceNode.forEach(c -> { + String ctrlKey = c.path("ctrlKey").asText(); + // commands variations are described separated by pipe "|" + String[] commands = c.path("command").asText().split("\\|"); + String dataValues = c.path("dataValue").asText(); + for (String cmd : commands) { + CommandDefinition cd = new CommandDefinition(); + cd.setCommand(cmd); + cd.setCmdOptValue(dataValues.replaceAll("[{%}]", "")); + cd.setRawCommand(c.toPrettyString()); + result.put(ctrlKey, cd); + } + }); + } + return result; + } + + @Override + protected String getOpModeNodeName() { + return "airState.opMode"; + } + + @Override + protected String getFanSpeedNodeName() { + return "airState.windStrength"; + } + + @Override + protected String getSupOpModeNodeName() { + return "support.airState.opMode"; + } + + @Override + protected String getSupFanSpeedNodeName() { + return "support.airState.windStrength"; + } + + @Override + protected String getJetModeNodeName() { + return "airState.wMode.jet"; + } + + @Override + protected String getStepUpDownNodeName() { + return "airState.wDir.vStep"; + } + + @Override + protected String getStepLeftRightNodeName() { + return "airState.wDir.hStep"; + } + + @Override + protected String getSupSubRacModeNodeName() { + return "support.racSubMode"; + } + + @Override + protected String getSupRacModeNodeName() { + return "support.racMode"; + } + + @Override + protected String getAutoDryStateNodeName() { + return "airState.miscFuncState.autoDry"; + } + + @Override + protected String getAirCleanStateNodeName() { + return "airState.wMode.airClean"; + } + + @Override + protected String getOptionsMapNodeName() { + return "value_mapping"; + } + + @Override + protected String getValuesNodeName() { + return "Value"; + } + + @Override + protected String getDataTypeFeatureNodeName() { + return "dataType"; + } + + @Override + protected Map extractFeatureOptions(JsonNode optionsNode) { + Map options = new HashMap<>(); + optionsNode.fields().forEachRemaining(o -> { + options.put(o.getKey(), o.getValue().path("label").asText()); + }); + return options; + } + + @Override + protected String getHpAirWaterSwitchNodeName() { + return "airState.miscFuncState.awhpTempSwitch"; + } + + @Override + public ACCapability create(JsonNode rootNode) throws LGThinqException { + ACCapability cap = super.create(rootNode); + Map cmd = getCommandsDefinition(rootNode); + // set energy and filter availability (extended info) + cap.setEnergyMonitorAvailable(cmd.containsKey("energyStateCtrl")); + cap.setFilterMonitorAvailable(cmd.containsKey("filterMngStateCtrl")); + return cap; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACFanSpeed.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACFanSpeed.java new file mode 100644 index 00000000000..6b50d0c510a --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACFanSpeed.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ACCanonicalSnapshot} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum ACFanSpeed { + F1(2.0), + F2(3.0), + F3(4.0), + F4(5.0), + F5(6.0), + F_AUTO(8.0), + F_UNK(-1); + + double speed; + + ACFanSpeed(double v) { + speed = v; + } + + public static ACFanSpeed statusOf(double value) { + return switch ((int) value) { + case 2 -> F1; + case 3 -> F2; + case 4 -> F3; + case 5 -> F4; + case 6 -> F5; + case 8 -> F_AUTO; + default -> F_UNK; + }; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACOpMode.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACOpMode.java new file mode 100644 index 00000000000..b919f8b1b2f --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACOpMode.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ACCanonicalSnapshot} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum ACOpMode { + COOL(0), + DRY(1), + FAN(2), + AI(3), + HEAT(4), + AIRCLEAN(5), + ENSAV(8), + OP_UNK(-1); + + private final int opMode; + + ACOpMode(int v) { + this.opMode = v; + } + + public static ACOpMode statusOf(int value) { + switch (value) { + case 0: + return COOL; + case 1: + return DRY; + case 2: + return FAN; + case 3: + return AI; + case 4: + return HEAT; + case 5: + return AIRCLEAN; + case 8: + return ENSAV; + default: + return OP_UNK; + } + } + + public int getValue() { + return this.opMode; + } + + /** + * Value of command (not state, but command to change the state of device) + * + * @return value of the command to reach the state + */ + public int commandValue() { + switch (this) { + case COOL: + return 0;// "@AC_MAIN_OPERATION_MODE_COOL_W" + case DRY: + return 1; // "@AC_MAIN_OPERATION_MODE_DRY_W" + case FAN: + return 2; // "@AC_MAIN_OPERATION_MODE_FAN_W" + case AI: + return 3; // "@AC_MAIN_OPERATION_MODE_AI_W" + case HEAT: + return 4; // "@AC_MAIN_OPERATION_MODE_HEAT_W" + case AIRCLEAN: + return 5; // "@AC_MAIN_OPERATION_MODE_AIRCLEAN_W" + case ENSAV: + return 8; // "AC_MAIN_OPERATION_MODE_ENERGY_SAVING_W" + default: + throw new IllegalArgumentException("Enum not accepted for command:" + this); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACSnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACSnapshotBuilder.java new file mode 100644 index 00000000000..e8fe9b9aa4f --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACSnapshotBuilder.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DefaultSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringBinaryProtocol; + +/** + * The {@link ACSnapshotBuilder} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class ACSnapshotBuilder extends DefaultSnapshotBuilder { + public ACSnapshotBuilder() { + super(ACCanonicalSnapshot.class); + } + + @Override + public ACCanonicalSnapshot createFromBinary(String binaryData, List prot, + CapabilityDefinition capDef) throws LGThinqUnmarshallException, LGThinqApiException { + return super.createFromBinary(binaryData, prot, capDef); + } + + @Override + protected ACCanonicalSnapshot getSnapshot(Map snapMap, CapabilityDefinition capDef) { + ACCanonicalSnapshot snap; + switch (capDef.getDeviceType()) { + case AIR_CONDITIONER: + case HEAT_PUMP: + snap = MAPPER.convertValue(snapMap, snapClass); + snap.setRawData(snapMap); + return snap; + default: + throw new IllegalStateException("Snapshot for device type " + capDef.getDeviceType() + + " not supported for this builder. It is most likely a bug"); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACTargetTmp.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACTargetTmp.java new file mode 100644 index 00000000000..91519676e1b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ACTargetTmp.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ACTargetTmp} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum ACTargetTmp { + _17(17.0), + _18(18.0), + _19(19.0), + _20(20.0), + _21(21.0), + _22(22.0), + _23(23.0), + _24(24.0), + _25(25.0), + _26(26.0), + _27(27.0), + _28(28.0), + _29(29.0), + _30(30.0), + UNK(-1); + + private final double targetTmp; + + ACTargetTmp(double v) { + this.targetTmp = v; + } + + public static ACTargetTmp statusOf(double value) { + switch ((int) value) { + case 17: + return _17; + case 18: + return _18; + case 19: + return _19; + case 20: + return _20; + case 21: + return _21; + case 22: + return _22; + case 23: + return _23; + case 24: + return _24; + case 25: + return _25; + case 26: + return _26; + case 27: + return _27; + case 28: + return _28; + case 29: + return _29; + case 30: + return _30; + default: + return UNK; + } + } + + public double getValue() { + return this.targetTmp; + } + + /** + * Value of command (not state, but command to change the state of device) + * + * @return value of the command to reach the state + */ + public int commandValue() { + return (int) this.targetTmp; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/AbstractACCapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/AbstractACCapabilityFactory.java new file mode 100644 index 00000000000..c69bc54d1d2 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/AbstractACCapabilityFactory.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; +import static org.openhab.binding.lgthinq.lgservices.model.DeviceTypes.HEAT_PUMP; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapabilityFactory; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDataType; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link AbstractACCapabilityFactory} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractACCapabilityFactory extends AbstractCapabilityFactory { + private final Logger logger = LoggerFactory.getLogger(AbstractACCapabilityFactory.class); + + @Override + public final List getSupportedDeviceTypes() { + return List.of(DeviceTypes.AIR_CONDITIONER, HEAT_PUMP); + } + + protected abstract Map extractFeatureOptions(JsonNode optionsNode); + + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + JsonNode featureNode = featuresNode.path(featureName); + if (featureNode.isMissingNode()) { + return FeatureDefinition.NULL_DEFINITION; + } + FeatureDefinition fd = new FeatureDefinition(); + fd.setName(featureName); + fd.setDataType(FeatureDataType.fromValue(featureNode.path(getDataTypeFeatureNodeName()).asText())); + JsonNode options = featureNode.path(getOptionsMapNodeName()); + if (options.isMissingNode()) { + return FeatureDefinition.NULL_DEFINITION; + } + fd.setValuesMapping(extractFeatureOptions(options)); + return fd; + } + + protected abstract String getDataTypeFeatureNodeName(); + + private List extractValueOptions(JsonNode optionsNode) throws LGThinqApiException { + if (optionsNode.isMissingNode()) { + throw new LGThinqApiException("Error extracting options supported by the device"); + } else { + List values = new ArrayList<>(); + optionsNode.fields().forEachRemaining(e -> { + values.add(e.getValue().asText()); + }); + return values; + } + } + + private Map extractOptions(JsonNode optionsNode, boolean invertKeyValue) { + if (optionsNode.isMissingNode()) { + logger.warn("Error extracting options supported by the device"); + return Collections.emptyMap(); + } else { + Map modes = new HashMap(); + optionsNode.fields().forEachRemaining(e -> { + if (invertKeyValue) { + modes.put(e.getValue().asText(), e.getKey()); + } else { + modes.put(e.getKey(), e.getValue().asText()); + } + }); + return modes; + } + } + + @Override + public ACCapability create(JsonNode rootNode) throws LGThinqException { + ACCapability acCap = super.create(rootNode); + + JsonNode valuesNode = rootNode.path(getValuesNodeName()); + if (valuesNode.isMissingNode()) { + throw new LGThinqApiException("Error extracting capabilities supported by the device"); + } + // supported operation modes + Map allOpModes = extractOptions( + valuesNode.path(getOpModeNodeName()).path(getOptionsMapNodeName()), true); + Map allFanSpeeds = extractOptions( + valuesNode.path(getFanSpeedNodeName()).path(getOptionsMapNodeName()), true); + + List supOpModeValues = extractValueOptions( + valuesNode.path(getSupOpModeNodeName()).path(getOptionsMapNodeName())); + List supFanSpeedValues = extractValueOptions( + valuesNode.path(getSupFanSpeedNodeName()).path(getOptionsMapNodeName())); + supOpModeValues.remove("@NON"); + supOpModeValues.remove("@NON"); + // find correct operation IDs + Map opModes = new HashMap<>(supOpModeValues.size()); + supOpModeValues.forEach(v -> { + // discovery ID of the operation + String key = allOpModes.get(v); + if (key != null) { + opModes.put(key, v); + } + }); + acCap.setOpMod(opModes); + + Map fanSpeeds = new HashMap<>(supFanSpeedValues.size()); + supFanSpeedValues.forEach(v -> { + // discovery ID of the fan speed + String key = allFanSpeeds.get(v); + if (key != null) { + fanSpeeds.put(key, v); + } + }); + acCap.setFanSpeed(fanSpeeds); + + // ===== get supported extra modes + + JsonNode supRacSubModeOps = valuesNode.path(getSupSubRacModeNodeName()).path(getOptionsMapNodeName()); + if (!supRacSubModeOps.isMissingNode()) { + supRacSubModeOps.fields().forEachRemaining(f -> { + if (CAP_AC_SUB_MODE_COOL_JET.equals(f.getValue().asText())) { + acCap.setJetModeAvailable(true); + } + if (CAP_AC_SUB_MODE_STEP_UP_DOWN.equals(f.getValue().asText())) { + acCap.setStepUpDownAvailable(true); + } + if (CAP_AC_SUB_MODE_STEP_LEFT_RIGHT.equals(f.getValue().asText())) { + acCap.setStepLeftRightAvailable(true); + } + }); + } + + // set Cool jetMode supportability + if (acCap.isJetModeAvailable()) { + JsonNode jetModeOps = valuesNode.path(getJetModeNodeName()).path(getOptionsMapNodeName()); + if (!jetModeOps.isMissingNode()) { + jetModeOps.fields().forEachRemaining(j -> { + String value = j.getValue().asText(); + if (CAP_AC_COOL_JET.containsKey(value)) { + acCap.setCoolJetModeCommandOn(j.getKey()); + } else if (CAP_AC_COMMAND_OFF.equals(value)) { + acCap.setCoolJetModeCommandOff(j.getKey()); + } + }); + } + } + // ============== Collect Wind Direction (Up-Down, Left-Right) if supported ================== + if (acCap.isStepUpDownAvailable()) { + Map stepUpDownValueMap = extractOptions( + valuesNode.path(getStepUpDownNodeName()).path(getOptionsMapNodeName()), false); + // remove options who value doesn't start with @, that indicates for this feature that is not supported + stepUpDownValueMap.values().removeIf(v -> !v.startsWith("@")); + acCap.setStepUpDown(stepUpDownValueMap); + } + + if (acCap.isStepLeftRightAvailable()) { + Map stepLeftRightValueMap = extractOptions( + valuesNode.path(getStepLeftRightNodeName()).path(getOptionsMapNodeName()), false); + // remove options who value doesn't start with @, that indicates for this feature that is not supported + stepLeftRightValueMap.values().removeIf(v -> !v.startsWith("@")); + acCap.setStepLeftRight(stepLeftRightValueMap); + } + // =================================================== // + + // get Supported RAC Mode + JsonNode supRACModeOps = valuesNode.path(getSupRacModeNodeName()).path(getOptionsMapNodeName()); + + if (!supRACModeOps.isMissingNode()) { + supRACModeOps.fields().forEachRemaining(r -> { + String racOpValue = r.getValue().asText(); + switch (racOpValue) { + case CAP_AC_AUTODRY: + Map dryStates = extractOptions( + valuesNode.path(getAutoDryStateNodeName()).path(getOptionsMapNodeName()), true); + if (!dryStates.isEmpty()) { // sanity check + acCap.setAutoDryModeAvailable(true); + dryStates.forEach((cmdKey, cmdValue) -> { + switch (cmdKey) { + case CAP_AC_COMMAND_OFF: + acCap.setAutoDryModeCommandOff(cmdValue); + break; + case CAP_AC_COMMAND_ON: + acCap.setAutoDryModeCommandOn(cmdValue); + } + }); + } + break; + case CAP_AC_AIRCLEAN: + Map airCleanStates = extractOptions( + valuesNode.path(getAirCleanStateNodeName()).path(getOptionsMapNodeName()), true); + if (!airCleanStates.isEmpty()) { + acCap.setAirCleanAvailable(true); + airCleanStates.forEach((cmdKey, cmdValue) -> { + switch (cmdKey) { + case CAP_AC_AIR_CLEAN_COMMAND_OFF: + acCap.setAirCleanModeCommandOff(cmdValue); + break; + case CAP_AC_AIR_CLEAN_COMMAND_ON: + acCap.setAirCleanModeCommandOn(cmdValue); + } + }); + } + break; + case CAP_AC_ENERGYSAVING: + acCap.setEnergySavingAvailable(true); + // there's no definition for this values. Assuming the defaults + acCap.setEnergySavingModeCommandOff("0"); + acCap.setEnergySavingModeCommandOn("1"); + break; + } + }); + } + if (HEAT_PUMP.equals(acCap.getDeviceType())) { + JsonNode supHpAirSwitchNode = valuesNode.path(getHpAirWaterSwitchNodeName()).path(getOptionsMapNodeName()); + if (!supHpAirSwitchNode.isMissingNode()) { + supHpAirSwitchNode.fields().forEachRemaining(r -> { + r.getValue().asText(); + }); + } + } + + JsonNode infoNode = rootNode.get("Info"); + if (infoNode.isMissingNode()) { + logger.warn("No info session defined in the cap data."); + } else { + // try to find monitoring result format + MonitoringResultFormat format = MonitoringResultFormat.getFormatOf(infoNode.path("model").asText()); + if (!MonitoringResultFormat.UNKNOWN_FORMAT.equals(format)) { + acCap.setMonitoringDataFormat(format); + } + } + return acCap; + } + + protected abstract String getOpModeNodeName(); + + protected abstract String getFanSpeedNodeName(); + + protected abstract String getSupOpModeNodeName(); + + protected abstract String getSupFanSpeedNodeName(); + + protected abstract String getJetModeNodeName(); + + protected abstract String getStepUpDownNodeName(); + + protected abstract String getStepLeftRightNodeName(); + + protected abstract String getSupSubRacModeNodeName(); + + protected abstract String getSupRacModeNodeName(); + + protected abstract String getAutoDryStateNodeName(); + + protected abstract String getAirCleanStateNodeName(); + + protected abstract String getOptionsMapNodeName(); + + @Override + public ACCapability getCapabilityInstance() { + return new ACCapability(); + } + + protected abstract String getValuesNodeName(); + + // ===== For HP only ==== + protected abstract String getHpAirWaterSwitchNodeName(); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ExtendedDeviceInfo.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ExtendedDeviceInfo.java new file mode 100644 index 00000000000..8829aee16b7 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/ac/ExtendedDeviceInfo.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.ac; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link ExtendedDeviceInfo} containing extended information obout the device. In + * AC cases, it holds instant power consumption, filter used in hours and max time to use. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExtendedDeviceInfo { + private String instantPower = ""; + private String filterHoursUsed = ""; + private String filterHoursMax = ""; + + @JsonProperty(CAP_EXTRA_ATTR_FILTER_USED_TIME) + @JsonAlias("airState.filterMngStates.useTime") + public String getFilterHoursUsed() { + return filterHoursUsed; + } + + public void setFilterHoursUsed(String filterHoursUsed) { + this.filterHoursUsed = filterHoursUsed; + } + + @JsonProperty(CAP_EXTRA_ATTR_FILTER_MAX_TIME_TO_USE) + @JsonAlias("airState.filterMngStates.maxTime") + public String getFilterHoursMax() { + return filterHoursMax; + } + + public void setFilterHoursMax(String filterHoursMax) { + this.filterHoursMax = filterHoursMax; + } + + /** + * Returns the instant total power consumption + * + * @return the instant total power consumption + */ + @JsonProperty(CAP_EXTRA_ATTR_INSTANT_POWER) + @JsonAlias("airState.energy.totalCurrent") + public String getRawInstantPower() { + return instantPower; + } + + public void setRawInstantPower(String instantPower) { + this.instantPower = instantPower; + } + + public Double getInstantPower() { + try { + return Double.parseDouble(instantPower); + } catch (NumberFormatException e) { + return 0.0; + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseDefinition.java new file mode 100644 index 00000000000..a6c9fb75246 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseDefinition.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link CourseDefinition} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class CourseDefinition { + private String courseName = ""; + // Name of the course this is based on. It's only used for SmartCourses + private String baseCourseName = ""; + private CourseType courseType = CourseType.UNDEF; + private List functions = new ArrayList<>(); + + public String getCourseName() { + return courseName; + } + + public void setCourseName(String courseName) { + this.courseName = courseName; + } + + public String getBaseCourseName() { + return baseCourseName; + } + + public void setBaseCourseName(String baseCourseName) { + this.baseCourseName = baseCourseName; + } + + public CourseType getCourseType() { + return courseType; + } + + public void setCourseType(CourseType courseType) { + this.courseType = courseType; + } + + public List getFunctions() { + return functions; + } + + public void setFunctions(List functions) { + this.functions = functions; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseFunction.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseFunction.java new file mode 100644 index 00000000000..11939c5425a --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseFunction.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link CourseFunction} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class CourseFunction { + private String value = ""; + private String defaultValue = ""; + private boolean isSelectable; + private List selectableValues = new ArrayList<>(); + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + public boolean isSelectable() { + return isSelectable; + } + + public void setSelectable(boolean selectable) { + isSelectable = selectable; + } + + public List getSelectableValues() { + return selectableValues; + } + + public void setSelectableValues(List selectableValues) { + this.selectableValues = selectableValues; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseType.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseType.java new file mode 100644 index 00000000000..f3d02a94d22 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/CourseType.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link CourseType} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public enum CourseType { + COURSE("Course"), + SMART_COURSE("SmartCourse"), + DOWNLOADED_COURSE("DownloadedCourse"), + UNDEF("Undefined"); + + private final String value; + + CourseType(String s) { + value = s; + } + + public String getValue() { + return value; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/Utils.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/Utils.java new file mode 100644 index 00000000000..56e2cd58b5a --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/Utils.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; + +/** + * The {@link Utils} class defines common methods to handle generic washer devices + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class Utils { + + public static Map getGenericCourseDefinitions(JsonNode courseNode, CourseType type, + String notSelectedCourseKey) { + Map coursesDef = new HashMap<>(); + courseNode.fields().forEachRemaining(e -> { + CourseDefinition cd = new CourseDefinition(); + JsonNode thisCourseNode = e.getValue(); + cd.setCourseName(thisCourseNode.path("_comment").textValue()); + if (CourseType.SMART_COURSE.equals(type)) { + cd.setBaseCourseName(thisCourseNode.path("Course").textValue()); + } + cd.setCourseType(type); + if (thisCourseNode.path("function").isArray()) { + // just to be safe here + ArrayNode functions = (ArrayNode) thisCourseNode.path("function"); + List functionList = cd.getFunctions(); + for (JsonNode fNode : functions) { + // map all course functions here + CourseFunction f = new CourseFunction(); + f.setValue(fNode.path("value").textValue()); + f.setDefaultValue(fNode.path("default").textValue()); + JsonNode selectableNode = fNode.path("selectable"); + // only Courses (not SmartCourses or DownloadedCourses) can have selectable functions + f.setSelectable( + !selectableNode.isMissingNode() && selectableNode.isArray() && (type == CourseType.COURSE)); + if (f.isSelectable()) { + List selectableValues = f.getSelectableValues(); + // map values acceptable for this function + for (JsonNode v : selectableNode) { + if (v.isValueNode()) { + selectableValues.add(v.textValue()); + } + } + f.setSelectableValues(selectableValues); + } + functionList.add(f); + } + cd.setFunctions(functionList); + } + coursesDef.put(e.getKey(), cd); + }); + CourseDefinition cdNotSelected = new CourseDefinition(); + cdNotSelected.setCourseType(type); + cdNotSelected.setCourseName("Not Selected"); + coursesDef.put(notSelectedCourseKey, cdNotSelected); + return coursesDef; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/WasherFeatureDefinition.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/WasherFeatureDefinition.java new file mode 100644 index 00000000000..2f5ec05b9f6 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/commons/washers/WasherFeatureDefinition.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDataType; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The WasherFeatureDefinition + * + * @author nemer (nemer.daud@gmail.com) - Initial contribution + */ +@NonNullByDefault +public class WasherFeatureDefinition { + public static FeatureDefinition getBasicFeatureDefinition(String featureName, JsonNode featureNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + if (featureNode.isMissingNode()) { + return FeatureDefinition.NULL_DEFINITION; + } + FeatureDefinition fd = new FeatureDefinition(); + fd.setName(featureName); + fd.setChannelId(Objects.requireNonNullElse(targetChannelId, "")); + fd.setRefChannelId(Objects.requireNonNullElse(refChannelId, "")); + fd.setLabel(featureName); + return fd; + } + + public static FeatureDefinition setAllValuesMapping(FeatureDefinition fd, JsonNode featureNode) { + fd.setDataType(FeatureDataType.ENUM); + JsonNode valuesMappingNode = featureNode.path("option"); + if (!valuesMappingNode.isMissingNode()) { + Map valuesMapping = new HashMap<>(); + valuesMappingNode.fields().forEachRemaining(e -> { + // collect values as: + // + // "option":{ + // "0":"@WM_STATE_POWER_OFF_W", + // to "0" -> "@WM_STATE_POWER_OFF_W" + valuesMapping.put(e.getKey(), e.getValue().asText()); + }); + fd.setValuesMapping(valuesMapping); + } + + return fd; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/AbstractDishWasherCapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/AbstractDishWasherCapabilityFactory.java new file mode 100644 index 00000000000..03a34ade7de --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/AbstractDishWasherCapabilityFactory.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapabilityFactory; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseType; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.Utils; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link AbstractDishWasherCapabilityFactory} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractDishWasherCapabilityFactory extends AbstractCapabilityFactory { + + protected abstract String getStateFeatureNodeName(); + + protected abstract String getProcessStateNodeName(); + + protected abstract String getDoorLockFeatureNodeName(); + + protected abstract String getConvertingRulesNodeName(); + + protected abstract String getControlConvertingRulesNodeName(); + + @SuppressWarnings("unused") + protected abstract MonitoringResultFormat getMonitorDataFormat(JsonNode rootNode); + + @Override + public DishWasherCapability create(JsonNode rootNode) throws LGThinqException { + DishWasherCapability dwCap = super.create(rootNode); + JsonNode coursesNode = rootNode.path(getCourseNodeName()); + JsonNode smartCoursesNode = rootNode.path(getSmartCourseNodeName()); + if (coursesNode.isMissingNode()) { + throw new LGThinqException("Course node not present in Capability Json Descriptor"); + } + + Map courses = new HashMap<>(getCourseDefinitions(coursesNode)); + Map smartCourses = new HashMap<>(getSmartCourseDefinitions(smartCoursesNode)); + Map convertedAllCourses = new HashMap<>(); + // change the Key to the reverse MapCourses coming from LG API + BiConsumer, JsonNode> convertCoursesRules = (courseMap, node) -> { + node.fields().forEachRemaining(e -> { + CourseDefinition df = courseMap.get(e.getKey()); + if (df != null) { + convertedAllCourses.put(e.getValue().asText(), df); + } + }); + }; + JsonNode controlConvertingRules = rootNode.path(getConvertingRulesNodeName()).path(getCourseNodeName()) + .path(getControlConvertingRulesNodeName()); + if (!controlConvertingRules.isMissingNode()) { + convertCoursesRules.accept(courses, controlConvertingRules); + } + controlConvertingRules = rootNode.path(getConvertingRulesNodeName()).path(getSmartCourseNodeName()) + .path(getControlConvertingRulesNodeName()); + if (!controlConvertingRules.isMissingNode()) { + convertCoursesRules.accept(smartCourses, controlConvertingRules); + } + + dwCap.setCourses(convertedAllCourses); + + JsonNode monitorValueNode = rootNode.path(getMonitorValueNodeName()); + if (monitorValueNode.isMissingNode()) { + throw new LGThinqException("MonitoringValue node not found in the V2 WashingDryer cap definition."); + } + // mapping possible states + dwCap.setState(newFeatureDefinition(getStateFeatureNodeName(), monitorValueNode)); + dwCap.setProcessState(newFeatureDefinition(getProcessStateNodeName(), monitorValueNode)); + dwCap.setDoorStateFeat(newFeatureDefinition(getDoorLockFeatureNodeName(), monitorValueNode)); + dwCap.setMonitoringDataFormat(getMonitorDataFormat(rootNode)); + return dwCap; + } + + protected Map getCourseDefinitions(JsonNode courseNode) { + return Utils.getGenericCourseDefinitions(courseNode, CourseType.COURSE, getNotSelectedCourseKey()); + } + + protected Map getSmartCourseDefinitions(JsonNode smartCourseNode) { + return Utils.getGenericCourseDefinitions(smartCourseNode, CourseType.SMART_COURSE, getNotSelectedCourseKey()); + } + + protected abstract String getNotSelectedCourseKey(); + + @Override + public final List getSupportedDeviceTypes() { + return List.of(DeviceTypes.DISH_WASHER); + } + + protected abstract String getCourseNodeName(); + + protected abstract String getSmartCourseNodeName(); + + protected abstract String getMonitorValueNodeName(); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapability.java new file mode 100644 index 00000000000..4bf7725a9a5 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapability.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapability; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseDefinition; + +/** + * The {@link DishWasherCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class DishWasherCapability extends AbstractCapability { + private FeatureDefinition doorState = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition state = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition processState = FeatureDefinition.NULL_DEFINITION; + private Map courses = new LinkedHashMap<>(); + + public FeatureDefinition getProcessState() { + return processState; + } + + public void setProcessState(FeatureDefinition processState) { + this.processState = processState; + } + + public Map getCourses() { + return courses; + } + + public void setCourses(Map courses) { + this.courses = courses; + } + + public FeatureDefinition getStateFeat() { + return state; + } + + public FeatureDefinition getDoorStateFeat() { + return doorState; + } + + public void setDoorStateFeat(FeatureDefinition doorState) { + this.doorState = doorState; + } + + public void setState(FeatureDefinition state) { + this.state = state; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapabilityFactoryV2.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapabilityFactoryV2.java new file mode 100644 index 00000000000..c25d92830f4 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherCapabilityFactoryV2.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.WasherFeatureDefinition; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link DishWasherCapabilityFactoryV2} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class DishWasherCapabilityFactoryV2 extends AbstractDishWasherCapabilityFactory { + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V2_0); + } + + protected String getDoorLockFeatureNodeName() { + return "Door"; + } + + @Override + protected String getConvertingRulesNodeName() { + return "ConvertingRule"; + } + + @Override + protected String getControlConvertingRulesNodeName() { + return "ControlConvertingRule"; + } + + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + JsonNode featureNode = featuresNode.path(featureName); + + FeatureDefinition fd; + if ((fd = WasherFeatureDefinition.getBasicFeatureDefinition(featureName, featureNode, targetChannelId, + refChannelId)) == FeatureDefinition.NULL_DEFINITION) { + return fd; + } + // all features from V2 are enums + return WasherFeatureDefinition.setAllValuesMapping(fd, featureNode); + } + + @Override + public DishWasherCapability getCapabilityInstance() { + return new DishWasherCapability(); + } + + @Override + protected String getCourseNodeName() { + return "Course"; + } + + @Override + protected String getSmartCourseNodeName() { + return "SmartCourse"; + } + + @Override + protected String getStateFeatureNodeName() { + return "State"; + } + + @Override + protected String getProcessStateNodeName() { + return "Process"; + } + + @Override + protected MonitoringResultFormat getMonitorDataFormat(JsonNode rootNode) { + // All v2 are Json format + return MonitoringResultFormat.JSON_FORMAT; + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + return Collections.emptyMap(); + } + + @Override + protected String getNotSelectedCourseKey() { + return "NOT_SELECTED"; + } + + @Override + protected String getMonitorValueNodeName() { + return "Value"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshot.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshot.java new file mode 100644 index 00000000000..04a06f2a4f2 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshot.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link DishWasherSnapshot} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@JsonIgnoreProperties(ignoreUnknown = true) +public class DishWasherSnapshot extends AbstractSnapshotDefinition { + private DevicePowerState powerState = DevicePowerState.DV_POWER_UNK; + private String state = ""; + private String processState = ""; + private boolean online; + private String course = ""; + private String smartCourse = ""; + private String doorLock = ""; + private Double remainingHour = 0.00; + private Double remainingMinute = 0.00; + private Double reserveHour = 0.00; + private Double reserveMinute = 0.00; + + @JsonAlias({ "Course" }) + @JsonProperty("course") + public String getCourse() { + return course; + } + + public void setCourse(String course) { + this.course = course; + } + + @JsonProperty("process") + @JsonAlias({ "Process" }) + public String getProcessState() { + return processState; + } + + public void setProcessState(String processState) { + this.processState = processState; + } + + @Override + public DevicePowerState getPowerStatus() { + return powerState; + } + + @Override + public void setPowerStatus(DevicePowerState value) { + this.powerState = value; + } + + @Override + public boolean isOnline() { + return online; + } + + @Override + public void setOnline(boolean online) { + this.online = online; + } + + @JsonProperty("state") + @JsonAlias({ "State" }) + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + if (state.equals(DW_POWER_OFF_VALUE)) { + powerState = DevicePowerState.DV_POWER_OFF; + } else { + powerState = DevicePowerState.DV_POWER_ON; + } + } + + @JsonProperty("smartCourse") + @JsonAlias({ "SmartCourse" }) + public String getSmartCourse() { + return smartCourse; + } + + public void setSmartCourse(String smartCourse) { + this.smartCourse = smartCourse; + } + + @JsonIgnore + public String getRemainingTime() { + return String.format("%02.0f:%02.0f", getRemainingHour(), getRemainingMinute()); + } + + @JsonIgnore + public String getReserveTime() { + return String.format("%02.0f:%02.0f", getReserveHour(), getReserveMinute()); + } + + @JsonProperty("remainTimeHour") + @JsonAlias({ "Remain_Time_H" }) + public Double getRemainingHour() { + return remainingHour; + } + + public void setRemainingHour(Double remainingHour) { + this.remainingHour = remainingHour; + } + + @JsonProperty("remainTimeMinute") + @JsonAlias({ "Remain_Time_M" }) + public Double getRemainingMinute() { + // Issue in some DW when the remainingMinute stay in 1 after complete in some cases + return DW_STATE_COMPLETE.equals(getState()) ? 0.0 : remainingMinute; + } + + public void setRemainingMinute(Double remainingMinute) { + this.remainingMinute = remainingMinute; + } + + @JsonProperty("reserveTimeHour") + @JsonAlias({ "Reserve_Time_H" }) + public Double getReserveHour() { + return reserveHour; + } + + public void setReserveHour(Double reserveHour) { + this.reserveHour = reserveHour; + } + + @JsonProperty("reserveTimeMinute") + @JsonAlias({ "Reserve_Time_M" }) + public Double getReserveMinute() { + return reserveMinute; + } + + public void setReserveMinute(Double reserveMinute) { + this.reserveMinute = reserveMinute; + } + + @JsonProperty("door") + @JsonAlias({ "Door" }) + public String getDoorLock() { + return doorLock; + } + + public void setDoorLock(String doorLock) { + this.doorLock = doorLock; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshotBuilder.java new file mode 100644 index 00000000000..a32addf0bb1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/dishwasher/DishWasherSnapshotBuilder.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.dishwasher; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.DW_SNAPSHOT_WASHER_DRYER_NODE_V2; + +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DefaultSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * The {@link DishWasherSnapshotBuilder} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class DishWasherSnapshotBuilder extends DefaultSnapshotBuilder { + public DishWasherSnapshotBuilder() { + super(DishWasherSnapshot.class); + } + + @Override + protected DishWasherSnapshot getSnapshot(Map snapMap, CapabilityDefinition capDef) { + DishWasherSnapshot snap; + DeviceTypes type = capDef.getDeviceType(); + LGAPIVerion version = capDef.getDeviceVersion(); + if (!type.equals(DeviceTypes.DISH_WASHER)) { + throw new IllegalArgumentException( + String.format("Device Type %s not supported by this builder. It's most likely a bug.", type)); + } + switch (version) { + case V1_0: + throw new IllegalArgumentException("Version 1.0 for DishWasher is not supported yet."); + case V2_0: + Map dishWasher = Objects.requireNonNull( + MAPPER.convertValue(snapMap.get(DW_SNAPSHOT_WASHER_DRYER_NODE_V2), new TypeReference<>() { + }), "dishwasher node must be present in the snapshot"); + snap = MAPPER.convertValue(dishWasher, snapClass); + snap.setRawData(dishWasher); + return snap; + default: + throw new IllegalArgumentException( + String.format("Version %s for DishWasher is not supported.", version)); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeCapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeCapabilityFactory.java new file mode 100644 index 00000000000..e57510d4857 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeCapabilityFactory.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapabilityFactory; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link AbstractFridgeCapabilityFactory} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractFridgeCapabilityFactory extends AbstractCapabilityFactory { + private final Logger logger = LoggerFactory.getLogger(AbstractFridgeCapabilityFactory.class); + + protected abstract void loadTempNode(JsonNode tempNode, Map capMap); + + private Map convertCelsius2Fahrenheit(Map celcius) { + Map fMap = new HashMap<>(); + celcius.forEach((k, v) -> { + int c = Integer.parseInt(v); + fMap.put(k, String.valueOf((c * 9 / 5) + 32)); + }); + return fMap; + } + + @Override + public FridgeCapability create(JsonNode rootNode) throws LGThinqException { + FridgeCapability frCap = super.create(rootNode); + JsonNode node = mapper.valueToTree(rootNode); + if (node.isNull()) { + logger.debug("Can't parse json capability for Fridge. The payload has been ignored. Payload:{}", rootNode); + throw new LGThinqException("Can't parse json capability for Fridge. The payload has been ignored"); + } + + JsonNode fridgeTempCNode = node.path(getMonitorValueNodeName()).path(getFridgeTempCNodeName()) + .path(getOptionsNodeName()); + // version 1.4 of refrigerators thinq1 doesn't contain temp. segregated in C and F. + if (fridgeTempCNode.isMissingNode()) { + fridgeTempCNode = node.path(getMonitorValueNodeName()).path(getFridgeTempNodeName()) + .path(getOptionsNodeName()); + } + JsonNode fridgeTempFNode = node.path(getMonitorValueNodeName()).path(getFridgeTempFNodeName()) + .path(getOptionsNodeName()); + JsonNode freezerTempCNode = node.path(getMonitorValueNodeName()).path(getFreezerTempCNodeName()) + .path(getOptionsNodeName()); + if (freezerTempCNode.isMissingNode()) { + freezerTempCNode = node.path(getMonitorValueNodeName()).path(getFreezerTempNodeName()) + .path(getOptionsNodeName()); + } + JsonNode freezerTempFNode = node.path(getMonitorValueNodeName()).path(getFreezerTempFNodeName()) + .path(getOptionsNodeName()); + JsonNode tempUnitNode = node.path(getMonitorValueNodeName()).path(getTempUnitNodeName()) + .path(getOptionsNodeName()); + JsonNode icePlusNode = node.path(getMonitorValueNodeName()).path(getIcePlusNodeName()) + .path(getOptionsNodeName()); + JsonNode freshAirFilterNode = node.path(getMonitorValueNodeName()).path(getFreshAirFilterNodeName()) + .path(getOptionsNodeName()); + JsonNode waterFilterNode = node.path(getMonitorValueNodeName()).path(getWaterFilterNodeName()) + .path(getOptionsNodeName()); + JsonNode expressModeNode = node.path(getMonitorValueNodeName()).path(getExpressModeNodeName()) + .path(getOptionsNodeName()); + JsonNode smartSavingModeNode = node.path(getMonitorValueNodeName()).path(getSmartSavingModeNodeName()) + .path(getOptionsNodeName()); + JsonNode activeSavingNode = node.path(getMonitorValueNodeName()).path(getActiveSavingNodeName()) + .path(getOptionsNodeName()); + JsonNode atLeastOneDoorOpenNode = node.path(getMonitorValueNodeName()).path(getAtLeastOneDoorOpenNodeName()) + .path(getOptionsNodeName()); + if (!node.path(getMonitorValueNodeName()).path(getExpressCoolNodeName()).isMissingNode()) { + frCap.setExpressCoolModePresent(true); + } + if (!node.path(getMonitorValueNodeName()).path(getEcoFriendlyNodeName()).isMissingNode()) { + frCap.setEcoFriendlyModePresent(true); + } + loadTempNode(fridgeTempCNode, frCap.getFridgeTempCMap()); + if (fridgeTempFNode.isMissingNode()) { + frCap.getFridgeTempFMap().putAll(convertCelsius2Fahrenheit(frCap.getFridgeTempCMap())); + } else { + loadTempNode(fridgeTempFNode, frCap.getFridgeTempFMap()); + } + loadTempNode(freezerTempCNode, frCap.getFreezerTempCMap()); + if (freezerTempFNode.isMissingNode()) { + frCap.getFreezerTempFMap().putAll(convertCelsius2Fahrenheit(frCap.getFreezerTempCMap())); + } else { + loadTempNode(freezerTempFNode, frCap.getFreezerTempFMap()); + } + loadTempUnitNode(tempUnitNode, frCap.getTempUnitMap()); + loadIcePlus(icePlusNode, frCap.getIcePlusMap()); + loadFreshAirFilter(freshAirFilterNode, frCap.getFreshAirFilterMap()); + loadWaterFilter(waterFilterNode, frCap.getWaterFilterMap()); + loadExpressFreezeMode(expressModeNode, frCap.getExpressFreezeModeMap()); + loadSmartSavingMode(smartSavingModeNode, frCap.getSmartSavingMap()); + loadActiveSaving(activeSavingNode, frCap.getActiveSavingMap()); + loadAtLeastOneDoorOpen(atLeastOneDoorOpenNode, frCap.getAtLeastOneDoorOpenMap()); + + frCap.getCommandsDefinition().putAll(getCommandsDefinition(node)); + return frCap; + } + + protected abstract void loadTempUnitNode(JsonNode tempUnitNode, Map tempUnitMap); + + protected abstract void loadIcePlus(JsonNode icePlusNode, Map icePlusMap); + + protected abstract void loadFreshAirFilter(JsonNode freshAirFilterNode, Map freshAirFilterMap); + + protected abstract void loadWaterFilter(JsonNode waterFilterNode, Map waterFilterMap); + + protected abstract void loadExpressFreezeMode(JsonNode expressFreezeModeNode, + Map expressFreezeModeMap); + + protected abstract void loadSmartSavingMode(JsonNode smartSavingModeNode, Map smartSavingModeMap); + + protected abstract void loadActiveSaving(JsonNode activeSavingNode, Map activeSavingMap); + + protected abstract void loadAtLeastOneDoorOpen(JsonNode atLeastOneDoorOpenNode, + Map atLeastOneDoorOpenMap); + + @Override + protected List getSupportedDeviceTypes() { + return List.of(DeviceTypes.FRIDGE); + } + + protected abstract String getMonitorValueNodeName(); + + protected abstract String getFridgeTempCNodeName(); + + protected abstract String getFridgeTempNodeName(); + + protected abstract String getFridgeTempFNodeName(); + + protected abstract String getFreezerTempCNodeName(); + + protected abstract String getFreezerTempNodeName(); + + protected abstract String getFreezerTempFNodeName(); + + protected abstract String getTempUnitNodeName(); + + protected abstract String getIcePlusNodeName(); + + protected abstract String getFreshAirFilterNodeName(); + + protected abstract String getWaterFilterNodeName(); + + protected abstract String getExpressModeNodeName(); + + protected abstract String getSmartSavingModeNodeName(); + + protected abstract String getActiveSavingNodeName(); + + protected abstract String getAtLeastOneDoorOpenNodeName(); + + protected abstract String getExpressCoolNodeName(); + + protected abstract String getOptionsNodeName(); + + protected abstract String getEcoFriendlyNodeName(); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeSnapshot.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeSnapshot.java new file mode 100644 index 00000000000..7bae7a1fbf1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/AbstractFridgeSnapshot.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; + +/** + * The {@link AbstractFridgeSnapshot} + * + * @author Nemer Daud - Initial contribution + * @author Arne Seime - Complementary sensors + */ +@NonNullByDefault +public abstract class AbstractFridgeSnapshot extends AbstractSnapshotDefinition { + public abstract String getTempUnit(); + + public abstract String getFridgeStrTemp(); + + public abstract String getFreezerStrTemp(); + + public abstract String getDoorStatus(); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalCapability.java new file mode 100644 index 00000000000..f09eb378255 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalCapability.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapability; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; + +/** + * The {@link FridgeCanonicalCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class FridgeCanonicalCapability extends AbstractCapability + implements FridgeCapability { + + private final Map fridgeTempCMap = new LinkedHashMap(); + private final Map fridgeTempFMap = new LinkedHashMap(); + private final Map freezerTempCMap = new LinkedHashMap(); + private final Map freezerTempFMap = new LinkedHashMap(); + private final Map tempUnitMap = new LinkedHashMap(); + private final Map icePlusMap = new LinkedHashMap(); + private final Map freshAirFilterMap = new LinkedHashMap(); + private final Map waterFilterMap = new LinkedHashMap(); + private final Map expressFreezeModeMap = new LinkedHashMap(); + private final Map smartSavingMap = new LinkedHashMap(); + private final Map activeSavingMap = new LinkedHashMap(); + private final Map atLeastOneDoorOpenMap = new LinkedHashMap<>(); + private final Map commandsDefinition = new LinkedHashMap<>(); + private boolean isExpressCoolModePresent = false; + private boolean isEcoFriendlyModePresent = false; + + @Override + public boolean isEcoFriendlyModePresent() { + return isEcoFriendlyModePresent; + } + + @Override + public void setEcoFriendlyModePresent(boolean isEcoFriendlyModePresent) { + this.isEcoFriendlyModePresent = isEcoFriendlyModePresent; + } + + public Map getFridgeTempCMap() { + return fridgeTempCMap; + } + + public Map getFridgeTempFMap() { + return fridgeTempFMap; + } + + public Map getFreezerTempCMap() { + return freezerTempCMap; + } + + public Map getFreezerTempFMap() { + return freezerTempFMap; + } + + @Override + public Map getTempUnitMap() { + return tempUnitMap; + } + + @Override + public Map getIcePlusMap() { + return icePlusMap; + } + + @Override + public Map getFreshAirFilterMap() { + return freshAirFilterMap; + } + + @Override + public Map getWaterFilterMap() { + return waterFilterMap; + } + + @Override + public Map getExpressFreezeModeMap() { + return expressFreezeModeMap; + } + + @Override + public Map getSmartSavingMap() { + return smartSavingMap; + } + + @Override + public Map getActiveSavingMap() { + return activeSavingMap; + } + + @Override + public Map getAtLeastOneDoorOpenMap() { + return atLeastOneDoorOpenMap; + } + + public Map getCommandsDefinition() { + return commandsDefinition; + } + + @Override + public boolean isExpressCoolModePresent() { + return isExpressCoolModePresent; + } + + public void setExpressCoolModePresent(boolean expressCoolModePresent) { + isExpressCoolModePresent = expressCoolModePresent; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalSnapshot.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalSnapshot.java new file mode 100644 index 00000000000..7f987db9454 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCanonicalSnapshot.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link FridgeCanonicalSnapshot} + * This map the snapshot result from Washing Machine devices + * This json payload come with path: snapshot->fridge, but this POJO expects + * to map field below washerDryer + * + * @author Nemer Daud - Initial contribution + * @author Arne Seime - Complementary sensors + */ +@NonNullByDefault +@JsonIgnoreProperties(ignoreUnknown = true) +public class FridgeCanonicalSnapshot extends AbstractFridgeSnapshot { + + private boolean online; + private Double fridgeTemp = FRIDGE_TEMPERATURE_IGNORE_VALUE; + private Double freezerTemp = FREEZER_TEMPERATURE_IGNORE_VALUE; + private String tempUnit = RE_TEMP_UNIT_CELSIUS; // celsius as default + + private String doorStatus = ""; + private String waterFilterUsedMonth = ""; + private String freshAirFilterState = ""; + + private String expressMode = ""; + private String expressCoolMode = ""; + private String ecoFriendlyMode = ""; + + @JsonProperty("ecoFriendly") + public String getEcoFriendlyMode() { + return ecoFriendlyMode; + } + + public void setEcoFriendlyMode(String ecoFriendlyMode) { + this.ecoFriendlyMode = ecoFriendlyMode; + } + + @JsonProperty("expressMode") + public String getExpressMode() { + return expressMode; + } + + public void setExpressMode(String expressMode) { + this.expressMode = expressMode; + } + + @JsonProperty("atLeastOneDoorOpen") + @JsonAlias("DoorOpenState") + public String getDoorStatus() { + return doorStatus; + } + + public void setDoorStatus(String doorStatus) { + this.doorStatus = doorStatus; + } + + @Override + @JsonAlias({ "TempUnit" }) + @JsonProperty("tempUnit") + public String getTempUnit() { + return tempUnit; + } + + public void setTempUnit(String tempUnit) { + this.tempUnit = tempUnit; + } + + private String getStrTempWithUnit(Double temp) { + return temp.intValue() + (RE_TEMP_UNIT_CELSIUS.equals(tempUnit) ? " " + RE_TEMP_UNIT_CELSIUS_SYMBOL + : (RE_TEMP_UNIT_FAHRENHEIT).equals(tempUnit) ? " " + RE_TEMP_UNIT_FAHRENHEIT_SYMBOL : ""); + } + + @Override + @JsonIgnore + public String getFridgeStrTemp() { + return getStrTempWithUnit(getFridgeTemp()); + } + + @Override + @JsonIgnore + public String getFreezerStrTemp() { + return getStrTempWithUnit(getFreezerTemp()); + } + + @JsonAlias({ "TempRefrigerator" }) + @JsonProperty("fridgeTemp") + public Double getFridgeTemp() { + return fridgeTemp; + } + + public void setFridgeTemp(Double fridgeTemp) { + this.fridgeTemp = fridgeTemp; + } + + @JsonAlias({ "TempFreezer" }) + @JsonProperty("freezerTemp") + public Double getFreezerTemp() { + return freezerTemp; + } + + public void setFreezerTemp(Double freezerTemp) { + this.freezerTemp = freezerTemp; + } + + @Override + public DevicePowerState getPowerStatus() { + return isOnline() ? DevicePowerState.DV_POWER_ON : DevicePowerState.DV_POWER_OFF; + } + + @Override + public void setPowerStatus(DevicePowerState value) { + } + + @JsonAlias({ "WaterFilterUsedMonth" }) + @JsonProperty("waterFilter") + public String getWaterFilterUsedMonth() { + return waterFilterUsedMonth; + } + + public void setWaterFilterUsedMonth(String waterFilterUsedMonth) { + this.waterFilterUsedMonth = waterFilterUsedMonth; + } + + @JsonAlias({ "FreshAirFilter" }) + @JsonProperty("freshAirFilter") + public String getFreshAirFilterState() { + return freshAirFilterState; + } + + public void setFreshAirFilterState(String freshAirFilterState) { + this.freshAirFilterState = freshAirFilterState; + } + + @Override + public boolean isOnline() { + return online; + } + + @Override + public void setOnline(boolean online) { + this.online = online; + } + + @JsonProperty("expressFridge") + public String getExpressCoolMode() { + return expressCoolMode; + } + + public void setExpressCoolMode(String expressCoolMode) { + this.expressCoolMode = expressCoolMode; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapability.java new file mode 100644 index 00000000000..43cdaa7073c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapability.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; + +/** + * Represents the capabilities of a fridge device, defining various mappings for temperature control, + * filter status, and additional features. + * This interface extends {@link CapabilityDefinition}. + * + *

+ * It provides access to key mappings for different functionalities and allows checking the presence + * of specific modes. + *

+ * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public interface FridgeCapability extends CapabilityDefinition { + + /** + * Retrieves a mapping of fridge temperature values in Celsius. + * + * @return A {@link Map} where keys are feature names and values are corresponding temperature settings in Celsius. + */ + Map getFridgeTempCMap(); + + /** + * Retrieves a mapping of fridge temperature values in Fahrenheit. + * + * @return A {@link Map} where keys are feature names and values are corresponding temperature settings in + * Fahrenheit. + */ + Map getFridgeTempFMap(); + + /** + * Retrieves a mapping of freezer temperature values in Celsius. + * + * @return A {@link Map} where keys are feature names and values are corresponding temperature settings in Celsius. + */ + Map getFreezerTempCMap(); + + /** + * Retrieves a mapping of freezer temperature values in Fahrenheit. + * + * @return A {@link Map} where keys are feature names and values are corresponding temperature settings in + * Fahrenheit. + */ + Map getFreezerTempFMap(); + + /** + * Retrieves a mapping of temperature unit settings. + * + * @return A {@link Map} where keys are feature names and values indicate the temperature unit used (Celsius or + * Fahrenheit). + */ + Map getTempUnitMap(); + + /** + * Retrieves a mapping related to the Ice Plus feature. + * + * @return A {@link Map} representing Ice Plus settings. + */ + Map getIcePlusMap(); + + /** + * Retrieves a mapping related to the Fresh Air Filter status. + * + * @return A {@link Map} representing Fresh Air Filter status. + */ + Map getFreshAirFilterMap(); + + /** + * Retrieves a mapping related to the Water Filter status. + * + * @return A {@link Map} representing Water Filter status. + */ + Map getWaterFilterMap(); + + /** + * Retrieves a mapping related to the Express Freeze mode. + * + * @return A {@link Map} representing Express Freeze mode settings. + */ + Map getExpressFreezeModeMap(); + + /** + * Retrieves a mapping related to the Smart Saving feature. + * + * @return A {@link Map} representing Smart Saving settings. + */ + Map getSmartSavingMap(); + + /** + * Retrieves a mapping related to the Active Saving feature. + * + * @return A {@link Map} representing Active Saving settings. + */ + Map getActiveSavingMap(); + + /** + * Retrieves a mapping that indicates whether at least one door is open. + * + * @return A {@link Map} representing the door open status. + */ + Map getAtLeastOneDoorOpenMap(); + + /** + * Retrieves a mapping of command definitions available for the fridge. + * + * @return A {@link Map} where keys are command names and values are {@link CommandDefinition} instances. + */ + Map getCommandsDefinition(); + + /** + * Checks whether the Express Cool mode is present. + * + * @return {@code true} if Express Cool mode is available, otherwise {@code false}. + */ + boolean isExpressCoolModePresent(); + + /** + * Sets the presence status of Express Cool mode. + * + * @param isPresent {@code true} if Express Cool mode is available, otherwise {@code false}. + */ + void setExpressCoolModePresent(boolean isPresent); + + /** + * Checks whether the Eco-Friendly mode is present. + * + * @return {@code true} if Eco-Friendly mode is available, otherwise {@code false}. + */ + boolean isEcoFriendlyModePresent(); + + /** + * Sets the presence status of Eco-Friendly mode. + * + * @param isPresent {@code true} if Eco-Friendly mode is available, otherwise {@code false}. + */ + void setEcoFriendlyModePresent(boolean isPresent); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV1.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV1.java new file mode 100644 index 00000000000..6b1c7925327 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV1.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link FridgeCapabilityFactoryV1} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class FridgeCapabilityFactoryV1 extends AbstractFridgeCapabilityFactory { + + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + return FeatureDefinition.NULL_DEFINITION; + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + return getCommandsDefinitionV1(rootNode); + } + + private void loadGenericFeatNode(JsonNode featNode, Map capMap, + final Map constantsMap) { + featNode.fields().forEachRemaining(f -> { + // for each node like ' "1": {"index" : 1, "label" : "7", "_comment" : ""} ' + String translatedValue = constantsMap.get(f.getValue().asText()); + translatedValue = translatedValue == null ? f.getValue().asText() : translatedValue; + capMap.put(f.getKey(), translatedValue); + }); + } + + protected void loadTempNode(JsonNode tempNode, Map capMap) { + loadGenericFeatNode(tempNode, capMap, Collections.emptyMap()); + } + + @Override + protected void loadTempUnitNode(JsonNode tempUnitNode, Map tempUnitMap) { + loadGenericFeatNode(tempUnitNode, tempUnitMap, Collections.emptyMap()); + } + + @Override + protected void loadIcePlus(JsonNode icePlusNode, Map icePlusMap) { + loadGenericFeatNode(icePlusNode, icePlusMap, CAP_RE_ON_OFF); + } + + @Override + protected void loadFreshAirFilter(JsonNode freshAirFilterNode, Map freshAirFilterMap) { + loadGenericFeatNode(freshAirFilterNode, freshAirFilterMap, CAP_RE_FRESH_AIR_FILTER_MAP); + } + + @Override + protected void loadWaterFilter(JsonNode waterFilterNode, Map waterFilterMap) { + int minValue = waterFilterNode.path("min").asInt(0); + int maxValue = waterFilterNode.path("max").asInt(6); + for (int i = minValue; i <= maxValue; i++) { + waterFilterMap.put(String.valueOf(i), i + CAP_RE_WATER_FILTER_USED_POSTFIX); + } + } + + @Override + protected void loadExpressFreezeMode(JsonNode expressFreezeModeNode, Map expressFreezeModeMap) { + // not supported + } + + @Override + protected void loadSmartSavingMode(JsonNode smartSavingModeNode, Map smartSavingModeMap) { + loadGenericFeatNode(smartSavingModeNode, smartSavingModeMap, CAP_RE_SMART_SAVING_MODE); + } + + @Override + protected void loadActiveSaving(JsonNode activeSavingNode, Map activeSavingMap) { + int minValue = activeSavingNode.path("min").asInt(0); + int maxValue = activeSavingNode.path("max").asInt(3); + for (int i = minValue; i <= maxValue; i++) { + activeSavingMap.put(String.valueOf(i), String.valueOf(i)); + } + } + + @Override + protected void loadAtLeastOneDoorOpen(JsonNode atLeastOneDoorOpenNode, Map atLeastOneDoorOpenMap) { + loadGenericFeatNode(atLeastOneDoorOpenNode, atLeastOneDoorOpenMap, Collections.emptyMap()); + } + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V1_0); + } + + @Override + public FridgeCapability getCapabilityInstance() { + return new FridgeCanonicalCapability(); + } + + @Override + protected String getMonitorValueNodeName() { + return "Value"; + } + + @Override + protected String getFridgeTempCNodeName() { + return "TempRefrigerator_C"; + } + + protected String getFridgeTempNodeName() { + return "TempRefrigerator"; + } + + @Override + protected String getFridgeTempFNodeName() { + return "TempRefrigerator_F"; + } + + @Override + protected String getFreezerTempCNodeName() { + return "TempFreezer_C"; + } + + protected String getFreezerTempNodeName() { + return "TempFreezer"; + } + + @Override + protected String getFreezerTempFNodeName() { + return "TempFreezer_F"; + } + + @Override + protected String getOptionsNodeName() { + return "option"; + } + + @Override + protected String getEcoFriendlyNodeName() { + return "UNSUPPORTED"; + } + + protected String getTempUnitNodeName() { + return "TempUnit"; + } + + protected String getIcePlusNodeName() { + return "IcePlus"; + } + + @Override + protected String getFreshAirFilterNodeName() { + return "FreshAirFilter"; + } + + @Override + protected String getWaterFilterNodeName() { + return "WaterFilterUsedMonth"; + } + + @Override + protected String getExpressModeNodeName() { + return "UNSUPPORTED"; + } + + @Override + protected String getSmartSavingModeNodeName() { + return "SmartSavingMode"; + } + + @Override + protected String getActiveSavingNodeName() { + return "SmartSavingModeStatus"; + } + + @Override + protected String getAtLeastOneDoorOpenNodeName() { + return "DoorOpenState"; + } + + @Override + protected String getExpressCoolNodeName() { + return "UNSUPPORTED"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV2.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV2.java new file mode 100644 index 00000000000..53c95d14ef1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeCapabilityFactoryV2.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link FridgeCapabilityFactoryV2} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class FridgeCapabilityFactoryV2 extends AbstractFridgeCapabilityFactory { + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + return FeatureDefinition.NULL_DEFINITION; + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + // doesn't meter command definition for V2 + return Collections.emptyMap(); + } + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V2_0); + } + + @Override + public FridgeCapability getCapabilityInstance() { + return new FridgeCanonicalCapability(); + } + + private void loadGenericFeatNode(JsonNode featNode, Map capMap, + final Map constantsMap) { + featNode.fields().forEachRemaining(f -> { + // for each node like ' "1": {"index" : 1, "label" : "7", "_comment" : ""} ' + if (!"IGNORE".equals(f.getKey())) { + String translatedValue = constantsMap.get(f.getValue().path("label").asText()); + translatedValue = translatedValue == null ? f.getValue().path("label").asText() : translatedValue; + capMap.put(f.getKey(), translatedValue); + } + }); + } + + @Override + protected void loadTempNode(JsonNode tempNode, Map capMap) { + loadGenericFeatNode(tempNode, capMap, Collections.emptyMap()); + } + + @Override + protected void loadTempUnitNode(JsonNode tempUnitNode, Map tempUnitMap) { + tempUnitMap.putAll(CAP_RE_TEMP_UNIT_V2_MAP); + } + + @Override + protected void loadIcePlus(JsonNode icePlusNode, Map icePlusMap) { + // not supported + } + + @Override + protected void loadFreshAirFilter(JsonNode freshAirFilterNode, Map freshAirFilterMap) { + loadGenericFeatNode(freshAirFilterNode, freshAirFilterMap, CAP_RE_FRESH_AIR_FILTER_MAP); + } + + @Override + protected void loadWaterFilter(JsonNode waterFilterNode, Map waterFilterMap) { + loadGenericFeatNode(waterFilterNode, waterFilterMap, CAP_RE_WATER_FILTER); + } + + @Override + protected void loadExpressFreezeMode(JsonNode expressFreezeModeNode, Map expressFreezeModeMap) { + loadGenericFeatNode(expressFreezeModeNode, expressFreezeModeMap, CAP_RE_EXPRESS_FREEZE_MODES); + } + + @Override + protected void loadSmartSavingMode(JsonNode smartSavingModeNode, Map smartSavingModeMap) { + loadGenericFeatNode(smartSavingModeNode, smartSavingModeMap, CAP_RE_SMART_SAVING_V2_MODE); + } + + @Override + protected void loadActiveSaving(JsonNode activeSavingNode, Map activeSavingMap) { + loadGenericFeatNode(activeSavingNode, activeSavingMap, CAP_RE_LABEL_ON_OFF); + } + + @Override + protected void loadAtLeastOneDoorOpen(JsonNode atLeastOneDoorOpenNode, Map atLeastOneDoorOpenMap) { + loadGenericFeatNode(atLeastOneDoorOpenNode, atLeastOneDoorOpenMap, CAP_RE_LABEL_CLOSE_OPEN); + } + + @Override + protected String getMonitorValueNodeName() { + return "MonitoringValue"; + } + + @Override + protected String getFridgeTempCNodeName() { + return "fridgeTemp_C"; + } + + protected String getFridgeTempNodeName() { + throw new UnsupportedOperationException( + "Fridge Thinq2 doesn't support FridgeTemp node. It is most likely a bug"); + } + + @Override + protected String getFridgeTempFNodeName() { + return "fridgeTemp_F"; + } + + @Override + protected String getFreezerTempCNodeName() { + return "freezerTemp_C"; + } + + @Override + protected String getFreezerTempNodeName() { + throw new UnsupportedOperationException( + "Fridge Thinq2 doesn't support FreezerTemp node. It is most likely a bug"); + } + + @Override + protected String getFreezerTempFNodeName() { + return "freezerTemp_F"; + } + + protected String getTempUnitNodeName() { + return "tempUnit"; + } + + protected String getIcePlusNodeName() { + return "UNSUPPORTED"; + } + + @Override + protected String getFreshAirFilterNodeName() { + return "freshAirFilter"; + } + + @Override + protected String getWaterFilterNodeName() { + return "waterFilter"; + } + + @Override + protected String getExpressModeNodeName() { + return "expressMode"; + } + + @Override + protected String getSmartSavingModeNodeName() { + return "smartSavingMode"; + } + + @Override + protected String getActiveSavingNodeName() { + return "activeSaving"; + } + + @Override + protected String getAtLeastOneDoorOpenNodeName() { + return "atLeastOneDoorOpen"; + } + + @Override + protected String getExpressCoolNodeName() { + return "expressFridge"; + } + + @Override + protected String getOptionsNodeName() { + return "valueMapping"; + } + + @Override + protected String getEcoFriendlyNodeName() { + return "ecoFriendly"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeSnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeSnapshotBuilder.java new file mode 100644 index 00000000000..6b155e1c17b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/fridge/FridgeSnapshotBuilder.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.fridge; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.RE_SNAPSHOT_NODE_V2; +import static org.openhab.binding.lgthinq.lgservices.model.DeviceTypes.FRIDGE; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DefaultSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringBinaryProtocol; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * The {@link FridgeSnapshotBuilder} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class FridgeSnapshotBuilder extends DefaultSnapshotBuilder { + public FridgeSnapshotBuilder() { + super(FridgeCanonicalSnapshot.class); + } + + @Override + public FridgeCanonicalSnapshot createFromBinary(String binaryData, List prot, + CapabilityDefinition capDef) throws LGThinqUnmarshallException, LGThinqApiException { + return super.createFromBinary(binaryData, prot, capDef); + } + + @Override + protected FridgeCanonicalSnapshot getSnapshot(Map snapMap, CapabilityDefinition capDef) { + FridgeCanonicalSnapshot snap; + if (FRIDGE.equals(capDef.getDeviceType())) { + switch (capDef.getDeviceVersion()) { + case V1_0: + throw new IllegalArgumentException("Version 1.0 for Fridge driver is not supported yet."); + case V2_0: { + Map refMap = Objects.requireNonNull( + MAPPER.convertValue(snapMap.get(RE_SNAPSHOT_NODE_V2), new TypeReference<>() { + }), "washerDryer node must be present in the snapshot"); + snap = MAPPER.convertValue(refMap, snapClass); + snap.setRawData(snapMap); + return snap; + } + default: + throw new IllegalArgumentException("Version informed is not supported for the Fridge driver."); + } + } + + throw new IllegalStateException("Snapshot for device type " + capDef.getDeviceType() + + " not supported for this builder. It is most likely a bug"); + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/AbstractWasherDryerCapabilityFactory.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/AbstractWasherDryerCapabilityFactory.java new file mode 100644 index 00000000000..ac0f2b02d76 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/AbstractWasherDryerCapabilityFactory.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import static org.openhab.binding.lgthinq.internal.LGThinQBindingConstants.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapabilityFactory; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseType; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.Utils; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link AbstractWasherDryerCapabilityFactory} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractWasherDryerCapabilityFactory extends AbstractCapabilityFactory { + + protected abstract String getStateFeatureNodeName(); + + protected abstract String getProcessStateNodeName(); + + protected abstract String getPreStateFeatureNodeName(); + + // --- Selectable features ----- + protected abstract String getRinseFeatureNodeName(); + + protected abstract String getTemperatureFeatureNodeName(); + + protected abstract String getSpinFeatureNodeName(); + + // ------------------------------ + protected abstract String getSoilWashFeatureNodeName(); + + protected abstract String getDoorLockFeatureNodeName(); + + protected abstract MonitoringResultFormat getMonitorDataFormat(JsonNode rootNode); + + protected abstract String getCommandRemoteStartNodeName(); + + protected abstract String getCommandStopNodeName(); + + protected abstract String getCommandWakeUpNodeName(); + + protected abstract String getDefaultCourseIdNodeName(); + + @Override + public WasherDryerCapability create(JsonNode rootNode) throws LGThinqException { + WasherDryerCapability wdCap = super.create(rootNode); + JsonNode coursesNode = rootNode.path(getCourseNodeName(rootNode)); + JsonNode smartCoursesNode = rootNode.path(getSmartCourseNodeName(rootNode)); + if (coursesNode.isMissingNode()) { + throw new LGThinqException("Course node not present in Capability Json Descriptor"); + } + + Map allCourses = new HashMap<>(getCourseDefinitions(coursesNode)); + allCourses.putAll(getSmartCourseDefinitions(smartCoursesNode)); + wdCap.setCourses(allCourses); + + JsonNode monitorValueNode = rootNode.path(getMonitorValueNodeName()); + if (monitorValueNode.isMissingNode()) { + throw new LGThinqException("MonitoringValue node not found in the V2 WashingDryer cap definition."); + } + // mapping possible states + FeatureDefinition fd = newFeatureDefinition(getStateFeatureNodeName(), monitorValueNode); + fd.getValuesMapping().put(WM_LOST_WASHING_STATE_KEY, WM_LOST_WASHING_STATE_VALUE); + wdCap.setState(fd); + wdCap.setProcessState(newFeatureDefinition(getProcessStateNodeName(), monitorValueNode)); + // --- Selectable features ----- + wdCap.setRinseFeat(newFeatureDefinition(getRinseFeatureNodeName(), monitorValueNode, + CHANNEL_WMD_REMOTE_START_RINSE, CHANNEL_WMD_RINSE_ID)); + wdCap.setTemperatureFeat(newFeatureDefinition(getTemperatureFeatureNodeName(), monitorValueNode, + CHANNEL_WMD_REMOTE_START_TEMP, CHANNEL_WMD_TEMP_LEVEL_ID)); + wdCap.setSpinFeat(newFeatureDefinition(getSpinFeatureNodeName(), monitorValueNode, + CHANNEL_WMD_REMOTE_START_SPIN, CHANNEL_WMD_SPIN_ID)); + // ---------------------------- + wdCap.setDryLevel(newFeatureDefinition(getDryLevelNodeName(), monitorValueNode)); + wdCap.setSoilWash(newFeatureDefinition(getSoilWashFeatureNodeName(), monitorValueNode)); + wdCap.setCommandsDefinition(getCommandsDefinition(rootNode)); + // DoorLock feat can be in alone (v2) or inside Options node (v1) + if (monitorValueNode.get(getDoorLockFeatureNodeName()) != null + || hasFeatInOptions(getDoorLockFeatureNodeName(), monitorValueNode)) { + wdCap.setHasDoorLook(true); + } + wdCap.setDefaultCourseFieldName(getConfigCourseType(rootNode)); + wdCap.setDefaultSmartCourseFeatName(getConfigSmartCourseType(rootNode)); + wdCap.setCommandStop(getCommandStopNodeName()); + wdCap.setCommandRemoteStart(getCommandRemoteStartNodeName()); + wdCap.setCommandWakeUp(getCommandWakeUpNodeName()); + // custom feature values map. + wdCap.setFeatureDefinitionMap( + Map.of(getTemperatureFeatureNodeName(), new WasherDryerCapability.TemperatureFeatureFunction(), + getRinseFeatureNodeName(), new WasherDryerCapability.RinseFeatureFunction(), + getSpinFeatureNodeName(), new WasherDryerCapability.SpinFeatureFunction())); + wdCap.setMonitoringDataFormat(getMonitorDataFormat(rootNode)); + wdCap.setDefaultCourseId(rootNode.path("Config").path(getDefaultCourseIdNodeName()).asText()); + return wdCap; + } + + protected abstract boolean hasFeatInOptions(String featName, JsonNode monitoringValueNode); + + protected Map getCourseDefinitions(JsonNode courseNode) { + return Utils.getGenericCourseDefinitions(courseNode, CourseType.COURSE, getNotSelectedCourseKey()); + } + + protected Map getSmartCourseDefinitions(JsonNode smartCourseNode) { + return Utils.getGenericCourseDefinitions(smartCourseNode, CourseType.SMART_COURSE, getNotSelectedCourseKey()); + } + + protected abstract String getDryLevelNodeName(); + + protected abstract String getNotSelectedCourseKey(); + + @Override + public final List getSupportedDeviceTypes() { + return List.of(DeviceTypes.WASHERDRYER_MACHINE, DeviceTypes.DRYER); + } + + protected abstract String getCourseNodeName(JsonNode rootNode); + + protected abstract String getSmartCourseNodeName(JsonNode rootNode); + + protected abstract String getDefaultCourse(JsonNode rootNode); + + protected abstract String getRemoteFeatName(); + + protected abstract String getStandByFeatName(); + + protected abstract String getConfigCourseType(JsonNode rootNode); + + protected abstract String getConfigSmartCourseType(JsonNode rootNote); + + protected abstract String getConfigDownloadCourseType(JsonNode rootNode); + + protected abstract String getMonitorValueNodeName(); +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapability.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapability.java new file mode 100644 index 00000000000..11ec8c7624e --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapability.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractCapability; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.CourseDefinition; + +/** + * The {@link WasherDryerCapability} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class WasherDryerCapability extends AbstractCapability { + private String defaultCourseFieldName = ""; + private String doorLockFeatName = ""; + private String childLockFeatName = ""; + private String defaultSmartCourseFieldName = ""; + private String commandRemoteStart = ""; + private String remoteStartFeatName = ""; + private String commandWakeUp = ""; + private String commandStop = ""; + private String defaultCourseId = ""; + private FeatureDefinition state = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition soilWash = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition spin = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition temperature = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition rinse = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition error = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition dryLevel = FeatureDefinition.NULL_DEFINITION; + private FeatureDefinition processState = FeatureDefinition.NULL_DEFINITION; + private boolean hasDoorLook; + private Map commandsDefinition = new HashMap<>(); + private Map courses = new LinkedHashMap<>(); + + public String getDefaultCourseId() { + return defaultCourseId; + } + + public void setDefaultCourseId(String defaultCourseId) { + this.defaultCourseId = defaultCourseId; + } + + public Map getCommandsDefinition() { + return commandsDefinition; + } + + public void setCommandsDefinition(Map commandsDefinition) { + this.commandsDefinition = commandsDefinition; + } + + public FeatureDefinition getDryLevel() { + return dryLevel; + } + + public void setDryLevel(FeatureDefinition dryLevel) { + this.dryLevel = dryLevel; + } + + public String getCommandStop() { + return commandStop; + } + + public void setCommandStop(String commandStop) { + this.commandStop = commandStop; + } + + public String getCommandRemoteStart() { + return commandRemoteStart; + } + + public void setCommandRemoteStart(String commandRemoteStart) { + this.commandRemoteStart = commandRemoteStart; + } + + public String getCommandWakeUp() { + return commandWakeUp; + } + + public void setCommandWakeUp(String commandWakeUp) { + this.commandWakeUp = commandWakeUp; + } + + public FeatureDefinition getProcessState() { + return processState; + } + + public void setProcessState(FeatureDefinition processState) { + this.processState = processState; + } + + public Map getCourses() { + return courses; + } + + public void setCourses(Map courses) { + this.courses = courses; + } + + public FeatureDefinition getStateFeat() { + return state; + } + + public boolean hasDoorLook() { + return this.hasDoorLook; + } + + public void setHasDoorLook(boolean hasDoorLook) { + this.hasDoorLook = hasDoorLook; + } + + public void setState(FeatureDefinition state) { + this.state = state; + } + + public FeatureDefinition getSoilWash() { + return soilWash; + } + + public void setSoilWash(FeatureDefinition soilWash) { + this.soilWash = soilWash; + } + + public FeatureDefinition getSpinFeat() { + return spin; + } + + public void setSpinFeat(FeatureDefinition spin) { + this.spin = spin; + } + + public FeatureDefinition getTemperatureFeat() { + return temperature; + } + + public void setTemperatureFeat(FeatureDefinition temperature) { + this.temperature = temperature; + } + + public FeatureDefinition getRinseFeat() { + return rinse; + } + + public void setRinseFeat(FeatureDefinition rinse) { + this.rinse = rinse; + } + + public FeatureDefinition getError() { + return error; + } + + public void setError(FeatureDefinition error) { + this.error = error; + } + + public String getDefaultCourseFieldName() { + return defaultCourseFieldName; + } + + public void setDefaultCourseFieldName(String defaultCourseFieldName) { + this.defaultCourseFieldName = defaultCourseFieldName; + } + + public String getDefaultSmartCourseFeatName() { + return defaultSmartCourseFieldName; + } + + public void setDefaultSmartCourseFeatName(String defaultSmartCourseFieldName) { + this.defaultSmartCourseFieldName = defaultSmartCourseFieldName; + } + + public String getRemoteStartFeatName() { + return remoteStartFeatName; + } + + public void setRemoteStartFeatName(String remoteStartFeatName) { + this.remoteStartFeatName = remoteStartFeatName; + } + + public String getChildLockFeatName() { + return childLockFeatName; + } + + public void setChildLockFeatName(String childLockFeatName) { + this.childLockFeatName = childLockFeatName; + } + + public String getDoorLockFeatName() { + return doorLockFeatName; + } + + public void setDoorLockFeatName(String doorLockFeatName) { + this.doorLockFeatName = doorLockFeatName; + } + + static class RinseFeatureFunction implements Function { + @Override + public FeatureDefinition apply(WasherDryerCapability c) { + return c.getRinseFeat(); + } + } + + static class TemperatureFeatureFunction implements Function { + @Override + public FeatureDefinition apply(WasherDryerCapability c) { + return c.getTemperatureFeat(); + } + } + + static class SpinFeatureFunction implements Function { + @Override + public FeatureDefinition apply(WasherDryerCapability c) { + return c.getSpinFeat(); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV1.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV1.java new file mode 100644 index 00000000000..12bda3c95dc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV1.java @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.WasherFeatureDefinition; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The {@link WasherDryerCapabilityFactoryV1} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class WasherDryerCapabilityFactoryV1 extends AbstractWasherDryerCapabilityFactory { + + @Override + public WasherDryerCapability create(JsonNode rootNode) throws LGThinqException { + WasherDryerCapability cap = super.create(rootNode); + cap.setRemoteStartFeatName("RemoteStart"); + cap.setChildLockFeatName("ChildLock"); + cap.setDoorLockFeatName("DoorLock"); + return cap; + } + + @Override + protected boolean hasFeatInOptions(String featName, JsonNode monitoringValueNode) { + for (String optionNode : new String[] { "Option1", "Option2" }) { + JsonNode arrNode = monitoringValueNode.path(optionNode).path("option"); + if (arrNode.isArray()) { + for (JsonNode v : arrNode) { + if (v.asText().equals(featName)) { + return true; + } + } + } + } + return false; + } + + @Override + protected String getStateFeatureNodeName() { + return "State"; + } + + @Override + protected String getProcessStateNodeName() { + return "PreState"; + } + + @Override + protected String getPreStateFeatureNodeName() { + return "PreState"; + } + + @Override + protected String getRinseFeatureNodeName() { + return "RinseOption"; + } + + @Override + protected String getTemperatureFeatureNodeName() { + return "WaterTemp"; + } + + @Override + protected String getSpinFeatureNodeName() { + return "SpinSpeed"; + } + + @Override + protected String getSoilWashFeatureNodeName() { + return "Wash"; + } + + @Override + protected String getDoorLockFeatureNodeName() { + return "DoorLock"; + } + + @Override + protected MonitoringResultFormat getMonitorDataFormat(JsonNode rootNode) { + String type = rootNode.path("Monitoring").path("type").textValue(); + return MonitoringResultFormat.getFormatOf(Objects.requireNonNullElse(type, "")); + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + return getCommandsDefinitionV1(rootNode); + } + + @Override + protected String getCommandRemoteStartNodeName() { + return "OperationStart"; + } + + @Override + protected String getCommandStopNodeName() { + return "OperationStop"; + } + + @Override + protected String getCommandWakeUpNodeName() { + return "OperationWakeUp"; + } + + @Override + protected String getDefaultCourseIdNodeName() { + return "defaultCourseId"; + } + + @Override + protected String getDryLevelNodeName() { + return "DryLevel"; + } + + @Override + protected String getNotSelectedCourseKey() { + return "0"; + } + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V1_0); + } + + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + JsonNode featureNode = featuresNode.path(featureName); + if (featureNode.isMissingNode()) { + return FeatureDefinition.NULL_DEFINITION; + } + FeatureDefinition fd = new FeatureDefinition(); + fd.setName(featureName); + fd.setLabel(featureName); + fd.setChannelId(Objects.requireNonNullElse(targetChannelId, "")); + fd.setRefChannelId(Objects.requireNonNullElse(refChannelId, "")); + // All features from V1 are ENUMs + return WasherFeatureDefinition.setAllValuesMapping(fd, featureNode); + } + + @Override + public WasherDryerCapability getCapabilityInstance() { + return new WasherDryerCapability(); + } + + @Override + /* + * Return the default Course ID. + * OBS:In the V1, the default course points to the ID of the course list that is the default. + */ + protected String getDefaultCourse(JsonNode rootNode) { + return rootNode.path("Config").path("defaultCourseId").textValue(); + } + + @Override + protected String getRemoteFeatName() { + return "RemoteStart"; + } + + @Override + protected String getStandByFeatName() { + return "Standby"; + } + + @Override + protected String getConfigCourseType(JsonNode rootNode) { + if (rootNode.path(getMonitorValueNodeName()).path("APCourse").isMissingNode()) { + return "Course"; + } else { + return "APCourse"; + } + } + + @Override + protected String getCourseNodeName(JsonNode rootNode) { + JsonNode refOptions = rootNode.path(getMonitorValueNodeName()).path(getConfigCourseType(rootNode)) + .path("option"); + if (refOptions.isArray()) { + for (JsonNode node : refOptions) { + return node.asText(); + } + } + return ""; + } + + @Override + protected String getSmartCourseNodeName(JsonNode rootNode) { + return "SmartCourse"; + } + + @Override + protected String getConfigSmartCourseType(JsonNode rootNote) { + return "SmartCourse"; + } + + @Override + protected String getConfigDownloadCourseType(JsonNode rootNode) { + // just to ignore because there is no DownloadCourseType in V1 + return "XXXXXXXXXXX"; + } + + @Override + protected String getMonitorValueNodeName() { + return "Value"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV2.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV2.java new file mode 100644 index 00000000000..77782e9a844 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerCapabilityFactoryV2.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.CommandDefinition; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDataType; +import org.openhab.binding.lgthinq.lgservices.model.FeatureDefinition; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringResultFormat; +import org.openhab.binding.lgthinq.lgservices.model.devices.commons.washers.WasherFeatureDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ValueNode; + +/** + * The {@link WasherDryerCapabilityFactoryV2} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class WasherDryerCapabilityFactoryV2 extends AbstractWasherDryerCapabilityFactory { + private final Logger logger = LoggerFactory.getLogger(WasherDryerCapabilityFactoryV2.class); + + @Override + protected List getSupportedAPIVersions() { + return List.of(LGAPIVerion.V2_0); + } + + @Override + public WasherDryerCapability create(JsonNode rootNode) throws LGThinqException { + WasherDryerCapability cap = super.create(rootNode); + cap.setRemoteStartFeatName("remoteStart"); + cap.setChildLockFeatName("standby"); + cap.setDoorLockFeatName("loadItemWasher"); + return cap; + } + + @Override + protected boolean hasFeatInOptions(String featName, JsonNode monitoringValueNode) { + // there's no option node in V2 + return false; + } + + @Override + protected FeatureDefinition newFeatureDefinition(String featureName, JsonNode featuresNode, + @Nullable String targetChannelId, @Nullable String refChannelId) { + JsonNode featureNode = featuresNode.path(featureName); + FeatureDefinition fd; + if ((fd = WasherFeatureDefinition.getBasicFeatureDefinition(featureName, featureNode, targetChannelId, + refChannelId)) == FeatureDefinition.NULL_DEFINITION) { + return fd; + } + JsonNode labelNode = featureNode.path("label"); + if (!labelNode.isMissingNode() && !labelNode.isNull()) { + fd.setLabel(labelNode.asText()); + } else { + fd.setLabel(featureName); + } + // all features from V2 are enums + fd.setDataType(FeatureDataType.ENUM); + JsonNode valuesMappingNode = featureNode.path("valueMapping"); + if (!valuesMappingNode.isMissingNode()) { + Map valuesMapping = new HashMap<>(); + valuesMappingNode.fields().forEachRemaining(e -> { + // collect values as: + // + // "POWEROFF": { + // "index": 0, + // "label": "@WM_STATE_POWER_OFF_W" + // }, + // to "POWEROFF" -> "@WM_STATE_POWER_OFF_W" + valuesMapping.put(e.getKey(), e.getValue().path("label").asText()); + }); + fd.setValuesMapping(valuesMapping); + } + + return fd; + } + + @Override + public WasherDryerCapability getCapabilityInstance() { + return new WasherDryerCapability(); + } + + @Override + protected String getCourseNodeName(JsonNode rootNode) { + String courseType = getConfigCourseType(rootNode); + return rootNode.path(getMonitorValueNodeName()).path(courseType).path("ref").textValue(); + } + + @Override + protected String getSmartCourseNodeName(JsonNode rootNode) { + return "SmartCourse"; + } + + private String getConfigNodeName() { + return "Config"; + } + + @Override + /* + * Return the default Course Name + * OBS:In the V2, the default course points to the default course name + */ + protected String getDefaultCourse(JsonNode rootNode) { + return rootNode.path(getConfigNodeName()).path("defaultCourse").textValue(); + } + + @Override + protected String getRemoteFeatName() { + return "remoteStart"; + } + + @Override + protected String getStandByFeatName() { + return "standby"; + } + + @Override + protected String getConfigCourseType(JsonNode rootNode) { + return rootNode.path(getConfigNodeName()).path("courseType").textValue(); + } + + protected String getConfigSmartCourseType(JsonNode rootNode) { + return rootNode.path(getConfigNodeName()).path("smartCourseType").textValue(); + } + + protected String getConfigDownloadCourseType(JsonNode rootNode) { + return rootNode.path(getConfigNodeName()).path("downloadedCourseType").textValue(); + } + + @Override + protected String getStateFeatureNodeName() { + return "state"; + } + + @Override + protected String getProcessStateNodeName() { + return "preState"; + } + + @Override + protected String getPreStateFeatureNodeName() { + return "preState"; + } + + @Override + protected String getRinseFeatureNodeName() { + return "rinse"; + } + + @Override + protected String getTemperatureFeatureNodeName() { + return "temp"; + } + + @Override + protected String getSpinFeatureNodeName() { + return "spin"; + } + + @Override + protected String getSoilWashFeatureNodeName() { + return "soilWash"; + } + + @Override + protected String getDoorLockFeatureNodeName() { + return "doorLock"; + } + + @Override + protected MonitoringResultFormat getMonitorDataFormat(JsonNode rootNode) { + // All v2 are Json format + return MonitoringResultFormat.JSON_FORMAT; + } + + @Override + protected Map getCommandsDefinition(JsonNode rootNode) { + JsonNode commandNode = rootNode.path("ControlWifi"); + List escapeDataValues = Arrays.asList("course", "SmartCourse", "doorLock", "childLock"); + if (commandNode.isMissingNode()) { + logger.warn("No commands found in the DryerWasher definition. This is most likely a bug."); + return Collections.emptyMap(); + } + Map commands = new HashMap<>(); + for (Iterator> it = commandNode.fields(); it.hasNext();) { + Map.Entry e = it.next(); + String commandName = e.getKey(); + if ("vtCtrl".equals(commandName)) { + // ignore command + continue; + } + CommandDefinition cd = new CommandDefinition(); + JsonNode thisCommandNode = e.getValue(); + cd.setCommand(thisCommandNode.path("command").textValue()); + JsonNode dataValues = thisCommandNode.path("data").path("washerDryer"); + if (!dataValues.isMissingNode()) { + Map data = new HashMap<>(); + dataValues.fields().forEachRemaining(f -> { + // only load features outside escape. + if (!escapeDataValues.contains(f.getKey())) { + if (f.getValue().isValueNode()) { + ValueNode vn = (ValueNode) f.getValue(); + if (f.getValue().isTextual()) { + data.put(f.getKey(), vn.asText()); + } else if (f.getValue().isNumber()) { + data.put(f.getKey(), vn.asInt()); + } + } + } + }); + // add extra data features + data.put(getConfigCourseType(rootNode), ""); + data.put(getConfigSmartCourseType(rootNode), ""); + data.put("courseType", ""); + cd.setData(data); + cd.setRawCommand(thisCommandNode.toPrettyString()); + } else { + logger.warn("Data node not found in the WasherDryer definition. It's most likely a bug"); + } + commands.put(commandName, cd); + } + return commands; + } + + @Override + protected String getCommandRemoteStartNodeName() { + return "WMStart"; + } + + @Override + protected String getCommandStopNodeName() { + return "WMStop"; + } + + @Override + protected String getCommandWakeUpNodeName() { + return "WMWakeup"; + } + + @Override + protected String getDefaultCourseIdNodeName() { + return "defaultCourse"; + } + + @Override + protected String getNotSelectedCourseKey() { + return "NOT_SELECTED"; + } + + @Override + protected String getMonitorValueNodeName() { + return "MonitoringValue"; + } + + @Override + protected String getDryLevelNodeName() { + return "dryLevel"; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshot.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshot.java new file mode 100644 index 00000000000..a813a2d60c9 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshot.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.WMD_POWER_OFF_VALUE; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.model.AbstractSnapshotDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DevicePowerState; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@link WasherDryerSnapshot} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +@JsonIgnoreProperties(ignoreUnknown = true) +public class WasherDryerSnapshot extends AbstractSnapshotDefinition { + private DevicePowerState powerState = DevicePowerState.DV_POWER_UNK; + private String state = ""; + private String processState = ""; + private boolean online; + private String course = ""; + private String smartCourse = ""; + private String downloadedCourse = ""; + private String temperatureLevel = ""; + private String doorLock = ""; + private String option1 = ""; + private String option2 = ""; + private String childLock = ""; + private Double remainingHour = 0.00; + private Double remainingMinute = 0.00; + private Double reserveHour = 0.00; + private Double reserveMinute = 0.00; + + private String remoteStart = ""; + private boolean remoteStartEnabled = false; + private String standByStatus = ""; + + private String dryLevel = ""; + private boolean standBy = false; + private String error = ""; + private String rinse = ""; + private String spin = ""; + + private String loadItem = ""; + + public String getLoadItem() { + return loadItem; + } + + @JsonAlias({ "LoadItem" }) + @JsonProperty("loadItemWasher") + public void setLoadItem(String loadItem) { + this.loadItem = loadItem; + } + + @JsonAlias({ "Course", "courseFL24inchBaseTitan" }) + @JsonProperty("courseFL24inchBaseTitan") + public String getCourse() { + return course; + } + + public void setCourse(String course) { + this.course = course; + } + + @JsonProperty("dryLevel") + @JsonAlias({ "DryLevel" }) + public String getDryLevel() { + return dryLevel; + } + + public void setDryLevel(String dryLevel) { + this.dryLevel = dryLevel; + } + + @JsonProperty("processState") + @JsonAlias({ "ProcessState", "preState", "PreState" }) + public String getProcessState() { + return processState; + } + + public void setProcessState(String processState) { + this.processState = processState; + } + + @JsonProperty("error") + @JsonAlias({ "Error" }) + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + @Override + public DevicePowerState getPowerStatus() { + return powerState; + } + + @Override + public void setPowerStatus(DevicePowerState value) { + this.powerState = value; + } + + @Override + public boolean isOnline() { + return online; + } + + @Override + public void setOnline(boolean online) { + this.online = online; + } + + @JsonProperty("state") + @JsonAlias({ "state", "State" }) + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + if (state.equals(WMD_POWER_OFF_VALUE)) { + powerState = DevicePowerState.DV_POWER_OFF; + } else { + powerState = DevicePowerState.DV_POWER_ON; + } + } + + @JsonProperty("smartCourseFL24inchBaseTitan") + @JsonAlias({ "smartCourseFL24inchBaseTitan", "SmartCourse" }) + public String getSmartCourse() { + return smartCourse; + } + + public void setSmartCourse(String smartCourse) { + this.smartCourse = smartCourse; + } + + @JsonProperty("downloadedCourseFL24inchBaseTitan") + @JsonAlias({ "downloadedCourseFLUpper25inchBaseUS" }) + public String getDownloadedCourse() { + return downloadedCourse; + } + + public void setDownloadedCourse(String downloadedCourse) { + this.downloadedCourse = downloadedCourse; + } + + @JsonIgnore + public String getRemainingTime() { + return String.format("%02.0f:%02.0f", getRemainingHour(), getRemainingMinute()); + } + + @JsonIgnore + public String getReserveTime() { + return String.format("%02.0f:%02.0f", getReserveHour(), getReserveMinute()); + } + + @JsonProperty("remainTimeHour") + @JsonAlias({ "remainTimeHour", "Remain_Time_H" }) + public Double getRemainingHour() { + return remainingHour; + } + + public void setRemainingHour(Double remainingHour) { + this.remainingHour = remainingHour; + } + + @JsonProperty("remainTimeMinute") + @JsonAlias({ "remainTimeMinute", "Remain_Time_M" }) + public Double getRemainingMinute() { + return remainingMinute; + } + + public void setRemainingMinute(Double remainingMinute) { + this.remainingMinute = remainingMinute; + } + + @JsonProperty("reserveTimeHour") + @JsonAlias({ "reserveTimeHour", "Reserve_Time_H" }) + public Double getReserveHour() { + return reserveHour; + } + + public void setReserveHour(Double reserveHour) { + this.reserveHour = reserveHour; + } + + @JsonProperty("reserveTimeMinute") + @JsonAlias({ "reserveTimeMinute", "Reserve_Time_M" }) + public Double getReserveMinute() { + return reserveMinute; + } + + public void setReserveMinute(Double reserveMinute) { + this.reserveMinute = reserveMinute; + } + + @JsonProperty("temp") + @JsonAlias({ "WaterTemp" }) + public String getTemperatureLevel() { + return temperatureLevel; + } + + public void setTemperatureLevel(String temperatureLevel) { + this.temperatureLevel = temperatureLevel; + } + + @JsonProperty("doorLock") + @JsonAlias({ "DoorLock", "DoorClose" }) + public String getDoorLock() { + return doorLock; + } + + public void setDoorLock(String doorLock) { + this.doorLock = doorLock; + } + + @JsonProperty("ChildLock") + @JsonAlias({ "childLock" }) + public String getChildLock() { + return childLock; + } + + public void setChildLock(String childLock) { + this.childLock = childLock; + } + + public boolean isRemoteStartEnabled() { + return remoteStartEnabled; + } + + @JsonProperty("remoteStart") + @JsonAlias({ "RemoteStart" }) + public String getRemoteStart() { + return remoteStart; + } + + public void setRemoteStart(String remoteStart) { + this.remoteStart = remoteStart.contains("ON") || "1".equals(remoteStart) ? "ON" + : (remoteStart.contains("OFF") || "0".equals(remoteStart) ? "OFF" : remoteStart); + remoteStartEnabled = "ON".equals(this.remoteStart); + } + + @JsonProperty("standby") + @JsonAlias({ "Standby" }) + public String getStandByStatus() { + return standByStatus; + } + + public void setStandByStatus(String standByStatus) { + this.standByStatus = standByStatus.contains("ON") || "1".equals(standByStatus) ? "ON" + : (standByStatus.contains("OFF") || "0".equals(standByStatus) ? "OFF" : standByStatus); + standBy = this.standByStatus.contains("ON"); + } + + public boolean isStandBy() { + return standBy; + } + + @JsonProperty("rinse") + @JsonAlias({ "RinseOption" }) + public String getRinse() { + return rinse; + } + + public void setRinse(String rinse) { + this.rinse = rinse; + } + + @JsonProperty("spin") + @JsonAlias({ "SpinSpeed" }) + public String getSpin() { + return spin; + } + + public void setSpin(String spin) { + this.spin = spin; + } + + @JsonProperty("Option1") + public String getOption1() { + return option1; + } + + public void setOption1(String option1) { + this.option1 = option1; + } + + @JsonProperty("Option2") + public String getOption2() { + return option2; + } + + public void setOption2(String option2) { + this.option2 = option2; + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshotBuilder.java b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshotBuilder.java new file mode 100644 index 00000000000..d0c9cf5a2f2 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/java/org/openhab/binding/lgthinq/lgservices/model/devices/washerdryer/WasherDryerSnapshotBuilder.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer; + +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.WMD_SNAPSHOT_WASHER_DRYER_NODE_V2; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqApiException; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqUnmarshallException; +import org.openhab.binding.lgthinq.lgservices.model.CapabilityDefinition; +import org.openhab.binding.lgthinq.lgservices.model.DefaultSnapshotBuilder; +import org.openhab.binding.lgthinq.lgservices.model.DeviceTypes; +import org.openhab.binding.lgthinq.lgservices.model.LGAPIVerion; +import org.openhab.binding.lgthinq.lgservices.model.MonitoringBinaryProtocol; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * The {@link WasherDryerSnapshotBuilder} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class WasherDryerSnapshotBuilder extends DefaultSnapshotBuilder { + public WasherDryerSnapshotBuilder() { + super(WasherDryerSnapshot.class); + } + + private static void setAltCourseNodeName(CapabilityDefinition capDef, WasherDryerSnapshot snap, + Map washerDryerMap) { + if (snap.getCourse().isEmpty() && capDef instanceof WasherDryerCapability capability) { + String altCourseNodeName = capability.getDefaultCourseFieldName(); + String altSmartCourseNodeName = capability.getDefaultSmartCourseFeatName(); + snap.setCourse(Objects.requireNonNullElse((String) washerDryerMap.get(altCourseNodeName), "")); + snap.setSmartCourse(Objects.requireNonNullElse((String) washerDryerMap.get(altSmartCourseNodeName), "")); + } + } + + @Override + public WasherDryerSnapshot createFromBinary(String binaryData, List prot, + CapabilityDefinition capDef) throws LGThinqUnmarshallException, LGThinqApiException { + WasherDryerSnapshot snap = super.createFromBinary(binaryData, prot, capDef); + + if (!(capDef instanceof WasherDryerCapability washerCap)) { + throw new IllegalArgumentException( + "Capability must be an WasherDryerCapability for WasherDryerSnapshotBuilder. It is most likely a bug!"); + } + + snap.setRemoteStart(bitValue(washerCap.getRemoteStartFeatName(), snap.getRawData(), capDef)); + snap.setChildLock(bitValue(washerCap.getChildLockFeatName(), snap.getRawData(), capDef)); + + if (washerCap.hasDoorLook()) { + snap.setDoorLock(bitValue(washerCap.getDoorLockFeatName(), snap.getRawData(), capDef)); + } + + return snap; + } + + @Override + protected WasherDryerSnapshot getSnapshot(Map snapMap, CapabilityDefinition capDef) { + WasherDryerSnapshot snap; + DeviceTypes type = capDef.getDeviceType(); + LGAPIVerion version = capDef.getDeviceVersion(); + switch (type) { + case DRYER_TOWER: + case DRYER: + case WASHER_TOWER: + case WASHERDRYER_MACHINE: + switch (version) { + case V1_0: { + if (type == DeviceTypes.DRYER || type == DeviceTypes.DRYER_TOWER) { + throw new IllegalArgumentException("Version 1.0 for Dryer is not supported yet."); + } else { + snap = MAPPER.convertValue(snapMap, snapClass); + snap.setRawData(snapMap); + } + } + case V2_0: { + Map washerDryerMap = Objects.requireNonNull(MAPPER + .convertValue(snapMap.get(WMD_SNAPSHOT_WASHER_DRYER_NODE_V2), new TypeReference<>() { + }), "washerDryer node must be present in the snapshot"); + snap = Objects.requireNonNull(MAPPER.convertValue(washerDryerMap, snapClass), + "Unexpected null returned from conversion"); + setAltCourseNodeName(capDef, snap, washerDryerMap); + snap.setRawData(washerDryerMap); + return snap; + } + default: + throw new IllegalStateException("Snapshot for device type " + type + " and version " + version + + " are not supported for this builder. It is most likely a bug"); + } + default: + throw new IllegalStateException("Snapshot for device type " + type + + " not supported for this builder. It is most likely a bug"); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..9e53f65093d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + LG ThinQ Binding + Controlling LG ThinQ enabled devices + cloud + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/i18n/lgthinq.properties b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/i18n/lgthinq.properties new file mode 100644 index 00000000000..03f7bcdf99a --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/i18n/lgthinq.properties @@ -0,0 +1,281 @@ +# add-on +addon.lgthinq.name=LG ThinQ Binding +addon.lgthinq.description=Controlling LG ThinQ enabled devices +# thing types +thing-type.lgthinq.air-conditioner-401.label=LG ThinQ Air Conditioner +thing-type.lgthinq.air-conditioner-401.description=LG ThinQ Air Conditioner +thing-type.lgthinq.bridge.label=LG ThinQ Gateway +thing-type.lgthinq.bridge.description=A connection to a LG ThinQ Gateway +thing-type.lgthinq.dishwasher-204.label=LGThinQ Dish Washer +thing-type.lgthinq.dishwasher-204.description=LG ThinQ Dish Washer +thing-type.lgthinq.dryer-202.label=LGThinQ Dryer +thing-type.lgthinq.dryer-202.description=LG ThinQ Dryer +thing-type.lgthinq.dryer-tower-222.label=LGThinQ DryerTower +thing-type.lgthinq.dryer-tower-222.description=LG ThinQ Dryer Tower +thing-type.lgthinq.fridge-101.label=LGThinQ Fridge +thing-type.lgthinq.fridge-101.description=LG ThinQ Fridge +thing-type.lgthinq.heatpump-401HP.label=LGThinQ Heat Pump +thing-type.lgthinq.heatpump-401HP.description=LG ThinQ Heat Pump +thing-type.lgthinq.washer-201.label=LGThinQ Washer +thing-type.lgthinq.washer-201.description=LG ThinQ Washing Machine +thing-type.lgthinq.washer-tower-221.label=LGThinQ Washer Tower +thing-type.lgthinq.washer-tower-221.description=LGThinQ Washer Tower +# thing types config +thing-type.config.lgthinq.air-conditioner-401.group.Settings.label=Polling +thing-type.config.lgthinq.air-conditioner-401.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.air-conditioner-401.pollExtraInfoOnPowerOff.label=Extra Info +thing-type.config.lgthinq.air-conditioner-401.pollExtraInfoOnPowerOff.description=If enables, extra info will be fetched even when the device is powered off. It's not so common, since extra info are normally changed only when the device is running. +thing-type.config.lgthinq.air-conditioner-401.pollingExtraInfoPeriodSeconds.label=Polling Info Period +thing-type.config.lgthinq.air-conditioner-401.pollingExtraInfoPeriodSeconds.description=Seconds to wait to the next polling for Device's Extra Info (energy consumption, remaining filter, etc) +thing-type.config.lgthinq.air-conditioner-401.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.air-conditioner-401.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.air-conditioner-401.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.air-conditioner-401.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.bridge.alternativeServer.label=Alt Gateway Server +thing-type.config.lgthinq.bridge.alternativeServer.description=Only used for proxy/test gateway server. +thing-type.config.lgthinq.bridge.country.label=User Country +thing-type.config.lgthinq.bridge.country.description=The User Country registered in LG Account +thing-type.config.lgthinq.bridge.country.option.US=United States +thing-type.config.lgthinq.bridge.country.option.UK=United Kingdom +thing-type.config.lgthinq.bridge.country.option.BE=Belgium +thing-type.config.lgthinq.bridge.country.option.BR=Brazil +thing-type.config.lgthinq.bridge.country.option.IT=Italy +thing-type.config.lgthinq.bridge.country.option.LU=Luxembourg +thing-type.config.lgthinq.bridge.country.option.NL=Netherlands +thing-type.config.lgthinq.bridge.country.option.PL=Poland +thing-type.config.lgthinq.bridge.country.option.PT=Portugal +thing-type.config.lgthinq.bridge.country.option.DE=Germany +thing-type.config.lgthinq.bridge.country.option.DK=Denmark +thing-type.config.lgthinq.bridge.country.option.NO=Norway +thing-type.config.lgthinq.bridge.country.option.--=Other +thing-type.config.lgthinq.bridge.language.label=User Language +thing-type.config.lgthinq.bridge.language.description=The User Language registered in LG Account +thing-type.config.lgthinq.bridge.language.option.en-US=American English +thing-type.config.lgthinq.bridge.language.option.nl-BE=Belgium Dutch +thing-type.config.lgthinq.bridge.language.option.en-GB=British English +thing-type.config.lgthinq.bridge.language.option.pt-BR=Brazilian Portuguese +thing-type.config.lgthinq.bridge.language.option.it-IT=Italian +thing-type.config.lgthinq.bridge.language.option.de-LU=Luxembourg German +thing-type.config.lgthinq.bridge.language.option.nl-NL=Netherlands Dutch +thing-type.config.lgthinq.bridge.language.option.pl-PL=Polish +thing-type.config.lgthinq.bridge.language.option.pt-PT=Portugal Portuguese +thing-type.config.lgthinq.bridge.language.option.de-DE=German (Standard) +thing-type.config.lgthinq.bridge.language.option.da-DK=Danish +thing-type.config.lgthinq.bridge.language.option.--=Other +thing-type.config.lgthinq.bridge.manualCountry.label=Manual User Country +thing-type.config.lgthinq.bridge.manualCountry.description=Fill this only if selected "Other" in the Country above. Example value: "DE" +thing-type.config.lgthinq.bridge.manualLanguage.label=Manual User Lang. +thing-type.config.lgthinq.bridge.manualLanguage.description=Fill this only if selected "Other" in the Language above. Example value: de-DE +thing-type.config.lgthinq.bridge.password.label=Password +thing-type.config.lgthinq.bridge.password.description=Password from LG Thinq Personal Account +thing-type.config.lgthinq.bridge.pollingIntervalSec.label=Discovery Interval +thing-type.config.lgthinq.bridge.pollingIntervalSec.description=Polling interval to discover new devices from LG Account (in Seconds >300 or 0 disabled). +thing-type.config.lgthinq.bridge.username.label=Username +thing-type.config.lgthinq.bridge.username.description=Username from LG Thinq Personal Account +thing-type.config.lgthinq.dishwasher-204.group.Settings.label=Polling +thing-type.config.lgthinq.dishwasher-204.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.dishwasher-204.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.dishwasher-204.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.dishwasher-204.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.dishwasher-204.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.dryer-202.group.Settings.label=Polling +thing-type.config.lgthinq.dryer-202.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.dryer-202.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.dryer-202.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.dryer-202.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.dryer-202.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.dryer-tower-222.group.Settings.label=Polling +thing-type.config.lgthinq.dryer-tower-222.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.dryer-tower-222.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.dryer-tower-222.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.dryer-tower-222.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.dryer-tower-222.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.heatpump-401HP.group.Settings.label=Polling +thing-type.config.lgthinq.heatpump-401HP.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.heatpump-401HP.pollExtraInfoOnPowerOff.label=Extra Info +thing-type.config.lgthinq.heatpump-401HP.pollExtraInfoOnPowerOff.description=If enables, extra info will be fetched even when the device is powered off. It's not so common, since extra info are normally changed only when the device is running. +thing-type.config.lgthinq.heatpump-401HP.pollingExtraInfoPeriodSeconds.label=Polling Info Period +thing-type.config.lgthinq.heatpump-401HP.pollingExtraInfoPeriodSeconds.description=Seconds to wait to the next polling for Device's Extra Info (energy consumption, remaining filter, etc) +thing-type.config.lgthinq.heatpump-401HP.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.heatpump-401HP.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.heatpump-401HP.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.heatpump-401HP.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.washer-201.group.Settings.label=Polling +thing-type.config.lgthinq.washer-201.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.washer-201.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.washer-201.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.washer-201.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.washer-201.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +thing-type.config.lgthinq.washer-tower-221.group.Settings.label=Polling +thing-type.config.lgthinq.washer-tower-221.group.Settings.description=Settings required to optimize the polling behaviour. +thing-type.config.lgthinq.washer-tower-221.pollingPeriodPowerOffSeconds.label=Polling When Off +thing-type.config.lgthinq.washer-tower-221.pollingPeriodPowerOffSeconds.description=Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when your device is not working. If you use only this binding to control the device, you can put higher values here. +thing-type.config.lgthinq.washer-tower-221.pollingPeriodPowerOnSeconds.label=Polling When On +thing-type.config.lgthinq.washer-tower-221.pollingPeriodPowerOnSeconds.description=Seconds to wait to the next polling for device state (dashboard channels) +# channel group types +channel-group-type.lgthinq.ac-dashboard.label=Dashboard +channel-group-type.lgthinq.ac-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.ac-extended-info.label=More Info +channel-group-type.lgthinq.ac-extended-info.description=Show more information about the device. +channel-group-type.lgthinq.dr-dashboard.label=Dashboard +channel-group-type.lgthinq.dr-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.dr-rs-grp.label=Remote Start Options +channel-group-type.lgthinq.dr-rs-grp.description=Remote Start Actions and Options. +channel-group-type.lgthinq.dw-dashboard.label=Dashboard +channel-group-type.lgthinq.dw-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.fr-dashboard.label=Dashboard +channel-group-type.lgthinq.fr-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.fr-extra-info.label=More Info +channel-group-type.lgthinq.fr-extra-info.description=Show more information about the device. +channel-group-type.lgthinq.hp-dashboard.label=Dashboard +channel-group-type.lgthinq.hp-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.hp-extra-info.label=More Info +channel-group-type.lgthinq.hp-extra-info.description=Show more information about the device. +channel-group-type.lgthinq.wm-dashboard.label=Dashboard +channel-group-type.lgthinq.wm-dashboard.description=This is the Displayed Information. +channel-group-type.lgthinq.wm-rs-grp.label=Remote Start Options +channel-group-type.lgthinq.wm-rs-grp.description=Remote Start Actions and Options. +# channel types +channel-type.lgthinq.air-clean.label=Air Clean +channel-type.lgthinq.auto-dry.label=Auto Dry +channel-type.lgthinq.cool-jet.label=Cool Jet +channel-type.lgthinq.current-energy.label=Current Energy +channel-type.lgthinq.current-energy.description=Current Energy Consumption (kWh) +channel-type.lgthinq.current-temperature.label=Temperature +channel-type.lgthinq.current-temperature.description=Current temperature. +channel-type.lgthinq.current-watts-power.label=Current Energy +channel-type.lgthinq.current-watts-power.description=Current Energy Consumption (W) +channel-type.lgthinq.dryer-child-lock.label=Child Lock +channel-type.lgthinq.dryer-child-lock.description=Dryer Child Lock +channel-type.lgthinq.dryer-child-lock.state.option.CHILDLOCK_OFF=Unlocked +channel-type.lgthinq.dryer-child-lock.state.option.CHILDLOCK_ON=Locked +channel-type.lgthinq.dryer-dry-level.label=Dry Level +channel-type.lgthinq.dryer-dry-level.description=Dryer Dry +channel-type.lgthinq.dryer-error.label=Error +channel-type.lgthinq.dryer-error.description=Dryer Error +channel-type.lgthinq.dryer-error.state.option.ERROR_TE4=ERROR_TE4 +channel-type.lgthinq.dryer-error.state.option.ERROR_CE1=ERROR_CE1 +channel-type.lgthinq.dryer-remain-time.label=Remaining Time +channel-type.lgthinq.dryer-remain-time.description=Dryer Remaining Time +channel-type.lgthinq.dryer-remain-time.state.pattern=%1$tH:%1$tM +channel-type.lgthinq.dryer-state.label=Dryer State +channel-type.lgthinq.dryer-state.description=Dryer Operation State +channel-type.lgthinq.energy-saving.label=Energy Saving +channel-type.lgthinq.extra-info-collector.label=Info Collector +channel-type.lgthinq.extra-info-collector.description=This switch enable collector for energy and filter consumption (if presents) +channel-type.lgthinq.fan-speed.label=Fan Speed +channel-type.lgthinq.fan-speed.description=AC Wind Strength +channel-type.lgthinq.fan-step-left-right.label=Fan HDir +channel-type.lgthinq.fan-step-left-right.description=Fan Horizontal Direction +channel-type.lgthinq.fan-step-up-down.label=Fan VDir +channel-type.lgthinq.fan-step-up-down.description=Fan Vertical Direction +channel-type.lgthinq.active-saving.label=Active Saving +channel-type.lgthinq.active-saving.description=Active Saving +channel-type.lgthinq.door-open.label=Door Open +channel-type.lgthinq.door-open.description=Door status (at least one if combined fridge/freezer) +channel-type.lgthinq.door-open.state.option.OPEN=Open +channel-type.lgthinq.door-open.state.option.CLOSE=Closed +channel-type.lgthinq.eco-friendly-mode.label=Vacation +channel-type.lgthinq.eco-friendly-mode.description=Vacation Mode +channel-type.lgthinq.express-cool-mode.label=Express Cool +channel-type.lgthinq.express-cool-mode.description=Express Cool +channel-type.lgthinq.express-mode.label=Express Freeze +channel-type.lgthinq.express-mode.description=Express Freeze Mode +channel-type.lgthinq.freezer-temperature.label=Freezer Temp. +channel-type.lgthinq.freezer-temperature.description=Freezer target temperature +channel-type.lgthinq.fresh-air-filter.label=Fresh Air Filter +channel-type.lgthinq.fresh-air-filter.description=Fresh Air Filter State. +channel-type.lgthinq.fridge-temperature.label=Fridge Temp. +channel-type.lgthinq.fridge-temperature.description=Fridge target temperature +channel-type.lgthinq.ice-plus.label=Ice Plus +channel-type.lgthinq.ice-plus.description=Ice Plus Feature +channel-type.lgthinq.smart-saving-mode.label=Smart Saving +channel-type.lgthinq.smart-saving-mode.description=Smart Saving Mode +channel-type.lgthinq.smart-saving-switch.label=Smart Saving +channel-type.lgthinq.smart-saving-switch.description=Smart Saving +channel-type.lgthinq.temp-unit.label=Temp. Unit +channel-type.lgthinq.temp-unit.description=Temperature Unit +channel-type.lgthinq.temp-unit.state.option.CELSIUS=C +channel-type.lgthinq.temp-unit.state.option.FAHRENHEIT=F +channel-type.lgthinq.water-filter.label=Water Filter +channel-type.lgthinq.water-filter.description=Months passed since filter has been changed. +channel-type.lgthinq.hp-air-water-switch.label=Air/Water +channel-type.lgthinq.hp-air-water-switch.description=Define the Temperature Selector based on Water/Air. +channel-type.lgthinq.hp-air-water-switch.state.option.0.0=Air Temperature +channel-type.lgthinq.hp-air-water-switch.state.option.1.0=Leaving Water Temperature +channel-type.lgthinq.max-temperature.label=Maximum Temp. +channel-type.lgthinq.max-temperature.description=Maximum Temperature for this mode. +channel-type.lgthinq.min-temperature.label=Minimum Temp. +channel-type.lgthinq.min-temperature.description=Minimum temperature for this mode. +channel-type.lgthinq.operation-mode.label=Operation Mode +channel-type.lgthinq.operation-mode.description=AC Operation Mode +channel-type.lgthinq.remaining-filter.label=Remaining Filter +channel-type.lgthinq.remaining-filter.description=Remaining filter without need to be replaced. +channel-type.lgthinq.rs-course.label=Course to Run +channel-type.lgthinq.rs-course.description=Course +channel-type.lgthinq.rs-rinse.label=Rinse +channel-type.lgthinq.rs-rinse.description=Rinse +channel-type.lgthinq.rs-spin.label=Spin +channel-type.lgthinq.rs-spin.description=Spin Speed +channel-type.lgthinq.rs-start-stop.label=Remote Start/Stop +channel-type.lgthinq.rs-start-stop.description=Remote Start/Stop +channel-type.lgthinq.rs-temperature-level.label=Temp. Level +channel-type.lgthinq.rs-temperature-level.description=Target Temperature Level +channel-type.lgthinq.target-temperature.label=Target Temp. +channel-type.lgthinq.target-temperature.description=Target temperature. +channel-type.lgthinq.washer-course.label=Washer Course +channel-type.lgthinq.washer-course.description=Washer Course +channel-type.lgthinq.washer-course.state.option.COTTON=Cotton +channel-type.lgthinq.washer-downloaded-course.label=Washer Download Course +channel-type.lgthinq.washer-downloaded-course.description=Washer Downloaded Course +channel-type.lgthinq.washer-downloaded-course.state.option.COTTON=Cotton +channel-type.lgthinq.washer-rinse.label=Rinse +channel-type.lgthinq.washer-rinse.description=Rinse +channel-type.lgthinq.washer-smart-course.label=Washer Smart Course +channel-type.lgthinq.washer-smart-course.description=Washer Smart Course +channel-type.lgthinq.washer-smart-course.state.option.COTTON=Cotton +channel-type.lgthinq.washer-spin.label=Spin +channel-type.lgthinq.washer-spin.description=Spin Speed +channel-type.lgthinq.washer-state.label=Washer State +channel-type.lgthinq.washer-state.description=Washer State Operation +channel-type.lgthinq.washerdryer-course.label=Course +channel-type.lgthinq.washerdryer-course.description=Course +channel-type.lgthinq.washerdryer-delay-time.label=Delay Time +channel-type.lgthinq.washerdryer-delay-time.description=Delay Time +channel-type.lgthinq.washerdryer-door-lock.label=Door Lock +channel-type.lgthinq.washerdryer-door-lock.description=Door Lock +channel-type.lgthinq.washerdryer-door-lock.state.option.DOOR_LOCK_ON=Locked +channel-type.lgthinq.washerdryer-door-lock.state.option.DOOR_LOCK_OFF=Unlocked +channel-type.lgthinq.washerdryer-process-state.label=Process State +channel-type.lgthinq.washerdryer-process-state.description=Process State +channel-type.lgthinq.washerdryer-remain-time.label=Remaining Time +channel-type.lgthinq.washerdryer-remain-time.description=Remaining Time +channel-type.lgthinq.washerdryer-rs.label=Remote Start +channel-type.lgthinq.washerdryer-rs.description=Remote start +channel-type.lgthinq.washerdryer-stand-by.label=Standby Mode +channel-type.lgthinq.washerdryer-stand-by.description=Standby Mode +channel-type.lgthinq.washerdryer-temp-level.label=Temp. Level +channel-type.lgthinq.washerdryer-temp-level.description=Target Temperature Level +# channel types +channel-type.lgthinq.fridge-freezer-temperature.label=Freezer Temp. +channel-type.lgthinq.fridge-freezer-temperature.description=Freezer setpoint temperature +channel-type.lgthinq.fridge-fridge-temperature.label=Fridge Setpoint Temperature +channel-type.lgthinq.fridge-fridge-temperature.description=Fridge setpoint temperature. +channel-type.lgthinq.fridge-door-open.label=Door Open +channel-type.lgthinq.fridge-door-open.description=Door status (at least one if combined fridge/freezer) +channel-type.lgthinq.fridge-door-open.state.option.OPEN=Open +channel-type.lgthinq.fridge-door-open.state.option.CLOSE=Closed +channel-type.lgthinq.fridge-temp-unit.label=Temp. Unit +channel-type.lgthinq.fridge-temp-unit.description=Temperature Unit +channel-type.lgthinq.fridge-temp-unit.state.option.CELSIUS=C +channel-type.lgthinq.fridge-temp-unit.state.option.FAHRENHEIT=F +# channel types +error.lgapi-getting-devices=Error getting device list from the account +error.toke-file-corrupted=LGThinq Bridge Token File corrupted +error.toke-refresh=Error refreshing LGThinq Bridge Token +error.toke-file-access-error=Error reading token file. +error.lgapi-communication-error=Communication Error with LG API +error.mandotory-fields-missing=Mandatory Fields Configuration Missing +error.handler.device-cmd-queue-busy=Device Command Queue is Busy +error.communication-error.no-bridge-set=No Bridge defined for the thing +offline.device-disconnected=Device is Offline diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/air-conditioner.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/air-conditioner.xml new file mode 100644 index 00000000000..1504d98b9c0 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/air-conditioner.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + LG ThinQ Air Conditioner + + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up i/o and cpu when + your device + is not working. If you use only this binding to control the device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + Seconds to wait to the next polling for Device's Extra Info (energy consumption, + remaining filter, etc) + + 60 + + + + If enables, extra info will be fetched even when the device is powered off. + It's not so common, since + extra info are normally changed only when the device is running. + + false + + + + + + + This is the Displayed Information. + + + + + + + + + + + + Show more information about the device. + + + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/bridge.xml new file mode 100644 index 00000000000..7587f93976c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/bridge.xml @@ -0,0 +1,80 @@ + + + + + + A connection to a LG ThinQ Gateway + + + + + The User Language registered in LG Account + + + + + + + + + + + + + + + + + + The User Country registered in LG Account + + + + + + + + + + + + + + + + + + + Fill this only if selected "Other" in the Language above. Example value: de-DE + + + + + Fill this only if selected "Other" in the Country above. Example value: "DE" + + + + Username from LG Thinq Personal Account + + + + Password from LG Thinq Personal Account + password + + + + Polling interval to discover new devices from LG Account (in Seconds >300 or 0 disabled). + + 86400 + + + true + + Only used for proxy/test gateway server. + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/channels.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/channels.xml new file mode 100644 index 00000000000..67998cd01fc --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/channels.xml @@ -0,0 +1,407 @@ + + + + + Number:Temperature + + Current temperature. + Temperature + + + + + Number:Dimensionless + + Remaining filter without need to be replaced. + Battery + + + + + Number + + Define the temperature selector based on water or air. + + + + + + + + + + String + + Months passed since filter has been changed. + + + + + String + + Fresh Air Filter State. + + + + + Switch + + This switch enable collector for energy and filter consumption (if presents) + Switch + + + + Switch + + Switch + + + + Switch + + Switch + + + + Switch + + Switch + + + + Switch + + Switch + + + + Number + + Fan Vertical Direction + wind + + + + Number + + Fan Horizontal Direction + wind + + + + Number:Temperature + + Target temperature. + Temperature + + + + + Number:Temperature + + Minimum temperature for this mode. + Temperature + + + + + Number:Temperature + + Maximum Temperature for this mode. + Temperature + + + + + Number:Energy + + Current Energy Consumption (kWh) + Energy + + + + + Number + + AC Wind Strength + Wind + + + + Number + + AC Operation Mode + + + + Switch + + Remote start + + + + + Switch + + Remote Start/Stop + + + + Switch + + Standby Mode + + + + String + + Rinse + + + + + String + + Rinse + + + + String + + Spin Speed + + + + + String + + Spin Speed + + + + String + + Washer State Operation + + + + + String + + Remaining Time + + + + + String + + Delay Time + + + + + String + + Washer Course + + + + + + + + + String + + Washer Smart Course + + + + + + + + String + + Washer Downloaded Course + + + + + + + + String + + Target Temperature Level + Temperature + + + + String + + Target Temperature Level + Temperature + + + String + + Door Lock + + + + + + + + + + String + + Dryer Operation State + + + + + String + + Process State + + + + + String + + Course + + + + + String + + Course + + + + + String + + Dryer Dry + + + + + String + + Dryer Child Lock + + + + + + + + + + DateTime + + Dryer Remaining Time + + + + + String + + Dryer Error + + + + + + + + + + + Contact + + Door status (at least one if combined fridge/freezer) + + + + + + + + + String + + Temperature Unit + + + + + + + + + + Number:Temperature + + Freezer target temperature + Temperature + + + + + Number:Temperature + + Fridge target temperature + Temperature + + + + + String + + Express Freeze Mode + + + + Switch + + Express Cool + + + + Switch + + Vacation Mode + + + + Switch + + Ice Plus Feature + + + + Switch + + Smart Saving + + + + String + + Smart Saving Mode + + + + Switch + + Active Saving + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dish-washer.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dish-washer.xml new file mode 100644 index 00000000000..7619381c115 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dish-washer.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + LG ThinQ Dish Washer + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + + + + This is the Displayed Information. + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dryer.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dryer.xml new file mode 100644 index 00000000000..f7ec2e62525 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/dryer.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + LG ThinQ Dryer + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + + + + + + + LG ThinQ Dryer Tower + + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + + + + Remote Start Actions and Options. + + + + + + + + This is the Displayed Information. + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/fridge.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/fridge.xml new file mode 100644 index 00000000000..af4d9b95afd --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/fridge.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + LG ThinQ Fridge + + + + + + + + + + This is the Displayed Information. + + + + + + + + + + Show more information about the device. + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/heat-pump.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/heat-pump.xml new file mode 100644 index 00000000000..0548bf505e7 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/heat-pump.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + LG ThinQ Heat Pump + + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + Seconds to wait to the next polling for Device's Extra Info (energy consumption, + remaining filter, etc) + + 60 + + + + If enables, extra info will be fetched even when the device is powered off. + It's not so common, since + extra info are normally changed only when the device is running. + + false + + + + + + + This is the Displayed Information. + + + + + + + + + + + + + + Show more information about the device. + + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/washer-dryer.xml b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/washer-dryer.xml new file mode 100644 index 00000000000..f7cdfcdf339 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/main/resources/OH-INF/thing/washer-dryer.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + LG ThinQ Washing Machine + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + + + + + + + LGThinQ Washer Tower + + + + + + + + Settings required to optimize the polling behaviour. + true + + + + Seconds to wait to the next polling when device is off. Useful to save up + i/o and cpu when your + device is + not working. If you use only this binding to control the + device, you can put higher values here. + + 10 + + + + Seconds to wait to the next polling for device state (dashboard channels) + + 10 + + + + + + Remote Start Actions and Options. + + + + + + + + + + + + This is the Displayed Information. + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/JsonUtils.java b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/JsonUtils.java new file mode 100644 index 00000000000..331fd194398 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/JsonUtils.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.handler; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link JsonUtils} used for test classes to json files serialization. + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +public class JsonUtils { + + public static String loadJson(String fileName) { + ClassLoader classLoader = JsonUtils.class.getClassLoader(); + if (classLoader == null) { + throw new IllegalStateException("Can't get classloader from a custom class ? Security Context issue ?"); + } + try (InputStream inputStream = classLoader.getResourceAsStream(fileName)) { + if (inputStream == null) { + throw new IllegalArgumentException( + "Unexpected error. It is not expected this behaviour since json test files must be present: " + + fileName); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalArgumentException( + "Unexpected error. It is not expected this behaviour since json test files must be present.", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/LGThinqBridgeTests.java b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/LGThinqBridgeTests.java new file mode 100644 index 00000000000..16e022a0365 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/handler/LGThinqBridgeTests.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.handler; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.openhab.binding.lgthinq.lgservices.LGServicesConstants.*; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.lgthinq.internal.LGThinQBridgeConfiguration; +import org.openhab.binding.lgthinq.internal.handler.LGThinQBridgeHandler; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientService; +import org.openhab.binding.lgthinq.lgservices.LGThinQApiClientServiceFactory; +import org.openhab.binding.lgthinq.lgservices.LGThinQWMApiClientService; +import org.openhab.binding.lgthinq.lgservices.api.RestUtils; +import org.openhab.binding.lgthinq.lgservices.api.TokenManager; +import org.openhab.binding.lgthinq.lgservices.model.LGDevice; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCanonicalSnapshot; +import org.openhab.binding.lgthinq.lgservices.model.devices.ac.ACCapability; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * The {@link LGThinqBridgeTests} + * + * @author Nemer Daud - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@WireMockTest(httpPort = 8880) +@NonNullByDefault +@SuppressWarnings({ "unchecked", "null" }) +class LGThinqBridgeTests { + private final Logger logger = LoggerFactory.getLogger(LGThinqBridgeTests.class); + private final String fakeBridgeName = "fakeBridgeId"; + private final String fakeLanguage = "pt-BR"; + private final String fakeCountry = "BR"; + private final String fakeUserName = "someone@some.url"; + private final String fakePassword = "somepassword"; + private final String gtwResponse = JsonUtils.loadJson("gtw-response-1.json"); + private final String preLoginResponse = JsonUtils.loadJson("prelogin-response-1.json"); + private final String userIdType = "LGE"; + private final String loginSessionId = "emp;11111111;222222222"; + private final String loginSessionResponse = String.format(JsonUtils.loadJson("login-session-response-1.json"), + loginSessionId, fakeUserName, userIdType, fakeUserName); + private final String userInfoReturned = String.format(JsonUtils.loadJson("user-info-response-1.json"), fakeUserName, + fakeUserName); + private final String dashboardListReturned = JsonUtils.loadJson("dashboard-list-response-1.json"); + private final String dashboardWMListReturned = JsonUtils.loadJson("dashboard-list-response-wm.json"); + private final String secretKey = "gregre9812012910291029120912091209"; + private final String oauthTokenSearchKeyReturned = "{\"returnData\":\"" + secretKey + "\"}"; + private final String refreshToken = "12897238974bb327862378ef290128390273aa7389723894734de"; + private final String accessToken = "11a1222c39f16a5c8b3fa45bb4c9be2e00a29a69dced2fa7fe731f1728346ee669f1a96d1f0b4925e5aa330b6dbab882772"; + private final String sessionTokenReturned = String.format(JsonUtils.loadJson("session-token-response-1.json"), + accessToken, refreshToken); + + LGThinqBridgeTests() throws IOException { + } + + @Test + public void testDiscoveryACThings() throws Exception { + setupAuthenticationMock(); + LGThinQApiClientService service2 = LGThinQApiClientServiceFactory + .newACApiClientService(LG_API_PLATFORM_TYPE_V2, mock(HttpClientFactory.class)); + try { + List devices = service2.listAccountDevices("bridgeTest"); + assertEquals(devices.size(), 2); + } catch (Exception e) { + logger.error("Error testing facade", e); + } + } + + static class LGThinQBridgeHandlerTest extends LGThinQBridgeHandler { + + public LGThinQBridgeHandlerTest(Bridge bridge, HttpClientFactory httpClientFactory) { + super(bridge, httpClientFactory); + } + + @Override + public T getConfigAs(Class configurationClass) { + return super.getConfigAs(configurationClass); + } + } + + private void setupAuthenticationMock() throws Exception { + stubFor(get(LG_API_GATEWAY_SERVICE_PATH_V2).willReturn(ok(gtwResponse))); + String preLoginPwd = RestUtils.getPreLoginEncPwd(fakePassword); + stubFor(post("/spx" + LG_API_PRE_LOGIN_PATH).withRequestBody(containing("user_auth2=" + preLoginPwd)) + .willReturn(ok(preLoginResponse))); + URI uri = UriBuilder.fromUri("http://localhost:8880").path("spx" + LG_API_OAUTH_SEARCH_KEY_PATH) + .queryParam("key_name", "OAUTH_SECRETKEY").queryParam("sever_type", "OP").build(); + stubFor(get(String.format("%s?%s", uri.getPath(), uri.getQuery())).willReturn(ok(oauthTokenSearchKeyReturned))); + String fakeUserNameEncoded = URLEncoder.encode(fakeUserName, StandardCharsets.UTF_8); + stubFor(post(LG_API_V2_SESSION_LOGIN_PATH + fakeUserNameEncoded) + .withRequestBody(containing("user_auth2=SOME_DUMMY_ENC_PWD")) + .withHeader("X-Signature", equalTo("SOME_DUMMY_SIGNATURE")) + .withHeader("X-Timestamp", equalTo("1643236928")).willReturn(ok(loginSessionResponse))); + stubFor(get(LG_API_V2_USER_INFO).willReturn(ok(userInfoReturned))); + stubFor(get("/v1" + LG_API_V2_LS_PATH).willReturn(ok(dashboardListReturned))); + Map empData = new LinkedHashMap<>(); + empData.put("account_type", userIdType); + empData.put("country_code", fakeCountry); + empData.put("username", fakeUserName); + + stubFor(post("/emp/oauth2/token/empsession").withRequestBody(containing("account_type=" + userIdType)) + .withRequestBody(containing("country_code=" + fakeCountry)) + .withRequestBody(containing("username=" + URLEncoder.encode(fakeUserName, StandardCharsets.UTF_8))) + .withHeader("lgemp-x-session-key", equalTo(loginSessionId)).willReturn(ok(sessionTokenReturned))); + // faking some constants + Bridge fakeThing = mock(Bridge.class); + ThingUID fakeThingUid = mock(ThingUID.class); + when(fakeThingUid.getId()).thenReturn(fakeBridgeName); + when(fakeThing.getUID()).thenReturn(fakeThingUid); + String tempDir = System.getProperty("java.io.tmpdir"); + System.setProperty("THINQ_CONNECTION_DATA_FILE", tempDir + File.separator + "token.json"); + System.setProperty("BASE_CAP_CONFIG_DATA_FILE", tempDir + File.separator + "thinq-cap.json"); + LGThinQBridgeHandlerTest b = new LGThinQBridgeHandlerTest(fakeThing, mock(HttpClientFactory.class)); + LGThinQBridgeHandlerTest spyBridge = spy(b); + doReturn(new LGThinQBridgeConfiguration(fakeUserName, fakePassword, fakeCountry, fakeLanguage, 60, + "http://localhost:8880")).when(spyBridge).getConfigAs(any(Class.class)); + spyBridge.initialize(); + TokenManager tokenManager = new TokenManager(mock(HttpClient.class)); + try { + if (!tokenManager.isOauthTokenRegistered(fakeBridgeName)) { + tokenManager.oauthFirstRegistration(fakeBridgeName, fakeLanguage, fakeCountry, fakeUserNameEncoded, + fakePassword, ""); + } + } catch (Exception e) { + logger.error("Error testing facade", e); + } + } + + @BeforeEach + void setUp() { + String tempDir = System.getProperty("java.io.tmpdir"); + File f = new File(tempDir + File.separator + "token.json"); + f.deleteOnExit(); + } + + @Test + public void testDiscoveryWMThings() throws Exception { + stubFor(get(LG_API_GATEWAY_SERVICE_PATH_V2).willReturn(ok(gtwResponse))); + String preLoginPwd = RestUtils.getPreLoginEncPwd(fakePassword); + stubFor(post("/spx" + LG_API_PRE_LOGIN_PATH).withRequestBody(containing("user_auth2=" + preLoginPwd)) + .willReturn(ok(preLoginResponse))); + URI uri = UriBuilder.fromUri("http://localhost:8880").path("spx" + LG_API_OAUTH_SEARCH_KEY_PATH) + .queryParam("key_name", "OAUTH_SECRETKEY").queryParam("sever_type", "OP").build(); + stubFor(get(String.format("%s?%s", uri.getPath(), uri.getQuery())).willReturn(ok(oauthTokenSearchKeyReturned))); + stubFor(post(LG_API_V2_SESSION_LOGIN_PATH + URLEncoder.encode(fakeUserName, StandardCharsets.UTF_8)) + .withRequestBody(containing("user_auth2=SOME_DUMMY_ENC_PWD")) + .withHeader("X-Signature", equalTo("SOME_DUMMY_SIGNATURE")) + .withHeader("X-Timestamp", equalTo("1643236928")).willReturn(ok(loginSessionResponse))); + stubFor(get(LG_API_V2_USER_INFO).willReturn(ok(userInfoReturned))); + stubFor(get("/v1" + LG_API_V2_LS_PATH).willReturn(ok(dashboardWMListReturned))); + String dataCollectedWM = JsonUtils.loadJson("wm-data-result.json"); + stubFor(get("/v1/service/devices/fakeDeviceId").willReturn(ok(dataCollectedWM))); + Map empData = new LinkedHashMap<>(); + empData.put("account_type", userIdType); + empData.put("country_code", fakeCountry); + empData.put("username", fakeUserName); + + stubFor(post("/emp/oauth2/token/empsession").withRequestBody(containing("account_type=" + userIdType)) + .withRequestBody(containing("country_code=" + fakeCountry)) + .withRequestBody(containing("username=" + URLEncoder.encode(fakeUserName, StandardCharsets.UTF_8))) + .withHeader("lgemp-x-session-key", equalTo(loginSessionId)).willReturn(ok(sessionTokenReturned))); + + String tempDir = Objects.requireNonNull(System.getProperty("java.io.tmpdir"), + "java.io.tmpdir environment variable must be set"); + System.setProperty("THINQ_USER_DATA_FOLDER", tempDir); + System.setProperty("THINQ_CONNECTION_DATA_FILE", tempDir + File.separator + "token.json"); + System.setProperty("BASE_CAP_CONFIG_DATA_FILE", tempDir + File.separator + "thinq-cap.json"); + // LGThinQBridgeHandler b = new LGThinQBridgeHandler(fakeThing, mock(HttpClientFactory.class)); + + final LGThinQWMApiClientService service2 = LGThinQApiClientServiceFactory + .newWMApiClientService(LG_API_PLATFORM_TYPE_V1, mock(HttpClientFactory.class)); + TokenManager tokenManager = new TokenManager(mock(HttpClient.class)); + try { + if (!tokenManager.isOauthTokenRegistered(fakeBridgeName)) { + tokenManager.oauthFirstRegistration(fakeBridgeName, fakeLanguage, fakeCountry, fakeUserName, + fakePassword, "http://localhost:8880"); + } + List devices = service2.listAccountDevices("bridgeTest"); + assertEquals(devices.size(), 1); + // service2.getDeviceData(fakeBridgeName, "fakeDeviceId", new DishWasherCapability()); + } catch (Exception e) { + logger.error("Error testing facade", e); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactoryTest.java b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactoryTest.java new file mode 100644 index 00000000000..8614a7d2663 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/java/org/openhab/binding/lgthinq/lgservices/model/CapabilityFactoryTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.lgthinq.lgservices.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.InputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.lgthinq.handler.JsonUtils; +import org.openhab.binding.lgthinq.lgservices.errors.LGThinqException; +import org.openhab.binding.lgthinq.lgservices.model.devices.washerdryer.WasherDryerCapability; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * The {@link CapabilityFactoryTest} + * + * @author Nemer Daud - Initial contribution + */ +@NonNullByDefault +class CapabilityFactoryTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void create() throws IOException, LGThinqException { + ClassLoader classLoader = JsonUtils.class.getClassLoader(); + assertNotNull(classLoader); + try (InputStream inputStream = classLoader.getResourceAsStream("thinq-washer-v2-cap.json")) { + assertNotNull(inputStream); + JsonNode mapper = objectMapper.readTree(inputStream); + WasherDryerCapability wpCap = CapabilityFactory.getInstance().create(mapper, WasherDryerCapability.class); + assertNotNull(wpCap); + assertEquals(40, wpCap.getCourses().size()); + assertTrue(wpCap.getRinseFeat().getValuesMapping().size() > 1); + assertTrue(wpCap.getSpinFeat().getValuesMapping().size() > 1); + assertTrue(wpCap.getSoilWash().getValuesMapping().size() > 1); + assertTrue(wpCap.getTemperatureFeat().getValuesMapping().size() > 1); + assertTrue(wpCap.hasDoorLook()); + } + } +} diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-1.json new file mode 100644 index 00000000000..8ac43da5f5b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-1.json @@ -0,0 +1,205 @@ +{ + "resultCode": "0000", + "result": { + "langPackCommonVer": "125.6", + "langPackCommonUri": "https://objectcontent.lgthinq.com/f1cae877-1d1e-4c12-8010-acbcdcce2df1?hdnts=exp=1706183232~hmac=257aa8146a089de87496cb13aa0b43761a19e7db225558dfb8996919746b465b", + "item": [ + { + "modelName": "RAC_056905_WW", + "subModelName": "", + "deviceType": 401, + "deviceCode": "AI01", + "alias": "Bedroom", + "deviceId": "abra-cadabra-0001-5771", + "fwVer": "2.5.8_RTOS_3K", + "imageFileName": "ac_home_wall_airconditioner_img.png", + "imageUrl": "https://objectcontent.lgthinq.com/9e0177e7-0956-4284-916d-61e213f1f5ab?hdnts=exp=1702098013~hmac=e14659e3ccf369930e4cc92ca2511203037d3c258b75c627af013e4656fc49d6", + "smallImageUrl": "https://objectcontent.lgthinq.com/c7e214d7-99f0-4641-b954-f238f9d55b64?hdnts=exp=1701658820~hmac=646137b7b590866c772649d03114184628b1477eb974ca8507c0dc4ede6807c5", + "ssid": "dummy-ssid", + "macAddress": "74:40:be:92:ac:08", + "networkType": "02", + "timezoneCode": "America/Sao_Paulo", + "timezoneCodeAlias": "Brazil/Sao Paulo", + "utcOffset": -3, + "utcOffsetDisplay": "-03:00", + "dstOffset": -2, + "dstOffsetDisplay": "-02:00", + "curOffset": -2, + "curOffsetDisplay": "-02:00", + "sdsGuide": "{\"deviceCode\":\"AI01\"}", + "newRegYn": "N", + "remoteControlType": "", + "modelJsonVer": 7.13, + "modelJsonUri": "https://aic.lgthinq.com:46030/api/webContents/modelJSON?modelName=modelJSON_401&countryCode=KR&contentsId=abra-cadabra-0001-5771&authKey=thinq", + "appModuleVer": 12.49, + "appModuleUri": "https://objectcontent.lgthinq.com/19b24102-f2c5-4ac4-97aa-bb1abe5b4c2e?hdnts=exp=1704438018~hmac=050615be890fedc1669a632310dc837b9c6c6ebfd428ed202e2b4b19c2e05155", + "appRestartYn": "Y", + "appModuleSize": 6082481, + "langPackProductTypeVer": 59.9, + "langPackProductTypeUri": "https://objectcontent.lgthinq.com/5642d2e1-cb10-41b4-8e99-f1831f20afe6?hdnts=exp=1705462185~hmac=68fe0ae9ef3fd02355c87668cff6d36c2ad8c312144d7406b9c040be992a15ea", + "langPackModelVer": "", + "langPackModelUri": "", + "deviceState": "E", + "online": false, + "platformType": "thinq1", + "regDt": 2.0200909053555E13, + "modelProtocol": "STANDARD", + "order": 0, + "drServiceYn": "N", + "fwInfoList": [ + { + "partNumber": "SAA38690433", + "checksum": "00000000", + "verOrder": 0 + } + ], + "guideTypeYn": "Y", + "guideType": "RAC_TYPE1", + "regDtUtc": "20200909073555", + "regIndex": 0, + "groupableYn": "Y", + "controllableYn": "Y", + "combinedProductYn": "N", + "masterYn": "Y", + "pccModelYn": "N", + "sdsPid": { + "sds4": "", + "sds3": "", + "sds2": "", + "sds1": "" + }, + "autoOrderYn": "N", + "modelNm": "RAC_056905_WW", + "initDevice": false, + "existsEntryPopup": "N", + "tclcount": 0 + }, + { + "appType": "NUTS", + "modelCountryCode": "WW", + "countryCode": "BR", + "modelName": "RAC_056905_WW", + "deviceType": 401, + "deviceCode": "AI01", + "alias": "Office", + "deviceId": "abra-cadabra-0001-5772", + "fwVer": "", + "imageFileName": "ac_home_wall_airconditioner_img.png", + "imageUrl": "https://objectcontent.lgthinq.com/9e0177e7-0956-4284-916d-61e213f1f5ab?hdnts=exp=1702098013~hmac=e14659e3ccf369930e4cc92ca2511203037d3c258b75c627af013e4656fc49d6", + "smallImageUrl": "https://objectcontent.lgthinq.com/c7e214d7-99f0-4641-b954-f238f9d55b64?hdnts=exp=1701658820~hmac=646137b7b590866c772649d03114184628b1477eb974ca8507c0dc4ede6807c5", + "ssid": "xxxxxxxxx", + "softapId": "", + "softapPass": "", + "macAddress": "", + "networkType": "02", + "timezoneCode": "America/Sao_Paulo", + "timezoneCodeAlias": "Brazil/Sao Paulo", + "utcOffset": -3, + "utcOffsetDisplay": "-03:00", + "dstOffset": -2, + "dstOffsetDisplay": "-02:00", + "curOffset": -2, + "curOffsetDisplay": "-02:00", + "sdsGuide": "{\"deviceCode\":\"AI01\"}", + "newRegYn": "N", + "remoteControlType": "", + "userNo": "xxxxxxxxxxx", + "tftYn": "N", + "modelJsonVer": 12.11, + "modelJsonUri": "https://objectcontent.lgthinq.com/544a6f1c-1b10-4244-a584-d103c8519910?hdnts=exp=1706145774~hmac=bf5e96e83ffdac724b7159b8ed3d7c52f5b9a2a0ef8b67cdbbcf96b1113bd25f", + "appModuleVer": 12.49, + "appModuleUri": "https://objectcontent.lgthinq.com/19b24102-f2c5-4ac4-97aa-bb1abe5b4c2e?hdnts=exp=1704438018~hmac=050615be890fedc1669a632310dc837b9c6c6ebfd428ed202e2b4b19c2e05155", + "appRestartYn": "Y", + "appModuleSize": 6082481, + "langPackProductTypeVer": 59.9, + "langPackProductTypeUri": "https://objectcontent.lgthinq.com/5642d2e1-cb10-41b4-8e99-f1831f20afe6?hdnts=exp=1705462185~hmac=68fe0ae9ef3fd02355c87668cff6d36c2ad8c312144d7406b9c040be992a15ea", + "deviceState": "E", + "snapshot": { + "airState.windStrength": 8.0, + "airState.wMode.lowHeating": 0.0, + "airState.diagCode": 0.0, + "airState.lightingState.displayControl": 1.0, + "airState.wDir.hStep": 0.0, + "mid": 8.4615358E7, + "airState.energy.onCurrent": 476.0, + "airState.wMode.airClean": 0.0, + "airState.quality.sensorMon": 0.0, + "airState.energy.accumulatedTime": 0.0, + "airState.miscFuncState.antiBugs": 0.0, + "airState.tempState.target": 18.0, + "airState.operation": 1.0, + "airState.wMode.jet": 0.0, + "airState.wDir.vStep": 2.0, + "timestamp": 1.643248573766E12, + "airState.powerSave.basic": 0.0, + "airState.quality.PM10": 0.0, + "static": { + "deviceType": "401", + "countryCode": "BR" + }, + "airState.quality.overall": 0.0, + "airState.tempState.current": 25.0, + "airState.miscFuncState.extraOp": 0.0, + "airState.energy.accumulated": 0.0, + "airState.reservation.sleepTime": 0.0, + "airState.miscFuncState.autoDry": 0.0, + "airState.reservation.targetTimeToStart": 0.0, + "meta": { + "allDeviceInfoUpdate": false, + "messageId": "fVz2AE-2SC-rf3GnerGdeQ" + }, + "airState.quality.PM1": 0.0, + "airState.wMode.smartCare": 0.0, + "airState.quality.PM2": 0.0, + "online": true, + "airState.opMode": 0.0, + "airState.reservation.targetTimeToStop": 0.0, + "airState.filterMngStates.maxTime": 0.0, + "airState.filterMngStates.useTime": 0.0 + }, + "online": true, + "platformType": "thinq2", + "area": 45883, + "regDt": 2.0220111184827E13, + "blackboxYn": "Y", + "modelProtocol": "STANDARD", + "order": 0, + "drServiceYn": "N", + "fwInfoList": [ + { + "checksum": "00004105", + "order": 1.0, + "partNumber": "SAA40128563" + } + ], + "modemInfo": { + "appVersion": "clip_hna_v1.9.116", + "modelName": "RAC_056905_WW", + "modemType": "QCOM_QCA4010", + "ruleEngine": "y" + }, + "guideTypeYn": "Y", + "guideType": "RAC_TYPE1", + "regDtUtc": "20220111204827", + "regIndex": 0, + "groupableYn": "Y", + "controllableYn": "Y", + "combinedProductYn": "N", + "masterYn": "Y", + "pccModelYn": "N", + "sdsPid": { + "sds4": "", + "sds3": "", + "sds2": "", + "sds1": "" + }, + "autoOrderYn": "N", + "initDevice": false, + "existsEntryPopup": "N", + "tclcount": 0 + } + ], + "group": [ + ] + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-wm.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-wm.json new file mode 100644 index 00000000000..10d693be17b --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/dashboard-list-response-wm.json @@ -0,0 +1,81 @@ +{ + "resultCode": "0000", + "result": { + "langPackCommonVer": "125.6", + "langPackCommonUri": "https://objectcontent.lgthinq.com/f1cae877-1d1e-4c12-8010-acbcdcce2df1?hdnts=exp=1706183232~hmac=257aa8146a089de87496cb13aa0b43761a19e7db225558dfb8996919746b465b", + "item": [ + { + "modelName": "RAC_056905_WW", + "subModelName": "", + "deviceType": 201, + "deviceCode": "WM01", + "alias": "Bedroom", + "deviceId": "washer-0001-5771", + "fwVer": "2.5.8_RTOS_3K", + "imageFileName": "washmachine-1.png", + "imageUrl": "https://objectcontent.lgthinq.com/9e0177e7-0956-4284-916d-61e213f1f5ab?hdnts=exp=1702098013~hmac=e14659e3ccf369930e4cc92ca2511203037d3c258b75c627af013e4656fc49d6", + "smallImageUrl": "https://objectcontent.lgthinq.com/c7e214d7-99f0-4641-b954-f238f9d55b64?hdnts=exp=1701658820~hmac=646137b7b590866c772649d03114184628b1477eb974ca8507c0dc4ede6807c5", + "ssid": "dummy-ssid", + "macAddress": "74:40:be:92:ac:08", + "networkType": "02", + "timezoneCode": "America/Sao_Paulo", + "timezoneCodeAlias": "Brazil/Sao Paulo", + "utcOffset": -3, + "utcOffsetDisplay": "-03:00", + "dstOffset": -2, + "dstOffsetDisplay": "-02:00", + "curOffset": -2, + "curOffsetDisplay": "-02:00", + "sdsGuide": "{\"deviceCode\":\"WM01\"}", + "newRegYn": "N", + "remoteControlType": "", + "modelJsonVer": 7.13, + "modelJsonUri": "https://aic.lgthinq.com:46030/api/webContents/modelJSON?modelName=modelJSON_401&countryCode=KR&contentsId=abra-cadabra-0001-5771&authKey=thinq", + "appModuleVer": 12.49, + "appModuleUri": "https://objectcontent.lgthinq.com/19b24102-f2c5-4ac4-97aa-bb1abe5b4c2e?hdnts=exp=1704438018~hmac=050615be890fedc1669a632310dc837b9c6c6ebfd428ed202e2b4b19c2e05155", + "appRestartYn": "Y", + "appModuleSize": 6082481, + "langPackProductTypeVer": 59.9, + "langPackProductTypeUri": "https://objectcontent.lgthinq.com/5642d2e1-cb10-41b4-8e99-f1831f20afe6?hdnts=exp=1705462185~hmac=68fe0ae9ef3fd02355c87668cff6d36c2ad8c312144d7406b9c040be992a15ea", + "langPackModelVer": "", + "langPackModelUri": "", + "deviceState": "E", + "online": false, + "platformType": "thinq2", + "regDt": 2.0200909053555E13, + "modelProtocol": "STANDARD", + "order": 0, + "drServiceYn": "N", + "fwInfoList": [ + { + "partNumber": "SAA38690433", + "checksum": "00000000", + "verOrder": 0 + } + ], + "guideTypeYn": "Y", + "guideType": "RAC_TYPE1", + "regDtUtc": "20200909073555", + "regIndex": 0, + "groupableYn": "Y", + "controllableYn": "Y", + "combinedProductYn": "N", + "masterYn": "Y", + "pccModelYn": "N", + "sdsPid": { + "sds4": "", + "sds3": "", + "sds2": "", + "sds1": "" + }, + "autoOrderYn": "N", + "modelNm": "RAC_056905_WW", + "initDevice": false, + "existsEntryPopup": "N", + "tclcount": 0 + } + ], + "group": [ + ] + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result.json new file mode 100644 index 00000000000..576a4cb45b8 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result.json @@ -0,0 +1,125 @@ +{ + "resultCode": "0000", + "result": { + "appType": "NUTS", + "modelCountryCode": "WW", + "countryCode": "NO", + "modelName": "2REB1GLTL1__2", + "deviceType": 101, + "deviceCode": "KI0104", + "alias": "My fridge", + "deviceId": "UUID", + "fwVer": "", + "imageFileName": "home_appliances_img_fridge.png", + "ssid": "WifiSSID", + "softapId": "", + "softapPass": "", + "macAddress": "", + "networkType": "02", + "timezoneCode": "Europe/Oslo", + "timezoneCodeAlias": "Europe/Oslo", + "utcOffset": 1, + "utcOffsetDisplay": "+01:00", + "dstOffset": 2, + "dstOffsetDisplay": "+02:00", + "curOffset": 1, + "curOffsetDisplay": "+01:00", + "sdsGuide": "{\"deviceCode\":\"KI01\"}", + "newRegYn": "N", + "remoteControlType": "", + "userNo": "NO00000000000", + "tftYn": "N", + "deviceState": "E", + "snapshot": { + "fwUpgradeInfo": { + "upgSched": { + "upgUtc": "0", + "cmd": "none" + } + }, + "static": { + "deviceType": "101", + "countryCode": "NO" + }, + "meta": { + "allDeviceInfoUpdate": false, + "messageId": "coDTKiAHSaGPecyqOkLRFg" + }, + "mid": 1.288660304E9, + "online": true, + "refState": { + "dispenserCapacity": 255.0, + "dispenserUnit": "IGNORE", + "freezerTemp": 255.0, + "sabbathMode": "IGNORE", + "tempUnit": "CELSIUS", + "ecoFriendly": "OFF", + "activeSaving": "IGNORE", + "voiceMode": "IGNORE", + "smartSavingRun": "IGNORE", + "atLeastOneDoorOpen": "CLOSE", + "expressMode": "IGNORE", + "freshAirFilter": "IGNORE", + "convertibleTemp": 255.0, + "waterFilter": "IGNORE", + "dispenserMode": "NOT_DEFINE_VALUE value:255", + "displayLock": "UNLOCK", + "expressFridge": "OFF", + "selfCareMode": "IGNORE", + "drawerMode": "IGNORE", + "fridgeTemp": 5.0, + "pantryMode": "IGNORE", + "craftIceMode": "IGNORE", + "dualFridgeMode": "IGNORE", + "monStatus": "NORMAL", + "smartSavingMode": "IGNORE", + "smartCareV2": "ON" + }, + "timestamp": 1.676199284675E12 + }, + "online": true, + "platformType": "thinq2", + "area": 254946, + "regDt": 2.0221216190446E13, + "blackboxYn": "Y", + "modelProtocol": "STANDARD", + "receipeVersion": 0, + "activeSaving": "IGNORE", + "smartCareV2": "ON", + "order": 0, + "nlpAlias": "none", + "drServiceYn": "N", + "fwInfoList": [ + { + "checksum": "0000AC5B", + "order": 2.0, + "partNumber": "SAA42468501" + }, + { + "checksum": "0000D2B2", + "order": 1.0, + "partNumber": "SAA42473902" + } + ], + "regDtUtc": "20221216170446", + "regIndex": 0, + "groupableYn": "N", + "controllableYn": "N", + "combinedProductYn": "N", + "masterYn": "Y", + "initDevice": false, + "firebaseLogKey": "FIREBASE:LOG:KEY:BLABLA", + "manufacture": { + "inventoryOrg": "CP3", + "macAddress": "00:00:00:00:00:00", + "manufactureModel": "GC-B411EQAF.AMCQEUR", + "manufacturedAt": "2022-06-16T01:06:21+00:00", + "registeredAt": "2022-06-27T04:18:34.022045+00:00", + "salesModel": "GLM71MCCSF.AMCQEUR", + "serialNo": "SerialNumber" + }, + "upgradableYn": "N", + "autoFwDownloadYn": "N", + "tclcount": 0 + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result2.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result2.json new file mode 100644 index 00000000000..0a712df1e4d --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/fridge-data-result2.json @@ -0,0 +1,125 @@ +{ + "resultCode": "0000", + "result": { + "appType": "NUTS", + "modelCountryCode": "WW", + "countryCode": "NO", + "modelName": "2REB1GLTL1__2", + "deviceType": 101, + "deviceCode": "KI0104", + "alias": "My fridge", + "deviceId": "UUID", + "fwVer": "", + "imageFileName": "home_appliances_img_fridge.png", + "ssid": "WifiSSID", + "softapId": "", + "softapPass": "", + "macAddress": "", + "networkType": "02", + "timezoneCode": "Europe/Oslo", + "timezoneCodeAlias": "Europe/Oslo", + "utcOffset": 1, + "utcOffsetDisplay": "+01:00", + "dstOffset": 2, + "dstOffsetDisplay": "+02:00", + "curOffset": 1, + "curOffsetDisplay": "+01:00", + "sdsGuide": "{\"deviceCode\":\"KI01\"}", + "newRegYn": "N", + "remoteControlType": "", + "userNo": "NO00000000000", + "tftYn": "N", + "deviceState": "E", + "snapshot": { + "fwUpgradeInfo": { + "upgSched": { + "upgUtc": "0", + "cmd": "none" + } + }, + "static": { + "deviceType": "101", + "countryCode": "NO" + }, + "meta": { + "allDeviceInfoUpdate": false, + "messageId": "yOfRuCbKRqyBQfll-PbaoA" + }, + "mid": 1.291752295E9, + "online": true, + "refState": { + "dispenserCapacity": 255.0, + "dispenserUnit": "IGNORE", + "freezerTemp": 255.0, + "sabbathMode": "IGNORE", + "tempUnit": "CELSIUS", + "ecoFriendly": "OFF", + "activeSaving": "IGNORE", + "voiceMode": "IGNORE", + "smartSavingRun": "IGNORE", + "atLeastOneDoorOpen": "CLOSE", + "expressMode": "IGNORE", + "freshAirFilter": "IGNORE", + "convertibleTemp": 255.0, + "waterFilter": "IGNORE", + "dispenserMode": "NOT_DEFINE_VALUE value:255", + "displayLock": "UNLOCK", + "expressFridge": "OFF", + "selfCareMode": "IGNORE", + "drawerMode": "IGNORE", + "fridgeTemp": 6.0, + "pantryMode": "IGNORE", + "craftIceMode": "IGNORE", + "dualFridgeMode": "IGNORE", + "monStatus": "NORMAL", + "smartSavingMode": "IGNORE", + "smartCareV2": "ON" + }, + "timestamp": 1.676202376697E12 + }, + "online": true, + "platformType": "thinq2", + "area": 254946, + "regDt": 2.0221216190446E13, + "blackboxYn": "Y", + "modelProtocol": "STANDARD", + "receipeVersion": 0, + "activeSaving": "IGNORE", + "smartCareV2": "ON", + "order": 0, + "nlpAlias": "none", + "drServiceYn": "N", + "fwInfoList": [ + { + "checksum": "0000AC5B", + "order": 2.0, + "partNumber": "SAA42468501" + }, + { + "checksum": "0000D2B2", + "order": 1.0, + "partNumber": "SAA42473902" + } + ], + "regDtUtc": "20221216170446", + "regIndex": 0, + "groupableYn": "N", + "controllableYn": "N", + "combinedProductYn": "N", + "masterYn": "Y", + "initDevice": false, + "firebaseLogKey": "FIREBASE:LOG:KEY:BLABLA", + "manufacture": { + "inventoryOrg": "CP3", + "macAddress": "00:00:00:00:00:00", + "manufactureModel": "GC-B411EQAF.AMCQEUR", + "manufacturedAt": "2022-06-16T01:06:21+00:00", + "registeredAt": "2022-06-27T04:18:34.022045+00:00", + "salesModel": "GLM71MCCSF.AMCQEUR", + "serialNo": "SerialNumber" + }, + "upgradableYn": "N", + "autoFwDownloadYn": "N", + "tclcount": 0 + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/gtw-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/gtw-response-1.json new file mode 100644 index 00000000000..eceff046ff1 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/gtw-response-1.json @@ -0,0 +1,63 @@ +{ + "resultCode": "0000", + "result": { + "countryCode": "BR", + "languageCode": "pt-BR", + "thinq1Uri": "http://localhost:8880/api", + "thinq2Uri": "http://localhost:8880/v1", + "empUri": "http://localhost:8880", + "empSpxUri": "http://localhost:8880/spx", + "rtiUri": "localhost:8880", + "mediaUri": "localhost:8880", + "appLatestVer": "4.0.14230", + "appUpdateYn": "N", + "appLink": "market://details?id=com.lgeha.nuts", + "uuidLoginYn": "N", + "lineLoginYn": "N", + "lineChannelId": "", + "cicTel": "4004-5400", + "cicUri": "", + "isSupportVideoYn": "N", + "countryLangDescription": "Brasil/Português", + "empTermsUri": "http://localhost:8880", + "googleAssistantUri": "https://assistant.google.com/services/invoke/uid/000000d26892b8a3", + "smartWorldUri": "", + "racUri": "br.rac.lgeapi.com", + "cssUri": "https://aic-common.lgthinq.com", + "cssWebUri": "http://s3-an2-op-t20-css-web-resource.s3-website.ap-northeast-2.amazonaws.com", + "iotssUri": "https://aic-iotservice.lgthinq.com", + "chatBotUri": "", + "autoOrderSetUri": "", + "autoOrderManageUri": "", + "aiShoppingUri": "", + "onestopCall": "", + "onestopEngineerUri": "", + "hdssUri": "", + "amazonDrsYn": "N", + "features": { + "supportTvIoTServerYn": "Y", + "disableWeatherCard": "Y", + "thinqCss": "Y", + "bleConfirmYn": "Y", + "tvRcmdContentYn": "Y", + "supportProductManualYn": "N", + "clientDbYn": "Y", + "androidAutoYn": "Y", + "searchYn": "Y", + "thinqFaq": "Y", + "thinqNotice": "Y", + "groupControlYn": "Y", + "inAppReviewYn": "Y", + "cicSupport": "Y", + "qrRegisterYn": "Y", + "supportBleYn": "Y" + }, + "serviceCards": [ + ], + "uris": { + "takeATourUri": "https://s3-us2-op-t20-css-contents.s3.us-west-2.amazonaws.com/workexperience-new/ios/no-version/index.html", + "gscsUri": "https://gscs-america.lge.com", + "amazonDartUri": "https://shs.lgthinq.com" + } + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/login-session-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/login-session-response-1.json new file mode 100644 index 00000000000..5dfe01e63fa --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/login-session-response-1.json @@ -0,0 +1,53 @@ +{ + "account": { + "loginSessionID": "%s", + "userID": "%s", + "userIDType": "%s", + "dateOfBirth": "05-05-1978", + "country": "BR", + "countryName": "Brazil", + "blacklist": "N", + "age": "43", + "isSubscribe": "N", + "changePw": "N", + "toEmailId": "N", + "periodPW": "N", + "lgAccount": "Y", + "isService": "Y", + "userNickName": "faker", + "termsList": [ + ], + "userIDList": [ + { + "lgeIDList": [ + { + "lgeIDType": "LGE", + "userID": "%s" + } + ] + } + ], + "serviceList": [ + { + "svcCode": "SVC202", + "svcName": "LG ThinQ", + "isService": "Y", + "joinDate": "30-04-2020" + }, + { + "svcCode": "SVC710", + "svcName": "EMP OAuth", + "isService": "Y", + "joinDate": "30-04-2020" + } + ], + "displayUserID": "faker", + "notiList": { + "totCount": "0", + "list": [ + ] + }, + "authUser": "N", + "dummyIdFlag": "N" + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/prelogin-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/prelogin-response-1.json new file mode 100644 index 00000000000..e5162aa26e8 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/prelogin-response-1.json @@ -0,0 +1,5 @@ +{ + "encrypted_pw": "SOME_DUMMY_ENC_PWD", + "signature": "SOME_DUMMY_SIGNATURE", + "tStamp": "1643236928" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/session-token-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/session-token-response-1.json new file mode 100644 index 00000000000..d7501de38a5 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/session-token-response-1.json @@ -0,0 +1,7 @@ +{ + "status": 1, + "access_token": "%s", + "expires_in": "3600", + "refresh_token": "%s", + "oauth2_backend_url": "http://localhost:8880/" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-fridge-v2-cap.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-fridge-v2-cap.json new file mode 100644 index 00000000000..811cdb711b5 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-fridge-v2-cap.json @@ -0,0 +1,1353 @@ +{ + "Info": { + "productType": "REF", + "country": "WW", + "modelType": "BF", + "model": "T20_B PJT THOR (Larder)(PP/C1) 유럽", + "modelName": "2REB1GLTL1__2", + "networkType": "WIFI", + "version": "1.02" + }, + "Module": { + "WPM": { + "GRM_CEN01_Main": "201", + "GRM_CEN02_UserSaving": "202", + "GRM_CEN04_RefViewer": "202", + "GRM_CEN05_ImgViewer": "202", + "GRM_FOD01_Main": "201", + "GRM_FOD02_EditFoodInfo": "201", + "GRM_FOD03_EditFoodIcon": "201", + "GRM_FOD04_AddFood": "201", + "GRM_ENM01_Main": "201", + "GRM_ENM02_DoorOpenings": "201", + "GRM_ENM03_PowerConsume": "201", + "GRM_ENM04_SetSaving": "202", + "GRM_ECO01_Main": "202", + "GRM_ECO02_Active": "202", + "GRM_ECO03_SavingRatio": "202", + "GRM_ECO04_SavingDetail": "202", + "GRM_ECO05_ViewTip": "202", + "GCM_SDS01_SdsMain": "201", + "GRM_FOT01_Main": "201", + "GRM_SET01_Main": "201", + "GRM_SET02_PushList": "201", + "GRM_SMC01_Main": "202", + "GRM_SMC02_SafeStore": "201", + "GRM_SMC03_ActiveCooling": "201", + "GRM_SMC04_SmartFreshStorage": "201", + "GRM_SMC05_ActiveIcePlus": "201", + "GRM_PHO01_Main": "201", + "GRM_SHO01_Main": "201", + "GRM_SBS01_Main": "201", + "GRM_SBS02_Local": "201", + "GRM_SET04_WeatherLocation": "201" + }, + "Menu": [ + "GRM_SMC01_Main", + "GCM_SDS01_SdsMain", + "GRM_SET01_Main" + ] + }, + "Config": { + "targetRoot": "refState", + "ignoreValue": { + "key": "IGNORE", + "index": -99 + }, + "replaceStateValue": "@RE_STATE_REPLACE_FILTER_W", + "wifiDiagnosis": "true", + "hasInsideView": false, + "fota": "true", + "hasdoor": "Y", + "blackBox": "N", + "supportFoodManager": true, + "ecoFriendlyDefaultIndex": { + "fridgeTemp": { + "tempUnit_C": 1, + "tempUnit_F": 1 + }, + "freezerTemp": { + "tempUnit_C": 1, + "tempUnit_F": 1 + }, + "convertibleTemp": { + "tempUnit_C": 1, + "tempUnit_F": 1 + }, + "expressMode": 0, + "expressFridge": 0 + }, + "sabbathDefaultSchedule": { + "type": "location", + "startDay": "FRI", + "endDay": "SAT", + "startTime": 0, + "endTime": 0, + "weekRepeatYn": "Y" + }, + "sabbathDayListMap": { + "FRI": "@RE_TERM_DAY_FRI_W", + "SAT": "@RE_TERM_DAY_SAT_W" + }, + "smartCareVersion": "V2", + "smartCare": { + "useSmartStorage": false, + "useSmartFreshStorage": true, + "useActiveIcePlus": false, + "useActiveSavingsV2": true + }, + "sideMenuInfo": { + "GRM_FOD01_Main": { + "title": "@RE_FOOD_MANAGEMENT_W", + "image": "image/ref_sidemenu_btn_foodmanager.png" + }, + "GRM_ENM01_Main": { + "title": "@RE_ENM_TITLE_W", + "image": "image/ref_sidemenu_btn_energymonitoring.png" + }, + "GCM_SDS01_SdsMain": { + "title": "@CP_NAME_SMART_DIAGNOSIS_W", + "image": "image/ref_sidemenu_btn_smart_diagnosis.png" + }, + "GRM_SET01_Main": { + "title": "@CP_SETTING_W", + "image": "image/ref_sidemenu_btn_setting.png" + }, + "GRM_ECO01_Main": { + "title": "@RE_ENM_TITLE_W", + "image": "wpm/GRM/image/ref_sidemenu_btn_energymonitoring.png" + }, + "GRM_SMC01_Main": { + "title": "@RE_SMARTCARE_RUN_V2_W", + "image": "wpm/GRM/image/ref_sidemenu_btn_smartcare.png" + }, + "GRM_SHO01_Main": { + "title": "@RE_GROCERY_LIST_W", + "image": "wpm/GRM/image/ref_sidemenu_btn_shopping.png" + } + }, + "visibleItems": [ + { + "feature": "fridgeTemp", + "imageUrl": "", + "monTitle": "@RE_TERM_FRIDGE_W", + "controlTitle": "@RE_TERM_FRIDGE_W", + "controlDisabledOption": [ + { + "optionValue": "@CP_OFF_EN_W", + "replaceOptionValue": "IGNORE" + } + ] + }, + { + "feature": "expressFridge", + "imageUrl": "wpm/GRM/image/ref_home_ic_coldstorage.png", + "monTitle": "@RE_TERM_EXPRESS_FRIDGE_W", + "controlTitle": "@RE_TERM_EXPRESS_FRIDGE_W", + "templateType": "typeSwitch.html" + }, + { + "feature": "ecoFriendly", + "imageUrl": "image/icon_fridge_eco.png", + "monTitle": "@RE_TERM_ECO_FRIENDLY_W", + "controlTitle": "@RE_TERM_ECO_FRIENDLY_W", + "templateType": "typeSwitch.html" + }, + { + "feature": "smartCareV2", + "imageUrl": "wpm/GRM/image/ref_home_ic_smartcare.png", + "monTitle": "@RE_SMARTCARE_RUN_V2_W", + "controlTitle": "@RE_SMARTCARE_RUN_V2_W", + "templateType": "NONE" + } + ] + }, + "MonitoringValue": { + "monStatus": { + "_comment": "Monitoring Status _ Not Shown Item", + "dataType": "enum", + "default": "NORMAL", + "visibleItem": { + "monitoringIndex": [ + 0, + 1, + 2 + ], + "controlIndex": [ + 0, + 1, + 2 + ] + }, + "valueMapping": { + "FAIL": { + "index": 0, + "label": "", + "_comment": "Fail" + }, + "NOT_WORK": { + "index": 1, + "label": "", + "_comment": "Not working" + }, + "NORMAL": { + "index": 2, + "label": "", + "_comment": "Normal" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "fridgeTemp": { + "_comment": "Fridge Target Temperature", + "dataType": "range", + "default": 1, + "visibleItem": { + "monitoringIndex": [], + "controlIndex": [] + }, + "targetKey": { + "tempUnit": { + "CELSIUS": "fridgeTemp_C", + "FAHRENHEIT": "fridgeTemp_F" + } + }, + "valueMapping": { + "min": 0, + "max": 255, + "step": 1 + } + }, + "freezerTemp": { + "_comment": "Freezer Target Temperature", + "dataType": "range", + "default": 1, + "visibleItem": { + "monitoringIndex": [], + "controlIndex": [] + }, + "targetKey": { + "tempUnit": { + "CELSIUS": "freezerTemp_C", + "FAHRENHEIT": "freezerTemp_F" + } + }, + "valueMapping": { + "min": 0, + "max": 255, + "step": 1 + } + }, + "convertibleTemp": { + "_comment": "Convertible Target Temperature", + "dataType": "range", + "default": 1, + "visibleItem": { + "monitoringIndex": [], + "controlIndex": [] + }, + "targetKey": { + "tempUnit": { + "CELSIUS": "convertibleTemp_C", + "FAHRENHEIT": "convertibleTemp_F" + } + }, + "valueMapping": { + "min": 0, + "max": 255, + "step": 1 + } + }, + "expressMode": { + "_comment": "Express Fridge, ExpressFreeze, Rapid Freeze", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [ + 0, + 1 + ] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Express Mode OFF" + }, + "EXPRESS_ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Express Fridge or Express Freeze ON" + }, + "RAPID_ON": { + "index": 2, + "label": "@RE_MAIN_SPEED_FREEZE_TERM_W", + "_comment": "Rapid Freeze ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "tempUnit": { + "_comment": "Temperature Unit", + "dataType": "enum", + "default": "FAHRENHEIT", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "CELSIUS": { + "index": 0, + "label": "˚C", + "_comment": "Celsius Unit" + }, + "FAHRENHEIT": { + "index": 1, + "label": "˚F", + "_comment": "Fahrenheit Unit" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "freshAirFilter": { + "_comment": "Fresh Air Filter Status", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1, + 2, + 3 + ], + "controlIndex": [] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_TERM_OFF_KO_W", + "_comment": "Fresh Air Filter OFF" + }, + "AUTO": { + "index": 1, + "label": "@RE_STATE_FRESH_AIR_FILTER_MODE_AUTO_W", + "_comment": "Fresh Air Filter AUTO" + }, + "POWER": { + "index": 2, + "label": "@RE_STATE_FRESH_AIR_FILTER_MODE_POWER_W", + "_comment": "Fresh Air Filter POWER" + }, + "REPLACE": { + "index": 3, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Fresh Air Filter REPLACE" + }, + "SMART_STORAGE_POWER": { + "index": 4, + "label": "", + "_comment": "Fresh Air Filter Smart Storage POWER" + }, + "SMART_STORAGE_OFF": { + "index": 5, + "label": "", + "_comment": "Fresh Air Filter Smart Storage OFF" + }, + "SMART_STORAGE_ON": { + "index": 6, + "label": "", + "_comment": "Fresh Air Filter Smart Storage ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "waterFilter": { + "_comment": "Water Filter Status", + "dataType": "enum", + "default": "0_MONTH", + "visibleItem": { + "monitoringIndex": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "controlIndex": [] + }, + "valueMapping": { + "0_MONTH": { + "index": 0, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 0 Month Passed" + }, + "1_MONTH": { + "index": 1, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 1 Month Passed" + }, + "2_MONTH": { + "index": 2, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 2 Month Passed" + }, + "3_MONTH": { + "index": 3, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 3 Month Passed" + }, + "4_MONTH": { + "index": 4, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 4 Month Passed" + }, + "5_MONTH": { + "index": 5, + "label": "@RE_TERM_OK_W", + "_comment": "Water Filter 5 Month Passed" + }, + "6_MONTH": { + "index": 6, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 6 Month Passed" + }, + "7_MONTH": { + "index": 7, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 7 Month Passed" + }, + "8_MONTH": { + "index": 8, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 8 Month Passed" + }, + "9_MONTH": { + "index": 9, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 9 Month Passed" + }, + "10_MONTH": { + "index": 10, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 10 Month Passed" + }, + "11_MONTH": { + "index": 11, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 11 Month Passed" + }, + "12_MONTH": { + "index": 12, + "label": "@RE_STATE_REPLACE_FILTER_W", + "_comment": "Water Filter 12 Month Passed" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "displayLock": { + "_comment": "Display Lock Status(unlock, lock)", + "dataType": "enum", + "default": "UNLOCK", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "UNLOCK": { + "index": 0, + "label": "", + "_comment": "Display Panel Unlocked" + }, + "LOCK": { + "index": 1, + "label": "", + "_comment": "Display Panel Locked" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "sabbathMode": { + "_comment": "Sabbath Mode State (ON, OFF)", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Sabbath Mode OFF" + }, + "ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Sabbath Mode ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "atLeastOneDoorOpen": { + "_comment": "Door Open State(Close or Open) global", + "dataType": "enum", + "default": "CLOSE", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "CLOSE": { + "index": 0, + "label": "", + "_comment": "Door Closed" + }, + "OPEN": { + "index": 1, + "label": "", + "_comment": "Door Open" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "smartSavingMode": { + "_comment": "Smart Saving Setting Status", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 3, + 4 + ], + "controlIndex": [] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Smart Saving OFF" + }, + "NIGHT_ON": { + "index": 1, + "label": "@RE_SMARTSAVING_MODE_NIGHT_W", + "_comment": "Smart Saving Night Mode ON" + }, + "CUSTOM_ON": { + "index": 2, + "label": "@RE_SMARTSAVING_MODE_CUSTOM_W", + "_comment": "Smart Saving Custom Mode ON" + }, + "SMARTGRID_DR_ON": { + "index": 3, + "label": "@RE_TERM_DEMAND_RESPONSE_FUNCTIONALITY_W", + "_comment": "Smart Grid Demand Response Mode ON" + }, + "SMARTGRID_DD_ON": { + "index": 4, + "label": "@RE_TERM_DELAY_DEFROST_CAPABILITY_W", + "_comment": "Smart Grid Delay Defrost Mode ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "smartSavingRun": { + "_comment": "Smart Saving Running Status", + "dataType": "enum", + "default": "STOP", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "STOP": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Smart Saving Stop (Smart Grid)" + }, + "RUN": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Smart Saving Running (Smart Grid)" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "activeSaving": { + "_comment": "Active Saving Status", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Active Saving OFF" + }, + "ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Active Saving ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "ecoFriendly": { + "_comment": "Eco Friendly Status", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [ + 0, + 1 + ] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Eco Friendly OFF" + }, + "ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Eco Friendly ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "fridgeTemp_C": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "controlIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "valueMapping": { + "1": { + "index": 1, + "label": "7", + "_comment": "" + }, + "2": { + "index": 2, + "label": "6", + "_comment": "" + }, + "3": { + "index": 3, + "label": "5", + "_comment": "" + }, + "4": { + "index": 4, + "label": "4", + "_comment": "" + }, + "5": { + "index": 5, + "label": "3", + "_comment": "" + }, + "6": { + "index": 6, + "label": "2", + "_comment": "" + }, + "7": { + "index": 7, + "label": "1", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "fridgeTemp_F": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "controlIndex": [] + }, + "valueMapping": { + "1": { + "index": 1, + "label": "46", + "_comment": "" + }, + "2": { + "index": 2, + "label": "45", + "_comment": "" + }, + "3": { + "index": 3, + "label": "44", + "_comment": "" + }, + "4": { + "index": 4, + "label": "43", + "_comment": "" + }, + "5": { + "index": 5, + "label": "42", + "_comment": "" + }, + "6": { + "index": 6, + "label": "41", + "_comment": "" + }, + "7": { + "index": 7, + "label": "40", + "_comment": "" + }, + "8": { + "index": 8, + "label": "39", + "_comment": "" + }, + "9": { + "index": 9, + "label": "38", + "_comment": "" + }, + "10": { + "index": 10, + "label": "37", + "_comment": "" + }, + "11": { + "index": 11, + "label": "36", + "_comment": "" + }, + "12": { + "index": 12, + "label": "35", + "_comment": "" + }, + "13": { + "index": 13, + "label": "34", + "_comment": "" + }, + "14": { + "index": 14, + "label": "33", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "freezerTemp_C": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "controlIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + "valueMapping": { + "1": { + "index": 1, + "label": "-15", + "_comment": "" + }, + "2": { + "index": 2, + "label": "-16", + "_comment": "" + }, + "3": { + "index": 3, + "label": "-17", + "_comment": "" + }, + "4": { + "index": 4, + "label": "-18", + "_comment": "" + }, + "5": { + "index": 5, + "label": "-19", + "_comment": "" + }, + "6": { + "index": 6, + "label": "-20", + "_comment": "" + }, + "7": { + "index": 7, + "label": "-21", + "_comment": "" + }, + "8": { + "index": 8, + "label": "-22", + "_comment": "" + }, + "9": { + "index": 9, + "label": "-23", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "freezerTemp_F": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "controlIndex": [] + }, + "valueMapping": { + "0": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "" + }, + "1": { + "index": 1, + "label": "7", + "_comment": "" + }, + "2": { + "index": 2, + "label": "6", + "_comment": "" + }, + "3": { + "index": 3, + "label": "5", + "_comment": "" + }, + "4": { + "index": 4, + "label": "4", + "_comment": "" + }, + "5": { + "index": 5, + "label": "3", + "_comment": "" + }, + "6": { + "index": 6, + "label": "2", + "_comment": "" + }, + "7": { + "index": 7, + "label": "1", + "_comment": "" + }, + "8": { + "index": 8, + "label": "0", + "_comment": "" + }, + "9": { + "index": 9, + "label": "-1", + "_comment": "" + }, + "10": { + "index": 10, + "label": "-2", + "_comment": "" + }, + "11": { + "index": 11, + "label": "-3", + "_comment": "" + }, + "12": { + "index": 12, + "label": "-7", + "_comment": "" + }, + "13": { + "index": 13, + "label": "-12", + "_comment": "" + }, + "14": { + "index": 14, + "label": "-15", + "_comment": "" + }, + "15": { + "index": 15, + "label": "-17", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "convertibleTemp_C": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 0, + 1, + 2, + 3, + 5, + 7, + 9, + 11, + 12, + 13 + ], + "controlIndex": [] + }, + "valueMapping": { + "0": { + "index": 0, + "label": "-13", + "_comment": "" + }, + "1": { + "index": 1, + "label": "-13", + "_comment": "" + }, + "2": { + "index": 2, + "label": "-14", + "_comment": "" + }, + "3": { + "index": 3, + "label": "-15", + "_comment": "" + }, + "5": { + "index": 5, + "label": "-16", + "_comment": "" + }, + "7": { + "index": 7, + "label": "-17", + "_comment": "" + }, + "9": { + "index": 9, + "label": "-18", + "_comment": "" + }, + "11": { + "index": 11, + "label": "-19", + "_comment": "" + }, + "12": { + "index": 12, + "label": "-20", + "_comment": "" + }, + "13": { + "index": 13, + "label": "-21", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "convertibleTemp_F": { + "dataType": "enum", + "default": "1", + "_comment": "Temperature Unit :℉ or ℃ ", + "visibleItem": { + "monitoringIndex": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "controlIndex": [] + }, + "valueMapping": { + "0": { + "index": 0, + "label": "8", + "_comment": "" + }, + "1": { + "index": 1, + "label": "8", + "_comment": "" + }, + "2": { + "index": 2, + "label": "6", + "_comment": "" + }, + "3": { + "index": 3, + "label": "5", + "_comment": "" + }, + "4": { + "index": 4, + "label": "4", + "_comment": "" + }, + "5": { + "index": 5, + "label": "3", + "_comment": "" + }, + "6": { + "index": 6, + "label": "2", + "_comment": "" + }, + "7": { + "index": 7, + "label": "1", + "_comment": "" + }, + "8": { + "index": 8, + "label": "0", + "_comment": "" + }, + "9": { + "index": 9, + "label": "-1", + "_comment": "" + }, + "10": { + "index": 10, + "label": "-2", + "_comment": "" + }, + "11": { + "index": 11, + "label": "-3", + "_comment": "" + }, + "12": { + "index": 12, + "label": "-4", + "_comment": "" + }, + "13": { + "index": 13, + "label": "-6", + "_comment": "" + }, + "255": { + "index": 255, + "label": "IGNORE", + "_comment": "" + } + } + }, + "smartSavingModeCustomOpt": { + "dataType": "string" + }, + "smartCareV2": { + "_comment": "Smart CareV2 State (ON, OFF)", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [ + 0, + 1 + ] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Smart CareV2 OFF" + }, + "ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Smart CareV2 ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + }, + "expressFridge": { + "_comment": "Express Fridge Status", + "dataType": "enum", + "default": "OFF", + "visibleItem": { + "monitoringIndex": [ + 0, + 1 + ], + "controlIndex": [ + 0, + 1 + ] + }, + "valueMapping": { + "OFF": { + "index": 0, + "label": "@CP_OFF_EN_W", + "_comment": "Express Fridge OFF" + }, + "ON": { + "index": 1, + "label": "@CP_ON_EN_W", + "_comment": "Express Fridge ON" + }, + "IGNORE": { + "index": 255, + "label": "", + "_comment": "Please ignore me" + } + } + } + }, + "ControlWifi": { + "basicCtrl": { + "command": "Set", + "data": { + "refState": { + "fridgeTemp": "{{fridgeTemp}}", + "fridgeDoorOpen": "{{fridgeDoorOpen}}", + "freezerTemp": "{{freezerTemp}}", + "freezerDoorOpen": "{{freezerDoorOpen}}", + "convertibleTemp": "{{convertibleTemp}}", + "convertibleDoorOpen": "{{convertibleDoorOpen}}", + "didDoorOpen": "{{didDoorOpen}}", + "smartSavingMode": "{{smartSavingMode}}", + "smartSavingRun": "{{smartSavingRun}}", + "activeSaving": "{{activeSaving}}", + "ecoFriendly": "{{ecoFriendly}}", + "expressMode": "{{expressMode}}", + "tempUnit": "{{tempUnit}}", + "freshAirFilter": "{{freshAirFilter}}", + "waterFilter": "{{waterFilter}}", + "displayLock": "{{displayLock}}", + "sabbathMode": "{{sabbathMode}}", + "atLeastOneDoorOpen": "{{atLeastOneDoorOpen}}", + "expressFridge": "{{expressFridge}}" + } + } + }, + "getActiveSavingScheduleCtrl": { + "command": "Get", + "data": {} + }, + "getSmartFreshStorageScheduleCtrl": { + "command": "Get", + "data": {} + }, + "getActiveIcePlusScheduleCtrl": { + "command": "Get", + "data": {} + } + }, + "Push": [ + { + "category": "PUSH_REF_STATE", + "label": "@RE_SETTING_PUSH_PRODUCT_STATE_W", + "groupCode": "10101" + } + ], + "SmartMode": {} +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-washer-v2-cap.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-washer-v2-cap.json new file mode 100644 index 00000000000..044f72c1c89 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/thinq-washer-v2-cap.json @@ -0,0 +1,3235 @@ +{ + "Info": { + "productType": "WM", + "country": "WW", + "modelType": "FL", + "MP Project": "Vivace", + "ProjectName": "Vivace WO VC3 550 V700 1600 rpm ", + "modelName": "F_V8_Y___W.B_2QEUK", + "networkType": "WIFI", + "version": "0.8" + }, + "Module": { + "WPM": { + "GWM_CEN01_Main": "201", + "GWM_CRS01_Main": "201", + "GWM_CRS02_CourseList": "201", + "GWM_CRS03_CourseDetail": "201", + "GWM_WCH01_Main": "201", + "GWM_WCH01_UserGuide2": "201", + "GWM_ENM01_Main": "201", + "GCM_SDS01_SdsMain": "001", + "GWM_SET01_Main": "201", + "GWM_SET02_PushList": "201", + "GWM_FOT01_Main": "201" + }, + "Menu": [ + "GWM_CRS01_Main", + "GWM_WCH01_Main", + "GWM_ENM01_Main", + "GCM_SDS01_SdsMain", + "GWM_SET01_Main" + ] + }, + "Config": { + "downloadPanelLabel": "@WM_TERM_DOWNLOAD_CYCLE_EN_W", + "remoteStartLabel": "@WM_TITAN2_OPTION_REMOTE_START_W", + "maxDownloadCourseNum": 1, + "defaultCourse": "COTTON", + "downloadCourseAPId": "RINSESPIN", + "defaultSmartCourse": "RINSESPIN", + "tubCleanCourseId": "TUB_CLEAN", + "standbyEnable": true, + "fota": true, + "powerOffDownload": true, + "expectedStartTime": false, + "isAIDDAvailable": true, + "SmartCourseCategory": [ + { + "label": "@WM_COURSE_CATEGORY_HOME_FAMILY_W", + "courseIdList": [ + "BABYCARE", + "HYGIENE", + "SMALLLOAD", + "SKINCARE", + "RINSESPIN", + "KIDSWEAR", + "SCHOOLUNIFORM", + "RAINYSEASON", + "SINGLEGARMENT", + "NOISEMINIMIZE", + "MINIMIZEWRINKLES", + "LIGHTLYSOILEDITEMS", + "MINIMIZEDETERGENT", + "SLEEVEHEMSANDCOLLARS", + "JUICEANDFOODSTAINS", + "QUICKTUBCLEAN", + "DRAIN", + "SPIN" + ] + }, + { + "label": "@WM_COURSE_CATEGORY_SPORTS_LEISURE_W", + "courseIdList": [ + "SWIMMINGWEAR", + "GYMCLOTHES", + "SWEATSTAIN" + ] + }, + { + "label": "@WM_COURSE_CATEGORY_FABRIC_CARE_W", + "courseIdList": [ + "LINGERIE", + "COLDWASH", + "JEANS", + "BLANKET", + "COLORPROTECTION" + ] + } + ], + "smartCourseType": "smartCourseFL24inchBaseTitan", + "courseType": "courseFL24inchBaseTitan", + "downloadedCourseType": "downloadedCourseFL24inchBaseTitan" + }, + "MonitoringValue": { + "state": { + "dataType": "enum", + "label": null, + "valueMapping": { + "POWEROFF": { + "index": 0, + "label": "@WM_STATE_POWER_OFF_W" + }, + "INITIAL": { + "index": 1, + "label": "@WM_STATE_INITIAL_W" + }, + "PAUSE": { + "index": 2, + "label": "@WM_STATE_PAUSE_W" + }, + "RESERVED": { + "index": 3, + "label": "@WM_STATE_RESERVE_W" + }, + "DETECTING": { + "index": 4, + "label": "@WM_STATE_DETECTING_W" + }, + "RUNNING": { + "index": 6, + "label": "@WM_STATE_RUNNING_W" + }, + "RINSING": { + "index": 7, + "label": "@WM_STATE_RINSING_W" + }, + "SPINNING": { + "index": 8, + "label": "@WM_STATE_SPINNING_W" + }, + "DRYING": { + "index": 9, + "label": "@WM_STATE_DRYING_W" + }, + "END": { + "index": 10, + "label": "@WM_STATE_END_W" + }, + "COOLDOWN": { + "index": 11, + "label": "@WM_STATE_COOLDOWN_W" + }, + "RINSEHOLD": { + "index": 12, + "label": "@WM_STATE_RINSEHOLD_W" + }, + "WASH_REFRESHING": { + "index": 14, + "label": "@WM_STATE_WASH_REFRESHING_W" + }, + "STEAMSOFTENING": { + "index": 15, + "label": "@WM_STATE_STEAMSOFTENING_W" + }, + "DEMO": { + "index": 16, + "label": "@WM_STATE_DEMO_W" + }, + "ERROR": { + "index": 18, + "label": "@WM_STATE_ERROR_W" + } + } + }, + "preState": { + "dataType": "enum", + "label": null, + "valueMapping": { + "POWEROFF": { + "index": 0, + "label": "@WM_STATE_POWER_OFF_W" + }, + "INITIAL": { + "index": 1, + "label": "@WM_STATE_INITIAL_W" + }, + "PAUSE": { + "index": 2, + "label": "@WM_STATE_PAUSE_W" + }, + "RESERVED": { + "index": 3, + "label": "@WM_STATE_RESERVE_W" + }, + "DETECTING": { + "index": 4, + "label": "@WM_STATE_DETECTING_W" + }, + "RUNNING": { + "index": 6, + "label": "@WM_STATE_RUNNING_W" + }, + "RINSING": { + "index": 7, + "label": "@WM_STATE_RINSING_W" + }, + "SPINNING": { + "index": 8, + "label": "@WM_STATE_SPINNING_W" + }, + "DRYING": { + "index": 9, + "label": "@WM_STATE_DRYING_W" + }, + "END": { + "index": 10, + "label": "@WM_STATE_END_W" + }, + "COOLDOWN": { + "index": 11, + "label": "@WM_STATE_COOLDOWN_W" + }, + "RINSEHOLD": { + "index": 12, + "label": "@WM_STATE_RINSEHOLD_W" + }, + "WASH_REFRESHING": { + "index": 14, + "label": "@WM_STATE_WASH_REFRESHING_W" + }, + "STEAMSOFTENING": { + "index": 15, + "label": "@WM_STATE_STEAMSOFTENING_W" + }, + "DEMO": { + "index": 16, + "label": "@WM_STATE_DEMO_W" + }, + "ERROR": { + "index": 18, + "label": "@WM_STATE_ERROR_W" + } + } + }, + "remoteStart": { + "dataType": "enum", + "label": "@WM_OPTION_REMOTE_START_W", + "valueMapping": { + "REMOTE_START_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "REMOTE_START_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "initialBit": { + "dataType": "enum", + "label": null, + "valueMapping": { + "INITIAL_BIT_OFF": { + "index": 0, + "label": "INITIAL_BIT_OFF" + }, + "INITIAL_BIT_ON": { + "index": 1, + "label": "INITIAL_BIT_ON" + } + } + }, + "AIDDLed": { + "dataType": "enum", + "valueMapping": { + "AIDDLed_OFF": { + "index": 0, + "label": "AIDDLed_OFF" + }, + "AIDDLed_ON": { + "index": 1, + "label": "AIDDLed_ON" + } + } + }, + "childLock": { + "dataType": "enum", + "label": "@WM_OPTION_CHILDLOCK_W", + "valueMapping": { + "CHILDLOCK_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "CHILDLOCK_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "TCLCount": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 60 + } + }, + "reserveTimeHour": { + "dataType": "range", + "label": "@WM_TITAN2_OPTION_DELAY_END_W", + "valueMapping": { + "min": 3, + "max": 19 + } + }, + "reserveTimeMinute": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 59 + } + }, + "remainTimeHour": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 30 + } + }, + "remainTimeMinute": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 59 + } + }, + "initialTimeHour": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 30 + } + }, + "initialTimeMinute": { + "dataType": "range", + "label": null, + "valueMapping": { + "min": 0, + "max": 59 + } + }, + "soilWash": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_WASH_W", + "valueMapping": { + "NO_SOILWASH": { + "index": 0, + "label": "@WM_TERM_NO_SELECT_W" + }, + "SOILWASH_TURBO_WASH": { + "index": 1, + "label": "@WM_TITAN2_OPTION_TURBO_WASH_W" + }, + "SOILWASH_TIMESAVE": { + "index": 2, + "label": "@WM_TITAN2_OPTION_WASH_TIMESAVE_W" + }, + "SOILWASH_NORMAL": { + "index": 3, + "label": "@WM_TITAN2_OPTION_WASH_NORMAL_W" + }, + "SOILWASH_INTENSIVE": { + "index": 4, + "label": "@WM_TITAN2_OPTION_WASH_INTENSIVE_W" + } + } + }, + "spin": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_SPIN_SPEED_W", + "valueMapping": { + "NOT_SELECTED": { + "index": 0, + "label": "@WM_TERM_NO_SELECT_W" + }, + "NO_SPIN": { + "index": 1, + "label": "@WM_TITAN2_OPTION_SPIN_NO_SPIN_W" + }, + "SPIN_400": { + "index": 2, + "label": "@WM_TITAN2_OPTION_SPIN_400_W" + }, + "SPIN_600": { + "index": 3, + "label": "@WM_TITAN2_OPTION_SPIN_600_W" + }, + "SPIN_700": { + "index": 4, + "label": "@WM_TITAN2_OPTION_SPIN_700_W" + }, + "SPIN_800": { + "index": 5, + "label": "@WM_TITAN2_OPTION_SPIN_800_W" + }, + "SPIN_900": { + "index": 6, + "label": "@WM_TITAN2_OPTION_SPIN_900_W" + }, + "SPIN_1000": { + "index": 7, + "label": "@WM_TITAN2_OPTION_SPIN_1000_W" + }, + "SPIN_1100": { + "index": 8, + "label": "@WM_TITAN2_OPTION_SPIN_1100_W" + }, + "SPIN_1200": { + "index": 9, + "label": "@WM_TITAN2_OPTION_SPIN_1200_W" + }, + "SPIN_1400": { + "index": 10, + "label": "@WM_TITAN2_OPTION_SPIN_1400_W" + }, + "SPIN_1600": { + "index": 11, + "label": "@WM_TITAN2_OPTION_SPIN_1600_W" + }, + "SPIN_Max": { + "index": 255, + "label": "@WM_TITAN2_OPTION_SPIN_MAX_W" + } + } + }, + "temp": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_TEMP_W", + "valueMapping": { + "NO_TEMP": { + "index": 0, + "label": "@WM_TERM_NO_SELECT_W" + }, + "TEMP_COLD": { + "index": 1, + "label": "@WM_TITAN2_OPTION_TEMP_COLD_W" + }, + "TEMP_20": { + "index": 2, + "label": "@WM_TITAN2_OPTION_TEMP_20_W" + }, + "TEMP_30": { + "index": 3, + "label": "@WM_TITAN2_OPTION_TEMP_30_W" + }, + "TEMP_40": { + "index": 4, + "label": "@WM_TITAN2_OPTION_TEMP_40_W" + }, + "TEMP_50": { + "index": 5, + "label": "@WM_TITAN2_OPTION_TEMP_50_W" + }, + "TEMP_60": { + "index": 6, + "label": "@WM_TITAN2_OPTION_TEMP_60_W" + }, + "TEMP_95": { + "index": 7, + "label": "@WM_TITAN2_OPTION_TEMP_95_W" + } + } + }, + "rinse": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_RINSE_W", + "valueMapping": { + "NO_RINSE": { + "index": 0, + "label": "@WM_TERM_NO_SELECT_W" + }, + "RINSE_NORMAL": { + "index": 1, + "label": "@WM_TITAN2_OPTION_RINSE_NORMAL_W" + }, + "RINSE_PLUS": { + "index": 2, + "label": "@WM_TITAN2_OPTION_RINSE_RINSE+_W" + }, + "RINSE_PLUSPLUS": { + "index": 3, + "label": "@WM_TITAN2_OPTION_RINSE_RINSE++_W" + }, + "RINSE_NORMAL_HOLD": { + "index": 4, + "label": "@WM_TITAN2_OPTION_RINSE_NORMALHOLD_W" + }, + "RINSE_PLUS_HOLD": { + "index": 5, + "label": "@WM_TITAN2_OPTION_RINSE_RINSE+HOLD_W" + } + } + }, + "turboWash": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_TURBO_WASH_W", + "valueMapping": { + "TURBOWASH_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "TURBOWASH_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "steam": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_STEAM_W", + "valueMapping": { + "STEAM_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "STEAM_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "preWash": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_PRE_WASH_W", + "valueMapping": { + "PREWASH_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "PREWASH_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "medicRinse": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_MEDIC_RINSE_W", + "valueMapping": { + "MEDICRINSE_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "MEDICRINSE_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "steamSoftener": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_STEAM_SOFTENER_W", + "valueMapping": { + "STEAMSOFTENER_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "STEAMSOFTENER_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "loadItemWasher": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_LOAD_ITEM_W", + "valueMapping": { + "LOADITEM_OFF": { + "index": 0, + "label": "0" + }, + "LOADITEM_1": { + "index": 1, + "label": "1" + }, + "LOADITEM_2": { + "index": 2, + "label": "2" + }, + "LOADITEM_3": { + "index": 3, + "label": "3" + } + } + }, + "standby": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_STANDBY_W", + "valueMapping": { + "STANDBY_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "STANDBY_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "creaseCare": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_CREASE_CARE_W", + "valueMapping": { + "CREASECARE_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "CREASECARE_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "proofing": { + "dataType": "enum", + "label": "@WM_TITAN2_OPTION_PROOFING_W", + "valueMapping": { + "PROOFING_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "PROOFING_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "courseFL24inchBaseTitan": { + "ref": "Course" + }, + "error": { + "dataType": "enum", + "valueMapping": { + "ERROR_NO": { + "_comment": "No Error", + "index": 0, + "label": "ERROR_NOERROR", + "title": "ERROR_NOERROR_TITLE", + "content": "ERROR_NOERROR_CONTENT" + }, + "ERROR_DE2": { + "_comment": "DE2 Error", + "index": 1, + "label": "@WM_WW_FL_ERROR_DE2_W", + "title": "@WM_WW_FL_ERROR_DE2_TITLE_W", + "content": "@WM_WW_FL_ERROR_DE2_CONTENT_S" + }, + "ERROR_DE1": { + "_comment": "DE1 Error", + "index": 2, + "label": "@WM_WW_FL_ERROR_DE1_W", + "title": "@WM_WW_FL_ERROR_DE1_TITLE_W", + "content": "@WM_WW_FL_ERROR_DE1_CONTENT_S" + }, + "ERROR_IE": { + "_comment": "IE Error", + "index": 3, + "label": "@WM_WW_FL_ERROR_IE_W", + "title": "@WM_WW_FL_ERROR_IE_TITLE_W", + "content": "@WM_WW_FL_ERROR_IE_CONTENT_S" + }, + "ERROR_OE": { + "_comment": "OE Error", + "index": 4, + "label": "@WM_WW_FL_ERROR_OE_W", + "title": "@WM_WW_FL_ERROR_OE_TITLE_W", + "content": "@WM_WW_FL_ERROR_OE_CONTENT_S" + }, + "ERROR_UE": { + "_comment": "UE Error", + "index": 5, + "label": "@WM_WW_FL_ERROR_UE_W", + "title": "@WM_WW_FL_ERROR_UE_TITLE_W", + "content": "@WM_WW_FL_ERROR_UE_CONTENT_S" + }, + "ERROR_FE": { + "_comment": "FE Error", + "index": 6, + "label": "@WM_WW_FL_ERROR_FE_W", + "title": "@WM_WW_FL_ERROR_FE_TITLE_W", + "content": "@WM_WW_FL_ERROR_FE_CONTENT_S" + }, + "ERROR_PE": { + "_comment": "PE Error", + "index": 7, + "label": "@WM_WW_FL_ERROR_PE_W", + "title": "@WM_WW_FL_ERROR_PE_TITLE_W", + "content": "@WM_WW_FL_ERROR_PE_CONTENT_S" + }, + "ERROR_TE": { + "_comment": "tE error", + "index": 8, + "label": "@WM_WW_FL_ERROR_TE_W", + "title": "@WM_WW_FL_ERROR_TE_TITLE_W", + "content": "@WM_WW_FL_ERROR_TE_CONTENT_S" + }, + "ERROR_LE": { + "_comment": "LE error", + "index": 9, + "label": "@WM_WW_FL_ERROR_LE_W", + "title": "@WM_WW_FL_ERROR_LE_TITLE_W", + "content": "@WM_WW_FL_ERROR_LE_CONTENT_S" + }, + "ERROR_DHE": { + "_comment": "dHE error", + "index": 11, + "label": "@WM_WW_FL_ERROR_DHE_W", + "title": "@WM_WW_FL_ERROR_DHE_TITLE_W", + "content": "@WM_WW_FL_ERROR_DHE_CONTENT_S" + }, + "ERROR_PF": { + "_comment": "PF error", + "index": 12, + "label": "@WM_WW_FL_ERROR_PF_W", + "title": "@WM_WW_FL_ERROR_PF_TITLE_W", + "content": "@WM_WW_FL_ERROR_PF_CONTENT_S" + }, + "ERROR_FF": { + "_comment": "FF error", + "index": 13, + "label": "@WM_WW_FL_ERROR_FF_W", + "title": "@WM_WW_FL_ERROR_FF_TITLE_W", + "content": "@WM_WW_FL_ERROR_FF_CONTENT_S" + }, + "ERROR_DCE": { + "_comment": "dCE Error", + "index": 14, + "label": "@WM_WW_FL_ERROR_DCE_W", + "title": "@WM_WW_FL_ERROR_DCE_TITLE_W", + "content": "@WM_WW_FL_ERROR_DCE_CONTENT_S" + }, + "ERROR_AE": { + "_comment": "AE Error (AquaLock)", + "index": 15, + "label": "@WM_WW_FL_ERROR_AE_W", + "title": "@WM_WW_FL_ERROR_AE_TITLE_W", + "content": "@WM_WW_FL_ERROR_AE_CONTENT_S" + }, + "ERROR_EE": { + "_comment": "EE error", + "index": 16, + "label": "@WM_WW_FL_ERROR_EE_W", + "title": "@WM_WW_FL_ERROR_EE_TITLE_W", + "content": "@WM_WW_FL_ERROR_EE_CONTENT_S" + }, + "ERROR_PS": { + "_comment": "PS Error", + "index": 17, + "label": "@WM_WW_FL_ERROR_PS_W", + "title": "@WM_WW_FL_ERROR_PS_TITLE_W", + "content": "@WM_WW_FL_ERROR_PS_CONTENT_S" + }, + "ERROR_DE4": { + "_comment": "dE4 Error", + "index": 18, + "label": "@WM_WW_FL_ERROR_DE4_W", + "title": "@WM_WW_FL_ERROR_DE4_TITLE_W", + "content": "@WM_WW_FL_ERROR_DE4_CONTENT_S" + }, + "ERROR_VS": { + "_comment": "vS Error", + "index": 19, + "label": "@WM_WW_FL_ERROR_VS_W", + "title": "@WM_WW_FL_ERROR_VS_TITLE_W", + "content": "@WM_WW_FL_ERROR_VS_CONTENT_S" + } + } + }, + "smartCourseFL24inchBaseTitan": { + "ref": "SmartCourse" + }, + "doorLock": { + "dataType": "enum", + "label": null, + "valueMapping": { + "DOOR_LOCK_OFF": { + "index": 0, + "label": "@CP_OFF_EN_W" + }, + "DOOR_LOCK_ON": { + "index": 1, + "label": "@CP_ON_EN_W" + } + } + }, + "downloadedCourseFL24inchBaseTitan": { + "ref": "SmartCourse" + } + }, + "ControlWifi": { + "WMStart": { + "command": "Set", + "data": { + "washerDryer": { + "course": "temp", + "soilWash": "NO_SOILWASH", + "spin": "NOT_SELECTED", + "temp": "NO_TEMP", + "rinse": "NO_RINSE", + "reserveTimeHour": 0, + "reserveTimeMinute": 0, + "loadItemWasher": "LOADITEM_OFF", + "turboWash": "TURBOWASH_OFF", + "creaseCare": "CREASECARE_OFF", + "steamSoftener": "STEAMSOFTENER_OFF", + "ecoHybrid": "ECOHYBRID_OFF", + "medicRinse": "MEDICRINSE_OFF", + "rinseSpin": "RINSE_SPIN_OFF", + "preWash": "PREWASH_OFF", + "steam": "STEAM_OFF", + "initialBit": "INITIAL_BIT_OFF", + "remoteStart": "REMOTE_START_OFF", + "doorLock": "DOOR_LOCK_OFF", + "childLock": "CHILDLOCK_OFF", + "SmartCourse": "temp" + } + } + }, + "WMDownload": { + "command": "Set", + "data": { + "washerDryer": { + "courseDownloadType": "COURSEDATA", + "courseDownloadDataLength": 21, + "course": "temp", + "soilWash": "NO_SOILWASH", + "spin": "NOT_SELECTED", + "temp": "NO_TEMP", + "rinse": "NO_RINSE", + "reserveTimeHour": 0, + "reserveTimeMinute": 0, + "loadItemWasher": "LOADITEM_OFF", + "turboWash": "TURBOWASH_OFF", + "creaseCare": "CREASECARE_OFF", + "steamSoftener": "STEAMSOFTENER_OFF", + "ecoHybrid": "ECOHYBRID_OFF", + "medicRinse": "MEDICRINSE_OFF", + "rinseSpin": "RINSE_SPIN_OFF", + "preWash": "PREWASH_OFF", + "steam": "STEAM_OFF", + "initialBit": "INITIAL_BIT_OFF", + "remoteStart": "REMOTE_START_OFF", + "doorLock": "DOOR_LOCK_OFF", + "childLock": "CHILDLOCK_OFF", + "SmartCourse": "temp" + } + } + }, + "WMOff": { + "command": "Set", + "data": { + "washerDryer": { + "controlDataType": "POWEROFF", + "controlDataValueLength": 1, + "controlDataValue": 0 + } + } + }, + "WMStop": { + "command": "Set", + "data": { + "washerDryer": { + "controlDataType": "PAUSE", + "controlDataValueLength": 1, + "controlDataValue": 0 + } + } + }, + "WMWakeup": { + "command": "Set", + "data": { + "washerDryer": { + "controlDataType": "WAKEUP", + "controlDataValueLength": 0 + } + } + } + }, + "Course": { + "COTTON": { + "_comment": "Cotton", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_COTTON_W", + "script": "", + "controlEnable": true, + "courseValue": "COTTON", + "imgIndex": 141, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40", + "TEMP_60", + "TEMP_95" + ] + }, + { + "value": "spin", + "default": "SPIN_Max", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "EASYCARE": { + "_comment": "Easy Care", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_EASY_CARE_W", + "script": "", + "controlEnable": true, + "courseValue": "EASYCARE", + "imgIndex": 145, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40", + "TEMP_60" + ] + }, + { + "value": "spin", + "default": "SPIN_Max", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "COTTONPLUS": { + "_comment": "Eco 40-60", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_ECO_40_60_W", + "script": "", + "controlEnable": true, + "courseValue": "COTTONPLUS", + "imgIndex": 148, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_40", + "TEMP_60" + ] + }, + { + "value": "spin", + "default": "SPIN_Max", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "DUVET": { + "_comment": "Duvet", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_DUVET_W", + "script": "", + "controlEnable": true, + "courseValue": "DUVET", + "imgIndex": 202, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_COLD", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40" + ] + }, + { + "value": "spin", + "default": "SPIN_1000", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "MIXEDFABRIC": { + "_comment": "Mix", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_MIX_W", + "script": "", + "controlEnable": true, + "courseValue": "MIXEDFABRIC", + "imgIndex": 142, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40", + "TEMP_60" + ] + }, + { + "value": "spin", + "default": "SPIN_1000", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SPORTSWEAR": { + "_comment": "Sports Wear", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_SPORTS_WEAR_W", + "script": "", + "controlEnable": true, + "courseValue": "SPORTSWEAR", + "imgIndex": 51, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40" + ] + }, + { + "value": "spin", + "default": "SPIN_800", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SILENTWASH": { + "_comment": "Silent Wash", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_SILENT_WASH_W", + "script": "", + "controlEnable": true, + "courseValue": "SILENTWASH", + "imgIndex": 26, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40", + "TEMP_60" + ] + }, + { + "value": "spin", + "default": "SPIN_800", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SPEED14": { + "_comment": "Speed 14", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_SPEED_14_W", + "script": "", + "controlEnable": true, + "courseValue": "SPEED14", + "imgIndex": 143, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_20", + "selectable": [ + "TEMP_20", + "TEMP_30", + "TEMP_40" + ] + }, + { + "value": "spin", + "default": "SPIN_400", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_ON" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "TUB_CLEAN": { + "_comment": "Tub Clean", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_TUB_CLEAN_W", + "script": "", + "controlEnable": true, + "courseValue": "TUB_CLEAN", + "imgIndex": 152, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL", + "showing": "@WM_TERM_NO_SELECT_W" + }, + { + "value": "temp", + "default": "TEMP_60", + "showing": "@WM_TERM_NO_SELECT_W" + }, + { + "value": "spin", + "default": "NO_SPIN", + "showing": "@WM_TERM_NO_SELECT_W" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "showing": "@WM_TERM_NO_SELECT_W" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + } + ] + }, + "WOOL": { + "_comment": "Wool", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_WOOL_W", + "script": "", + "controlEnable": true, + "courseValue": "WOOL", + "imgIndex": 99, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_30", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40" + ] + }, + { + "value": "spin", + "default": "SPIN_800", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "DELICATE": { + "_comment": "Delicate", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_DELICATE_W", + "script": "", + "controlEnable": true, + "courseValue": "DELICATE", + "imgIndex": 149, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_20", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40" + ] + }, + { + "value": "spin", + "default": "SPIN_800", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "ALLERGYSPASTEAM": { + "_comment": "Allergy SpaSteam", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_ALLERGY_SPASTEAM_W", + "script": "", + "controlEnable": true, + "courseValue": "ALLERGYSPASTEAM", + "imgIndex": 147, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_60" + }, + { + "value": "spin", + "default": "SPIN_Max", + "selectable": [ + "NO_SPIN", + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_ON" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "TURBO39": { + "_comment": "Turbo Wash 39", + "courseType": "Course", + "name": "@WM_WW_FL_TITAN2_COURSE_TURBO_39_W", + "script": "", + "controlEnable": true, + "courseValue": "TURBO39", + "imgIndex": 212, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "temp", + "default": "TEMP_40", + "selectable": [ + "TEMP_COLD", + "TEMP_20", + "TEMP_30", + "TEMP_40", + "TEMP_60" + ] + }, + { + "value": "spin", + "default": "SPIN_1200", + "selectable": [ + "SPIN_400", + "SPIN_800", + "SPIN_1000", + "SPIN_1200", + "SPIN_Max" + ] + }, + { + "value": "rinse", + "default": "RINSE_NORMAL", + "selectable": [ + "RINSE_NORMAL", + "RINSE_PLUS" + ] + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_ON" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + } + }, + "SmartCourse": { + "BABYCARE": { + "_comment": "baby_care", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "BABYCARE", + "name": "@WM_WW_FL_SMARTCOURSE_BABY_CARE_W", + "script": "@WM_WW_FL_SMARTCOURSE_BABY_CARE_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 52, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1000" + }, + { + "value": "temp", + "default": "TEMP_60" + }, + { + "value": "rinse", + "default": "RINSE_PLUS" + }, + { + "value": "preWash", + "default": "PREWASH_ON" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_ON", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "HYGIENE": { + "_comment": "Hygiene", + "Course": "ALLERGYCARE", + "courseType": "SmartCourse", + "courseValue": "HYGIENE", + "name": "@WM_WW_FL_SMARTCOURSE_HYGIENE_W", + "script": "@WM_WW_FL_SMARTCOURSE_HYGIENE_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 36, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_60" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_ON" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SMALLLOAD": { + "_comment": "Small Load", + "Course": "SPEED14", + "courseType": "SmartCourse", + "courseValue": "SMALLLOAD", + "name": "@WM_WW_FL_SMARTCOURSE_SMALL_LOAD_W", + "script": "@WM_WW_FL_SMARTCOURSE_SMALL_LOAD_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 46, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_ON" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "LINGERIE": { + "_comment": "Lingerie", + "Course": "DELICATE", + "courseType": "SmartCourse", + "courseValue": "LINGERIE", + "name": "@WM_WW_FL_SMARTCOURSE_LINGERIE_W", + "script": "@WM_WW_FL_SMARTCOURSE_LINGERIE_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 13, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_800" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SKINCARE": { + "_comment": "skin_care", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "SKINCARE", + "name": "@WM_WW_FL_SMARTCOURSE_SKIN_CARE_W", + "script": "@WM_WW_FL_SMARTCOURSE_SKIN_CARE_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 16, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_PLUS" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_ON", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "COLDWASH": { + "_comment": "Cold Wash", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "COLDWASH", + "name": "@WM_WW_FL_SMARTCOURSE_COLD_WASH_W", + "script": "@WM_WW_FL_SMARTCOURSE_COLD_WASH_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 22, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_COLD" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "RINSESPIN": { + "_comment": "Rinse + Spin", + "Course": "RINSESPIN", + "courseType": "SmartCourse", + "courseValue": "RINSESPIN", + "name": "@WM_WW_FL_SMARTCOURSE_RINSE_SPIN_W", + "script": "@WM_WW_FL_SMARTCOURSE_RINSE_SPIN_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 60, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "NO_TEMP" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "KIDSWEAR": { + "_comment": "Kids Wear", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "KIDSWEAR", + "name": "@WM_WW_FL_SMARTCOURSE_KIDS_WEAR_W", + "script": "@WM_WW_FL_SMARTCOURSE_KIDS_WEAR_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 53, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1200" + }, + { + "value": "temp", + "default": "TEMP_60" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_ON" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SCHOOLUNIFORM": { + "_comment": "School Uniform", + "Course": "EASYCARE", + "courseType": "SmartCourse", + "courseValue": "SCHOOLUNIFORM", + "name": "@WM_WW_FL_SMARTCOURSE_SCHOOL_UNIFORM_W", + "script": "@WM_WW_FL_SMARTCOURSE_SCHOOL_UNIFORM_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 130, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1000" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SWIMMINGWEAR": { + "_comment": "Swimming Wear", + "Course": "WOOL", + "courseType": "SmartCourse", + "courseValue": "SWIMMINGWEAR", + "name": "@WM_WW_FL_SMARTCOURSE_SWIMMING_WEAR_W", + "script": "@WM_WW_FL_SMARTCOURSE_SWIMMING_WEAR_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 54, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "RAINYSEASON": { + "_comment": "Rainy Season", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "RAINYSEASON", + "name": "@WM_WW_FL_SMARTCOURSE_RAINY_SEASON_W", + "script": "@WM_WW_FL_SMARTCOURSE_RAINY_SEASON_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 55, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "GYMCLOTHES": { + "_comment": "Gym Clothes", + "Course": "SPORTSWEAR", + "courseType": "SmartCourse", + "courseValue": "GYMCLOTHES", + "name": "@WM_WW_FL_SMARTCOURSE_GYM_CLOTHES_W", + "script": "@WM_WW_FL_SMARTCOURSE_GYM_CLOTHES_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 56, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_800" + }, + { + "value": "temp", + "default": "TEMP_COLD" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "JEANS": { + "_comment": "Jeans", + "Course": "DELICATE", + "courseType": "SmartCourse", + "courseValue": "JEANS", + "name": "@WM_WW_FL_SMARTCOURSE_JEANS_W", + "script": "@WM_WW_FL_SMARTCOURSE_JEANS_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 76, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "BLANKET": { + "_comment": "Blanket", + "Course": "DUVET", + "courseType": "SmartCourse", + "courseValue": "BLANKET", + "name": "@WM_WW_FL_SMARTCOURSE_BLANKET_W", + "script": "@WM_WW_FL_SMARTCOURSE_BLANKET_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 57, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_COLD" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SWEATSTAIN": { + "_comment": "Sweat Stain", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "SWEATSTAIN", + "name": "@WM_WW_FL_SMARTCOURSE_SWEAT_STAIN_W", + "script": "@WM_WW_FL_SMARTCOURSE_SWEAT_STAIN_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 58, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SINGLEGARMENT": { + "_comment": "Single Garment", + "Course": "SPEED14", + "courseType": "SmartCourse", + "courseValue": "SINGLEGARMENT", + "name": "@WM_WW_FL_SMARTCOURSE_SINGLE_GARMENT_W", + "script": "@WM_WW_FL_SMARTCOURSE_SINGLE_GARMENT_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 59, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_ON" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "COLORPROTECTION": { + "_comment": "Color Protection", + "Course": "DELICATE", + "courseType": "SmartCourse", + "courseValue": "COLORPROTECTION", + "name": "@WM_WW_FL_SMARTCOURSE_COLOR_PROTECTION_W", + "script": "@WM_WW_FL_SMARTCOURSE_COLOR_PROTECTION_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 47, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_800" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "NOISEMINIMIZE": { + "_comment": "Noise Minimize", + "Course": "SILENTWASH", + "courseType": "SmartCourse", + "courseValue": "NOISEMINIMIZE", + "name": "@WM_WW_FL_SMARTCOURSE_NOISE_MINIMIZE_W", + "script": "@WM_WW_FL_SMARTCOURSE_NOISE_MINIMIZE_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 88, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1000" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "MINIMIZEWRINKLES": { + "_comment": "Minimize Wrinkles", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "MINIMIZEWRINKLES", + "name": "@WM_WW_FL_SMARTCOURSE_MINIMIZE_WRINKLES_W", + "script": "@WM_WW_FL_SMARTCOURSE_MINIMIZE_WRINKLES_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 80, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "LIGHTLYSOILEDITEMS": { + "_comment": "Lightly Soiled Items", + "Course": "WOOL", + "courseType": "SmartCourse", + "courseValue": "LIGHTLYSOILEDITEMS", + "name": "@WM_WW_FL_SMARTCOURSE_LIGHTLY_SOILED_ITEMS_W", + "script": "@WM_WW_FL_SMARTCOURSE_LIGHTLY_SOILED_ITEMS_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 43, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_20" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "MINIMIZEDETERGENT": { + "_comment": "Minimize Detergent Residue", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "MINIMIZEDETERGENT", + "name": "@WM_WW_FL_SMARTCOURSE_MINIMIZE_DETERGENT_RESIDUE_W", + "script": "@WM_WW_FL_SMARTCOURSE_MINIMIZE_DETERGENT_RESIDUE_1_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 30, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_PLUS" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SLEEVEHEMSANDCOLLARS": { + "_comment": "Sleeve Hems and Collars", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "SLEEVEHEMSANDCOLLARS", + "name": "@WM_WW_FL_SMARTCOURSE_SLEEVE_HEMS_AND_COLLARS_W", + "script": "@WM_WW_FL_SMARTCOURSE_SLEEVE_HEMS_AND_COLLARS_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 37, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1000" + }, + { + "value": "temp", + "default": "TEMP_60" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_ON" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "JUICEANDFOODSTAINS": { + "_comment": "Juice and Food Stains", + "Course": "COTTON", + "courseType": "SmartCourse", + "courseValue": "JUICEANDFOODSTAINS", + "name": "@WM_WW_FL_SMARTCOURSE_JUICE_AND_FOOD_STAINS_W", + "script": "@WM_WW_FL_SMARTCOURSE_JUICE_AND_FOOD_STAINS_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 108, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_1000" + }, + { + "value": "temp", + "default": "TEMP_40" + }, + { + "value": "rinse", + "default": "RINSE_NORMAL" + }, + { + "value": "preWash", + "default": "PREWASH_ON" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "QUICKTUBCLEAN": { + "_comment": "quick_tub_clean", + "Course": "QUICKTUBCLEAN", + "courseType": "SmartCourse", + "courseValue": "QUICKTUBCLEAN", + "name": "@WM_WW_FL_SMARTCOURSE_QUICK_TUB_CLEAN_W", + "script": "@WM_WW_FL_SMARTCOURSE_QUICK_TUB_CLEAN_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 38, + "function": [ + { + "value": "soilWash", + "default": "SOILWASH_NORMAL" + }, + { + "value": "spin", + "default": "SPIN_400" + }, + { + "value": "temp", + "default": "TEMP_COLD" + }, + { + "value": "rinse", + "default": "NO_RINSE" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + } + ] + }, + "DRAIN": { + "_comment": "Drain", + "Course": "SPINONLY", + "courseType": "SmartCourse", + "courseValue": "DRAIN", + "name": "@WM_WW_FL_SMARTCOURSE_DRAIN_W", + "script": "@WM_WW_FL_SMARTCOURSE_DRAIN_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 217, + "function": [ + { + "value": "soilWash", + "default": "NO_SOILWASH" + }, + { + "value": "spin", + "default": "NO_SPIN" + }, + { + "value": "temp", + "default": "NO_TEMP" + }, + { + "value": "rinse", + "default": "NO_RINSE" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + }, + "SPIN": { + "_comment": "Spin", + "Course": "SPINONLY", + "courseType": "SmartCourse", + "courseValue": "SPIN", + "name": "@WM_WW_FL_SMARTCOURSE_SPIN_W", + "script": "@WM_WW_FL_SMARTCOURSE_SPIN_SCRIPT_S", + "downloadEnable": true, + "controlEnable": true, + "imgIndex": 27, + "function": [ + { + "value": "soilWash", + "default": "NO_SOILWASH" + }, + { + "value": "spin", + "default": "SPIN_Max" + }, + { + "value": "temp", + "default": "NO_TEMP" + }, + { + "value": "rinse", + "default": "NO_RINSE" + }, + { + "value": "preWash", + "default": "PREWASH_OFF" + }, + { + "value": "turboWash", + "default": "TURBOWASH_OFF" + }, + { + "value": "steam", + "default": "STEAM_OFF" + }, + { + "value": "medicRinse", + "default": "MEDICRINSE_OFF", + "visibility": "gone" + }, + { + "value": "loadItemWasher", + "default": "LOADITEM_OFF", + "visibility": "gone" + }, + { + "value": "reserveTimeHour", + "default": 0 + } + ] + } + }, + "Push": [ + { + "category": "PUSH_WM_STATE", + "label": "@CP_ALARM_PRODUCT_STATE_W", + "groupCode": "20101", + "pushList": [ + { + "0000": "PUSH_WM_COMPLETE" + }, + { + "0001": "PUSH_WM_REMOTE_ANOTHER_ID" + }, + { + "0100": "PUSH_WM_ERROR" + }, + { + "0200": "PUSH_WM_REMOTE_START_OFF" + }, + { + "0201": "PUSH_WM_REMOTE_START_ON" + } + ] + } + ], + "EnergyMonitoring": { + "valueMapping": [ + "temp", + "spin", + "soilWash" + ], + "powertable": { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6 + }, + "watertable": null + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/user-info-response-1.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/user-info-response-1.json new file mode 100644 index 00000000000..8ade4e18af7 --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/user-info-response-1.json @@ -0,0 +1,52 @@ +{ + "status": 1, + "account": { + "userID": "%s", + "userNo": "BR2005200239023", + "userIDType": "LGE", + "displayUserID": "faker", + "userIDList": [ + { + "lgeIDList": [ + { + "lgeIDType": "LGE", + "userID": "%s" + } + ] + } + ], + "dateOfBirth": "05-05-1978", + "country": "BR", + "countryName": "Brazil", + "blacklist": "N", + "age": "45", + "isSubscribe": "N", + "changePw": "N", + "toEmailId": "N", + "periodPW": "N", + "lgAccount": "Y", + "isService": "Y", + "userNickName": "faker", + "authUser": "N", + "serviceList": [ + { + "isService": "Y", + "svcName": "LG ThinQ", + "svcCode": "SVC202", + "joinDate": "29-05-2018" + }, + { + "isService": "Y", + "svcName": "LG Developer", + "svcCode": "SVC609", + "joinDate": "29-05-2018" + }, + { + "isService": "Y", + "svcName": "MC OAuth", + "svcCode": "SVC710", + "joinDate": "29-05-2018" + } + ] + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.lgthinq/src/test/resources/wm-data-result.json b/bundles/org.openhab.binding.lgthinq/src/test/resources/wm-data-result.json new file mode 100644 index 00000000000..5744eb9036c --- /dev/null +++ b/bundles/org.openhab.binding.lgthinq/src/test/resources/wm-data-result.json @@ -0,0 +1,118 @@ +{ + "resultCode": "0000", + "result": { + "appType": "NUTS", + "modelCountryCode": "WW", + "countryCode": "DK", + "modelName": "F_R7_Y___W.A__QEUK", + "deviceType": 201, + "deviceCode": "LA02", + "alias": "Frontbetjent vaskemaskine", + "deviceId": "592bd2a4-d3e7-16e9-a69f-44cb8b2e0c43", + "fwVer": "", + "imageFileName": "home_appliances_img_wmdrum.png", + "ssid": "Kepler", + "softapId": "", + "softapPass": "", + "macAddress": "", + "networkType": "02", + "timezoneCode": "Europe/Copenhagen", + "timezoneCodeAlias": "Europe/Copenhagen", + "utcOffset": 1, + "utcOffsetDisplay": "+01:00", + "dstOffset": 2, + "dstOffsetDisplay": "+02:00", + "curOffset": 1, + "curOffsetDisplay": "+01:00", + "sdsGuide": "{\"deviceCode\":\"LA02\"}", + "newRegYn": "N", + "remoteControlType": "", + "userNo": "DK2202075642801", + "tftYn": "N", + "deviceState": "E", + "snapshot": { + "washerDryer": { + "initialBit": "INITIAL_BIT_OFF", + "standby": "STANDBY_OFF", + "courseFL24inchBaseTitan": "MIXEDFABRIC", + "initialTimeMinute": 24.0, + "preState": "SPINNING", + "error": "ERROR_NO", + "dryLevel": "NOT_SELECTED", + "creaseCare": "CREASECARE_OFF", + "remainTimeHour": 0.0, + "smartCourseFL24inchBaseTitan": "NOT_SELECTED", + "preWash": "PREWASH_OFF", + "steam": "STEAM_OFF", + "state": "SPINNING", + "rinse": "NO_RINSE", + "wrinkleCare": "WRINKLECARE_OFF", + "loadItemWasher": "LOADITEM_OFF", + "temp": "NO_TEMP", + "doorLock": "DOOR_LOCK_ON", + "reserveTimeMinute": 0.0, + "AIDDLed": "AIDDLed_OFF", + "TCLCount": 33.0, + "downloadedCourseFL24inchBaseTitan": "RINSESPIN", + "medicRinse": "MEDICRINSE_OFF", + "turboWash": "TURBOWASH_OFF", + "ecoHybrid": "ECOHYBRID_OFF", + "remainTimeMinute": 11.0, + "reserveTimeHour": 0.0, + "steamSoftener": "STEAMSOFTENER_OFF", + "childLock": "CHILDLOCK_OFF", + "remoteStart": "REMOTE_START_ON", + "spin": "SPIN_1400", + "soilWash": "NO_SOILWASH", + "rinseSpin": "RINSE_SPIN_OFF", + "initialTimeHour": 1.0 + }, + "mid": 8.4022883E7, + "online": true, + "static": { + "deviceType": "201", + "countryCode": "DK" + }, + "meta": { + "allDeviceInfoUpdate": false, + "messageId": "TSmRTV6yTUq2obot8_Q9Qg" + }, + "timestamp": 1.644358361572E12 + }, + "online": true, + "platformType": "thinq2", + "area": 125955, + "regDt": 2.0220208013031E13, + "blackboxYn": "Y", + "modelProtocol": "courseFL24inchBaseTitan", + "receipeVersion": 0, + "activeSaving": "OFF", + "smartCareV2": "OFF", + "order": 0, + "nlpAlias": "none", + "drServiceYn": "N", + "fwInfoList": [ + { + "checksum": "016771B3", + "partNumber": "SAA41059310", + "order": 2.0 + }, + { + "checksum": "000075A3", + "partNumber": "SAA41059211", + "order": 1.0 + } + ], + "regDtUtc": "20220207233031", + "regIndex": 0, + "groupableYn": "N", + "controllableYn": "N", + "combinedProductYn": "N", + "masterYn": "Y", + "controlGuideType": "TYPE4", + "initDevice": false, + "upgradableYn": "N", + "autoFwDownloadYn": "N", + "tclcount": 0 + } +} \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index eeb3f3d435f..69ddd335169 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -231,6 +231,7 @@ org.openhab.binding.leapmotion org.openhab.binding.lghombot org.openhab.binding.lgtvserial + org.openhab.binding.lgthinq org.openhab.binding.lgwebos org.openhab.binding.lifx org.openhab.binding.linky