[lgthinq] Initial contribution (#12149)

* [lgthinq][feat] Initial contribution

Signed-off-by: Nemer Daud <nemer.daud@gmail.com>
Co-authored-by: Julio Vilmar Gesser <jgesser@gmail.com>
Co-authored-by: Nemer_Daud <nemer@smartsw.com.br>
pull/18466/head
Nemer Daud 2025-03-29 10:24:57 -03:00 committed by GitHub
parent 55b3117e1a
commit 0cebd324eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 22265 additions and 0 deletions

View File

@ -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

View File

@ -971,6 +971,11 @@
<artifactId>org.openhab.binding.lghombot</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.lgthinq</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.lgtvserial</artifactId>

View File

@ -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

View File

@ -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.<br/> 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.<br/> 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="<ac-model-url>", deviceId="<device-id>", platformType="<platform-type>", modelId="<model-id>", deviceAlias="<MyAC>" ]
}
```
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" <switch> { channel="lgthinq:air-conditioner-401:myAC:dashboard#power" }
Number ACOpMode "Operation Mode" <text> { channel="lgthinq:air-conditioner-401:myAC:dashboard#op-mode" }
Number:Temperature ACTargetTemp "Target Temperature" <text> { channel="lgthinq:air-conditioner-401:myAC:dashboard#target-temperature" }
Number:Temperature ACCurrTemp "Temperature" <text> { channel="lgthinq:air-conditioner-401:myAC:dashboard#current-temperature" }
Number ACFanSpeed "Fan Speed" <text> { channel="lgthinq:air-conditioner-401:myAC:dashboard#fan-speed" }
Switch ACCoolJet "CoolJet" <switch> { channel="lgthinq:air-conditioner-401:myAC:dashboard#cool-jet" }
Switch ACAutoDry "Auto Dry" <switch> { channel="lgthinq:air-conditioner-401:myAC:dashboard#auto-dry" }
Switch ACEnSaving "Energy Saving" <switch> { channel="lgthinq:air-conditioner-401:myAC:dashboard#emergy-saving" }
Number ACFanVDir "Vertical Direction" <text> { 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
}
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.lgthinq</artifactId>
<name>openHAB Add-ons :: Bundles :: LG Thinq Binding</name>
<dependencies>
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.32.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.lgthinq-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>
mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
</repository>
<feature name="openhab-binding-lgthinq" description="LG ThingQ Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.lgthinq/${project.version}</bundle>
</feature>
</features>

View File

@ -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<ThingTypeUID> 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<ThingTypeUID> 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";
}

View File

@ -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;
}
}

View File

@ -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}.
*
* <p>
* Supported device types include:
* </p>
* <ul>
* <li>Air Conditioners</li>
* <li>Heat Pumps</li>
* <li>Washing Machines & Towers</li>
* <li>Dryers & Dryer Towers</li>
* <li>Refrigerators</li>
* <li>Dishwashers</li>
* <li>Bridges</li>
* </ul>
*
* @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;
}
}

View File

@ -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;
}
}

View File

@ -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<LGThinQBridgeHandler> {
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<String, Object> 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();
}
}

View File

@ -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<String, Object> 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<String, Object> 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() {
}
}

View File

@ -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<ThingStatusDetail> 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<AsyncCommandParams> 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<S> snapshotClass = getSnapshotClass();
try {
Constructor<S> 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<S> 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<S>) 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<String, String> 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<Item> 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<String, Object> 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<String, Object> 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 <b>must be pre-defined in the thing definition (xml) and with
* the same name as the channel.</b>
*
* @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();
}
}
}

View File

@ -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<ACCapability, ACCanonicalSnapshot> {
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<StateOption> 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<StateOption> 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<StateOption> 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<StateOption> 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<ACCapability, ACCanonicalSnapshot> 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<String, Object> collectExtraInfoState() throws LGThinqException {
ExtendedDeviceInfo info = lgThinqACApiClientService.getExtendedDeviceInfo(getBridgeId(), getDeviceId());
Map<String, Object> result = mapper.convertValue(info, new TypeReference<>() {
});
return result == null ? Collections.emptyMap() : result;
}
@Override
protected void updateExtraInfoStateChannels(Map<String, Object> 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);
}
}
}
}

View File

@ -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<? extends CapabilityDefinition, ? extends SnapshotDefinition> thing);
/**
* Unregistry the thing
*
* @param thing to be unregistered
*/
void unRegistryListenerThing(
LGThinQAbstractDeviceHandler<? extends CapabilityDefinition, ? extends SnapshotDefinition> thing);
}

View File

@ -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<String, LGThinQAbstractDeviceHandler<? extends CapabilityDefinition, ? extends SnapshotDefinition>> lGDeviceRegister = new ConcurrentHashMap<>();
private final Map<String, LGDevice> 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<? extends CapabilityDefinition, ? extends SnapshotDefinition> 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<? extends CapabilityDefinition, ? extends SnapshotDefinition> thing) {
lGDeviceRegister.remove(thing.getDeviceId());
}
@Override
public Collection<ConfigStatusMessage> getConfigStatus() {
List<ConfigStatusMessage> 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<String, Object> 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<Class<? extends ThingHandlerService>> 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<String, LGDevice> lastDevicesDiscoveredCopy = new HashMap<>(lastDevicesDiscovered);
List<LGDevice> 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<? extends CapabilityDefinition, ? extends SnapshotDefinition> deviceThing = lGDeviceRegister
.get(deviceId);
if (deviceThing != null) {
deviceThing.onDeviceRemoved();
}
if (discoveryService != DUMMY_DISCOVERY_SERVICE && deviceThing != null) {
discoveryService.removeLgDeviceDiscovery(device);
}
});
lGDeviceRegister.values().forEach(LGThinQAbstractDeviceHandler::refreshStatus);
}
}
}

View File

@ -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<DishWasherCapability, DishWasherSnapshot> {
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<StateOption> 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<StateOption> 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<StateOption> 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<StateOption> 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<DishWasherCapability, DishWasherSnapshot> 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"));
}
}

View File

@ -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<FridgeCapability, FridgeCanonicalSnapshot> {
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<Temperature> 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<Temperature> 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<String, String> 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<String, String> conversionMap = getConversionMap(ch, refCap);
final Map<String, String> 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<String, String> getConversionMap(ChannelUID ch, FridgeCapability refCap) {
Map<String, String> 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<FridgeCapability, FridgeCanonicalSnapshot> 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<Temperature> 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<String, String> cap, ChannelUID channelUID) {
final List<StateOption> faOptions = new ArrayList<>();
cap.forEach((k, v) -> faOptions.add(new StateOption(k, v)));
stateDescriptionProvider.setStateOptions(channelUID, faOptions);
}
private void loadChannelTempStateOption(Map<String, String> cap, ChannelUID channelUID, Unit<Temperature> unTemp) {
final List<StateOption> faOptions = new ArrayList<>();
cap.forEach((k, v) -> {
try {
QuantityType<Temperature> 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<String, Object> 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);
}
}
}
}

View File

@ -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<WasherDryerCapability, WasherDryerSnapshot> {
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<Channel> 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<StateOption> 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<StateOption> 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<StateOption> optionsTemp = new ArrayList<>();
wmCap.getTemperatureFeat().getValuesMapping()
.forEach((k, v) -> optionsTemp.add(new StateOption(k, keyIfValueNotFound(CAP_WMD_TEMPERATURE, v))));
stateDescriptionProvider.setStateOptions(temperatureChannelUID, optionsTemp);
List<StateOption> optionsDoor = new ArrayList<>();
optionsDoor.add(new StateOption("0", "Unlocked"));
optionsDoor.add(new StateOption("1", "Locked"));
stateDescriptionProvider.setStateOptions(doorLockChannelUID, optionsDoor);
List<StateOption> optionsSpin = new ArrayList<>();
wmCap.getSpinFeat().getValuesMapping()
.forEach((k, v) -> optionsSpin.add(new StateOption(k, keyIfValueNotFound(CAP_WM_SPIN, v))));
stateDescriptionProvider.setStateOptions(spinChannelUID, optionsSpin);
List<StateOption> optionsRinse = new ArrayList<>();
wmCap.getRinseFeat().getValuesMapping()
.forEach((k, v) -> optionsRinse.add(new StateOption(k, keyIfValueNotFound(CAP_WM_RINSE, v))));
stateDescriptionProvider.setStateOptions(rinseChannelUID, optionsRinse);
List<StateOption> 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<StateOption> optionsChildLock = new ArrayList<>();
optionsChildLock.add(new StateOption("CHILDLOCK_OFF", "Unlocked"));
optionsChildLock.add(new StateOption("CHILDLOCK_ON", "Locked"));
stateDescriptionProvider.setStateOptions(childLockChannelUID, optionsChildLock);
List<StateOption> 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<WasherDryerCapability, WasherDryerSnapshot> 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<Channel> 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<StateOption> options = new ArrayList<>();
for (String v : f.getSelectableValues()) {
Map<String, String> 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<String, Object> 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<String, Object> rawData = lastShot.getRawData();
Map<String, Object> 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<String, Object> 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"));
}
}

View File

@ -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<String> 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<String, String> 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<String> RE_FAHRENHEIT_UNIT_VALUES = Set.of("02", "2", "F", "FAHRENHEIT",
RE_TEMP_UNIT_FAHRENHEIT_SYMBOL);
public static final Set<String> RE_DOOR_OPEN_VALUES = Set.of("1", "01", "OPEN");
public static final Set<String> 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<String, String> 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<String, String> CAP_RE_ON_OFF = Map.of("@CP_OFF_EN_W", "Off", "@CP_ON_EN_W", "On");
public static final Map<String, String> CAP_RE_LABEL_ON_OFF = Map.of("OFF", "Off", "ON", "On", "IGNORE",
"Not Available");
public static final Map<String, String> CAP_RE_LABEL_CLOSE_OPEN = Map.of("CLOSE", "Closed", "OPEN", "Open",
"IGNORE", "Not Available");
public static final Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, Map<String, String>> 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<String, String> CAP_DW_DOOR_STATE = Map.of("@CP_OFF_EN_W", "Close", "@CP_ON_EN_W",
"Opened");
public static final Map<String, String> 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<String, String> 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"));
}

View File

@ -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.
* <p>
* 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.
* </p>
*
* @author Nemer Daud - Initial contribution
*/
@NonNullByDefault
public interface LGThinQACApiClientService extends LGThinQApiClientService<ACCapability, ACCanonicalSnapshot> {
/**
* 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;
}

View File

@ -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<ACCapability, ACCanonicalSnapshot> 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.
* <b>It works only for API V2 device versions!</b>
*
* @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);
}
}
}

View File

@ -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<ACCapability, ACCanonicalSnapshot> 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, <b>works only on V1 API supported devices</b>.
*
* @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);
}
}
}

View File

@ -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<C extends CapabilityDefinition, S extends AbstractSnapshotDefinition>
implements LGThinQApiClientService<C, S> {
protected final ObjectMapper objectMapper = new ObjectMapper();
protected final TokenManager tokenManager;
protected final Class<C> capabilityClass;
protected final Class<S> snapshotClass;
protected final HttpClient httpClient;
private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiClientService.class);
private final String clientId = "";
protected LGThinQAbstractApiClientService(Class<C> capabilityClass, Class<S> 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<String, String> getCommonHeaders(String language, String country, String accessToken, String userNumber) {
Map<String, String> 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<LGDevice> 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<String, String> 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.
* <b>It works only for API V2 device versions!</b>
*
* @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<String, Object> 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<String, String> 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<String, Object> 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<String, Object> genericHandleDeviceSettingsResult(RestResult resp, ObjectMapper objectMapper)
throws LGThinqApiException {
Map<String, Object> deviceSettings;
Map<String, String> 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<String, Object>) deviceSettings.get("result"),
"Unexpected json result asking for Device Settings. Node 'result' no present");
}
private List<LGDevice> handleListAccountDevicesResult(RestResult resp) throws LGThinqApiException {
Map<String, Object> devicesResult;
List<LGDevice> 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<Map<String, Object>> items = (List<Map<String, Object>>) ((Map<String, Object>) 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<String, String> 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<String, Object> 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<String, Object> 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.
* <b>It works only for API V2 device versions!</b>
*
* @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<String, Object> deviceSettings = getDeviceSettings(bridgeName, deviceId);
if (deviceSettings.get("snapshot") != null) {
Map<String, Object> snapMap = (Map<String, Object>) 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, <b>works only on V1 API supported devices</b>.
*
* @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<String, String> 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<String, Object> 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<String, String> 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<String, String> 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<String, Object> handleGenericErrorResult(@Nullable RestResult resp)
throws LGThinqApiException;
}

View File

@ -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<C extends CapabilityDefinition, S extends AbstractSnapshotDefinition>
extends LGThinQAbstractApiClientService<C, S> {
private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiV1ClientService.class);
protected LGThinQAbstractApiV1ClientService(Class<C> capabilityClass, Class<S> 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<String, String> 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<String, Object> handleGenericErrorResult(@Nullable RestResult resp) throws LGThinqApiException {
Map<String, Object> metaResult;
Map<String, Object> 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<String, Object> prepareCommandV1(CommandDefinition cmdDef, Map<String, Object> snapData)
throws JsonProcessingException {
// expected map ordered here
String dataStr = cmdDef.getDataTemplate();
// Keep the order
for (Map.Entry<String, Object> e : snapData.entrySet()) {
String value = String.valueOf(e.getValue());
dataStr = dataStr.replace("{{" + e.getKey() + "}}", value);
}
return completeCommandDataNodeV1(cmdDef, dataStr);
}
protected LinkedHashMap<String, Object> completeCommandDataNodeV1(CommandDefinition cmdDef, String dataStr)
throws JsonProcessingException {
LinkedHashMap<String, Object> data = objectMapper.readValue(cmdDef.getRawCommand(), new TypeReference<>() {
});
logger.debug("Prepare command v1: {}", dataStr);
if (cmdDef.isBinary()) {
data.put("format", "B64");
List<Integer> 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;
}
}

View File

@ -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<C extends CapabilityDefinition, S extends AbstractSnapshotDefinition>
extends LGThinQAbstractApiClientService<C, S> {
private final Logger logger = LoggerFactory.getLogger(LGThinQAbstractApiV2ClientService.class);
protected LGThinQAbstractApiV2ClientService(Class<C> capabilityClass, Class<S> 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<String, String> 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<String, Object> handleGenericErrorResult(@Nullable RestResult resp) throws LGThinqApiException {
Map<String, Object> 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);
}
}
}
}

View File

@ -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 <C> The type representing the capability definition for a device.
* @param <S> The type representing a snapshot definition of device data.
* @author Nemer Daud - Initial contribution
*/
@NonNullByDefault
public interface LGThinQApiClientService<C extends CapabilityDefinition, S extends SnapshotDefinition> {
/**
* 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<LGDevice> 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<String, Object> 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;
}

View File

@ -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<GenericCapability, AbstractSnapshotDefinition> {
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<String, Object> handleGenericErrorResult(@Nullable RestResult resp) {
throw new UnsupportedOperationException();
}
}
private static final class GenericCapability extends AbstractCapability<GenericCapability> {
}
}

View File

@ -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<DishWasherCapability, DishWasherSnapshot> {
/**
* 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<String, Object> data);
}

View File

@ -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<DishWasherCapability, DishWasherSnapshot>
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<String, Object> data) {
throw new UnsupportedOperationException("Not implemented yet");
}
}

View File

@ -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<DishWasherCapability, DishWasherSnapshot>
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<String, Object> data) {
throw new UnsupportedOperationException("Not implemented yet");
}
}

View File

@ -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<FridgeCapability, FridgeCanonicalSnapshot> {
/**
* 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<String, Object> 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<String, Object> 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<String, Object> snapCmdData) throws LGThinqApiException;
}

View File

@ -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<FridgeCapability, FridgeCanonicalSnapshot>
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
}

View File

@ -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<FridgeCapability, FridgeCanonicalSnapshot>
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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
}

View File

@ -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<WasherDryerCapability, WasherDryerSnapshot> {
/**
* 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<String, Object> 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;
}

View File

@ -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<WasherDryerCapability, WasherDryerSnapshot>
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<String, Object> 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<String, Object> 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<String, Object> prepareCommandV1(CommandDefinition cmdDef, Map<String, Object> snapData)
throws JsonProcessingException {
// expected map ordered here
String dataStr = cmdDef.getDataTemplate();
for (Map.Entry<String, Object> 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<String, Object> cmd = completeCommandDataNodeV1(cmdDef, dataStr);
cmd.remove("encode");
return cmd;
}
}

View File

@ -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<WasherDryerCapability, WasherDryerSnapshot>
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<String, Object> 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<String, Object> 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);
}
}
}

View File

@ -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<String, Object> map = mapper.readValue(rawJson, new TypeReference<>() {
});
@SuppressWarnings("unchecked")
Map<String, String> content = (Map<String, String>) 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"));
}
}

View File

@ -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() ? "******" : "<blank>") + '\''
+ ", alternativeEmpServer='" + alternativeEmpServer + '\'' + ", accountVersion=" + accountVersion + '}';
}
}

View File

@ -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<String, String> 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<String, String> getGatewayRestHeader(String language, String country) {
return Map.ofEntries(new AbstractMap.SimpleEntry<String, String>("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<String, String> getLoginHeader(LGThinqGateway gw) {
Map<String, String> 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<String, String> 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<String, String> headers = getLoginHeader(gw);
// 1) Doing preLogin -> getting the password key
String preLoginUrl = gw.getLoginBaseUri() + LG_API_PRE_LOGIN_PATH;
Map<String, String> 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<String, String> 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<String, String> headers = getLoginHeader(gw);
headers.put("X-Signature", preLoginResult.signature());
headers.put("X-Timestamp", preLoginResult.timestamp());
Map<String, String> 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<String, Object> loginResult = MAPPER.readValue(resp.getJsonResponse(), new TypeReference<>() {
});
@SuppressWarnings("unchecked")
Map<String, String> accountResult = (Map<String, String>) 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<String, String> 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<String, String> 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<String, String> 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<String, String> getOauthEmpHeaders(LoginAccountResult accountResult, String timestamp,
byte[] oauthSig) {
Map<String, String> 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<String, String> 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<String, Object> 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<String, String> accountInfo = (Map<String, String>) 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<String, String> 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<String, String> 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<String, Object> 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<String, String> 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) {
}
}

View File

@ -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;
}
}

View File

@ -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<String, String> 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<String, String> headers,
@Nullable Map<String, String> 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<String, String> headers,
String jsonData) {
return postCall(httpClient, encodedUrl, headers, new StringContentProvider(jsonData));
}
@Nullable
public static RestResult postCall(HttpClient httpClient, String encodedUrl, Map<String, String> headers,
Map<String, String> 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<String, String> 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);
}
}
}

View File

@ -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<String, TokenResult> 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());
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<C extends CapabilityDefinition> implements CapabilityDefinition {
final Class<C> realClass;
// default result format
protected Map<String, Function<C, FeatureDefinition>> 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> monitoringBinaryProtocol = new ArrayList<>();
private Map<String, Object> rawData = new HashMap<>();
protected AbstractCapability() {
this.realClass = (Class<C>) ((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<String, Function<C, FeatureDefinition>> featureDefinitionMap) {
this.featureDefinitionMap = featureDefinitionMap;
}
@Override
public List<MonitoringBinaryProtocol> getMonitoringBinaryProtocol() {
return monitoringBinaryProtocol;
}
@Override
public void setMonitoringBinaryProtocol(List<MonitoringBinaryProtocol> 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<String, Object> getRawData() {
return rawData;
}
public void setRawData(Map<String, Object> rawData) {
this.rawData = rawData;
}
public Map<String, Map<String, Object>> getFeatureValuesRawData() {
switch (getDeviceVersion()) {
case V1_0:
return Objects.requireNonNullElse((Map<String, Map<String, Object>>) getRawData().get("Value"),
Collections.emptyMap());
case V2_0:
return Objects.requireNonNullElse(
(Map<String, Map<String, Object>>) 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<C, FeatureDefinition> f = featureDefinitionMap.get(featureName);
return f != null ? f.apply(realClass.cast(this)) : NULL_DEFINITION;
}
}

View File

@ -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<T extends CapabilityDefinition> {
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<MonitoringBinaryProtocol> 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,
* <b>only present in V1 devices</b>. 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<DeviceTypes> getSupportedDeviceTypes();
protected abstract List<LGAPIVerion> 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<String, CommandDefinition> 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<String, CommandDefinition> 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<String, CommandDefinition> commands = new HashMap<>();
for (Iterator<Map.Entry<String, JsonNode>> it = commandNode.fields(); it.hasNext();) {
Map.Entry<String, JsonNode> 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<String, Object> 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;
}
}

View File

@ -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<String, Object> otherInfo = new HashMap<>();
private Map<String, Object> 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<String, Object> getRawData() {
return rawData;
}
public void setRawData(Map<String, Object> rawData) {
this.rawData = rawData;
}
}

View File

@ -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.
* <p>
* 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.
* </p>
*
* @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<MonitoringBinaryProtocol> getMonitoringBinaryProtocol();
/**
* Sets the list of monitoring binary protocols supported by the device.
*
* @param monitoringBinaryProtocol The list of protocols to set.
*/
void setMonitoringBinaryProtocol(List<MonitoringBinaryProtocol> 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<String, Object> getRawData();
/**
* Sets the raw data for the device.
*
* @param rawData A {@link Map} containing raw data values.
*/
void setRawData(Map<String, Object> 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<String, Map<String, Object>> getFeatureValuesRawData();
/**
* Retrieves the feature definition based on its name from the device's JSON definition.
* <p>
* Example (for API v2):
*
* <pre>
* "MonitoringValue": {
* "spin": {
* "valueMapping": { ... }
* }
* }
* </pre>
* <p>
* Calling {@code getFeatureDefinition("spin")} will return the corresponding
* {@link FeatureDefinition} object representing the "spin" feature.
* </p>
*
* @param featureName The name of the feature in the JSON definition.
* @return A {@link FeatureDefinition} representing the specified feature.
*/
FeatureDefinition getFeatureDefinition(String featureName);
}

View File

@ -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.
* <p>
* 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.
* </p>
*
* @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<DeviceTypes, Map<LGAPIVerion, AbstractCapabilityFactory<? extends CapabilityDefinition>>> capabilityDeviceFactories = new HashMap<>();
/**
* Private constructor to initialize the factory registry.
* <p>
* This constructor registers all available capability factories for different
* device types and API versions.
* </p>
*/
private CapabilityFactory() {
List<AbstractCapabilityFactory<?>> 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<LGAPIVerion, AbstractCapabilityFactory<?>> 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.
* <p>
* 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.
* </p>
*
* @param <C> 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 extends CapabilityDefinition> C create(JsonNode rootNode, Class<C> 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<LGAPIVerion, AbstractCapabilityFactory<? extends CapabilityDefinition>> versionsFactory = capabilityDeviceFactories
.get(type);
if (versionsFactory == null || versionsFactory.isEmpty()) {
throw new IllegalStateException("Unexpected capability. The type " + type + " was not implemented yet");
}
AbstractCapabilityFactory<? extends CapabilityDefinition> 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));
}
}

View File

@ -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.
*
* <p>
* This class contains the following properties:
* <ul>
* <li><b>command:</b> A string representing the command tag value that the API uses to launch
* the command service.</li>
* <li><b>data:</b> A map holding additional data related to the command.</li>
* <li><b>cmdOptValue:</b> An optional value used only for LG ThinQ V1 commands.</li>
* <li><b>isBinary:</b> A boolean indicating whether the command operates in binary mode,
* used only for LG ThinQ V1 commands.</li>
* <li><b>dataTemplate:</b> A template for data that needs to be sent to the LG API,
* as defined in the device specification.</li>
* <li><b>rawCommand:</b> 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.</li>
* </ul>
* </p>
*
* <p>
* Usage example: A typical command definition might look like the following:
*
* <pre>
* {
* "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
* }
* </pre>
* </p>
*
* @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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

View File

@ -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 <S> The type parameter representing the Abstract Snapshot Definition
* @author Nemer Daud - Initial contribution
*/
@NonNullByDefault
public abstract class DefaultSnapshotBuilder<S extends AbstractSnapshotDefinition> implements SnapshotBuilder<S> {
protected static final ObjectMapper MAPPER = new ObjectMapper();
private static final Map<String, Map<String, Map<String, Object>>> MODEL_CACHED_BITKEY_DEF = new HashMap<>();
protected final Class<S> snapClass;
private final Logger logger = LoggerFactory.getLogger(DefaultSnapshotBuilder.class);
public DefaultSnapshotBuilder(Class<S> 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<MonitoringBinaryProtocol> prot, CapabilityDefinition capDef)
throws LGThinqUnmarshallException, LGThinqApiException {
try {
Map<String, Object> snapValues = new HashMap<>();
byte[] data = binaryData.getBytes();
BeanInfo beanInfo = Introspector.getBeanInfo(snapClass);
S snap = snapClass.getConstructor().newInstance();
PropertyDescriptor[] pds = beanInfo.getPropertyDescriptors();
Map<String, PropertyDescriptor> 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<String, Object> snapshotMap = MAPPER.readValue(snapshotDataJson, new TypeReference<>() {
});
Map<String, Object> 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<String, Object> deviceSettings, CapabilityDefinition capDef)
throws LGThinqApiException {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> getBitKey(String key, final Map<String, Map<String, Object>> capFeatureValues,
final Map<String, Map<String, Object>> cachedBitKey) {
// Define a local function to search for the bit key
Function<Map<String, Map<String, Object>>, Map<String, Object>> searchBitKey = data -> {
if (data.isEmpty()) {
return Collections.emptyMap();
}
for (int i = 1; i <= 3; i++) {
String optKey = "Option" + i;
Map<String, Object> option = data.get(optKey);
if (option == null) {
continue;
}
List<Map<String, Object>> optionList = MAPPER.convertValue(option.get("option"), new TypeReference<>() {
});
if (optionList == null) {
continue;
}
for (Map<String, Object> 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<String, Object> bitKey = new HashMap<>();
bitKey.put("option", optKey);
bitKey.put("startbit", startBit);
bitKey.put("length", length);
return bitKey;
}
}
}
return Collections.emptyMap();
};
Map<String, Object> 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<String, Object> 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<String, Map<String, Object>> cachedBitKey = getSpecificCacheBitKey(capDef);
Map<String, Object> 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<String, Map<String, Object>> getSpecificCacheBitKey(CapabilityDefinition capDef) {
return Objects
.requireNonNull(MODEL_CACHED_BITKEY_DEF.computeIfAbsent(capDef.getModelName(), k -> new HashMap<>()));
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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<String, String> 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<String, String> getValuesMapping() {
return valuesMapping;
}
public void setValuesMapping(Map<String, String> 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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<String, Object> rootMap) {
Map<String, String> 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<String, Object> mapper = MAPPER.convertValue(rootNode, new TypeReference<>() {
});
return getDeviceType(mapper);
}
public static LGAPIVerion discoveryAPIVersion(JsonNode rootNode) {
Map<String, Object> mapper = MAPPER.convertValue(rootNode, new TypeReference<>() {
});
return discoveryAPIVersion(mapper);
}
public static LGAPIVerion discoveryAPIVersion(Map<String, Object> rootMap) {
DeviceTypes type = getDeviceType(rootMap);
switch (type) {
case AIR_CONDITIONER:
case HEAT_PUMP:
Map<String, Object> 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");
}
}
}

View File

@ -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 = "";
}

View File

@ -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;
}
}

View File

@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* Usage Example:
*
* <pre>
* ResultCodes result = ResultCodes.fromCode("0000");
* System.out.println(result.getDescription()); // Outputs: "Success"
* </pre>
* </p>
*
* @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<String, String> 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<String> 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;
}
}

View File

@ -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.
*
* <p>
* 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}.
* </p>
*
* <p>
* Usage Example:
*
* <pre>
* SnapshotBuilder&lt;MySnapshot&gt; snapshotBuilder = new MySnapshotBuilder();
* MySnapshot snapshot = snapshotBuilder.createFromJson(jsonData, deviceType, capDef);
* </pre>
* </p>
*
* @param <S> the type of snapshot to be created, extending {@link SnapshotDefinition}
* @author Nemer Daud - Initial contribution
* @version 1.0
*/
@NonNullByDefault
public interface SnapshotBuilder<S extends SnapshotDefinition> {
/**
* 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<MonitoringBinaryProtocol> 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<String, Object> deviceSettings, CapabilityDefinition capDef) throws LGThinqApiException;
}

View File

@ -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<Class<? extends SnapshotDefinition>, SnapshotBuilder<? extends SnapshotDefinition>> internalBuilders = new HashMap<>();
private SnapshotBuilderFactory() {
}
public static SnapshotBuilderFactory getInstance() {
return INSTANCE;
}
public SnapshotBuilder<? extends SnapshotDefinition> getBuilder(Class<? extends SnapshotDefinition> snapDef) {
SnapshotBuilder<? extends SnapshotDefinition> 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;
}
}

View File

@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* Implementations of this interface should provide the actual data and logic for managing
* the power state and online status of a device.
* </p>
*
* <p>
* Usage Example:
*
* <pre>
* SnapshotDefinition snapshot = new MySnapshotImplementation();
* snapshot.setPowerStatus(DevicePowerState.ON);
* snapshot.setOnline(true);
* </pre>
* </p>
*
* @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);
}

View File

@ -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() + " }";
}
}

View File

@ -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<ACCapability> {
private Map<String, String> opMod = Collections.emptyMap();
private Map<String, String> fanSpeed = Collections.emptyMap();
private Map<String, String> stepUpDown = Collections.emptyMap();
private Map<String, String> 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<String, String> getStepLeftRight() {
return stepLeftRight;
}
public void setStepLeftRight(Map<String, String> stepLeftRight) {
this.stepLeftRight = stepLeftRight;
}
public Map<String, String> getStepUpDown() {
return stepUpDown;
}
public void setStepUpDown(Map<String, String> 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<String, String> getOpMode() {
return opMod;
}
public void setOpMod(Map<String, String> opMod) {
this.opMod = opMod;
}
public Map<String, String> getFanSpeed() {
return fanSpeed;
}
public void setFanSpeed(Map<String, String> 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;
}
}

View File

@ -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<LGAPIVerion> getSupportedAPIVersions() {
return List.of(LGAPIVerion.V1_0);
}
@Override
protected Map<String, CommandDefinition> getCommandsDefinition(JsonNode rootNode) {
return Collections.emptyMap();
}
@Override
protected Map<String, String> extractFeatureOptions(JsonNode optionsNode) {
Map<String, String> 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");
}
}

View File

@ -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<LGAPIVerion> getSupportedAPIVersions() {
return List.of(LGAPIVerion.V2_0);
}
@Override
protected Map<String, CommandDefinition> getCommandsDefinition(JsonNode rootNode) {
Map<String, CommandDefinition> 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<String, String> extractFeatureOptions(JsonNode optionsNode) {
Map<String, String> 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<String, CommandDefinition> cmd = getCommandsDefinition(rootNode);
// set energy and filter availability (extended info)
cap.setEnergyMonitorAvailable(cmd.containsKey("energyStateCtrl"));
cap.setFilterMonitorAvailable(cmd.containsKey("filterMngStateCtrl"));
return cap;
}
}

View File

@ -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;
};
}
}

View File

@ -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);
}
}
}

View File

@ -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<ACCanonicalSnapshot> {
public ACSnapshotBuilder() {
super(ACCanonicalSnapshot.class);
}
@Override
public ACCanonicalSnapshot createFromBinary(String binaryData, List<MonitoringBinaryProtocol> prot,
CapabilityDefinition capDef) throws LGThinqUnmarshallException, LGThinqApiException {
return super.createFromBinary(binaryData, prot, capDef);
}
@Override
protected ACCanonicalSnapshot getSnapshot(Map<String, Object> 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");
}
}
}

View File

@ -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;
}
}

View File

@ -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<ACCapability> {
private final Logger logger = LoggerFactory.getLogger(AbstractACCapabilityFactory.class);
@Override
public final List<DeviceTypes> getSupportedDeviceTypes() {
return List.of(DeviceTypes.AIR_CONDITIONER, HEAT_PUMP);
}
protected abstract Map<String, String> 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<String> extractValueOptions(JsonNode optionsNode) throws LGThinqApiException {
if (optionsNode.isMissingNode()) {
throw new LGThinqApiException("Error extracting options supported by the device");
} else {
List<String> values = new ArrayList<>();
optionsNode.fields().forEachRemaining(e -> {
values.add(e.getValue().asText());
});
return values;
}
}
private Map<String, String> extractOptions(JsonNode optionsNode, boolean invertKeyValue) {
if (optionsNode.isMissingNode()) {
logger.warn("Error extracting options supported by the device");
return Collections.emptyMap();
} else {
Map<String, String> modes = new HashMap<String, String>();
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<String, String> allOpModes = extractOptions(
valuesNode.path(getOpModeNodeName()).path(getOptionsMapNodeName()), true);
Map<String, String> allFanSpeeds = extractOptions(
valuesNode.path(getFanSpeedNodeName()).path(getOptionsMapNodeName()), true);
List<String> supOpModeValues = extractValueOptions(
valuesNode.path(getSupOpModeNodeName()).path(getOptionsMapNodeName()));
List<String> supFanSpeedValues = extractValueOptions(
valuesNode.path(getSupFanSpeedNodeName()).path(getOptionsMapNodeName()));
supOpModeValues.remove("@NON");
supOpModeValues.remove("@NON");
// find correct operation IDs
Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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();
}

View File

@ -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;
}
}
}

View File

@ -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<CourseFunction> 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<CourseFunction> getFunctions() {
return functions;
}
public void setFunctions(List<CourseFunction> functions) {
this.functions = functions;
}
}

View File

@ -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<String> 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<String> getSelectableValues() {
return selectableValues;
}
public void setSelectableValues(List<String> selectableValues) {
this.selectableValues = selectableValues;
}
}

View File

@ -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;
}
}

View File

@ -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<String, CourseDefinition> getGenericCourseDefinitions(JsonNode courseNode, CourseType type,
String notSelectedCourseKey) {
Map<String, CourseDefinition> 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<CourseFunction> 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<String> 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;
}
}

View File

@ -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<String, String> 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;
}
}

View File

@ -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<DishWasherCapability> {
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<String, CourseDefinition> courses = new HashMap<>(getCourseDefinitions(coursesNode));
Map<String, CourseDefinition> smartCourses = new HashMap<>(getSmartCourseDefinitions(smartCoursesNode));
Map<String, CourseDefinition> convertedAllCourses = new HashMap<>();
// change the Key to the reverse MapCourses coming from LG API
BiConsumer<Map<String, CourseDefinition>, 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<String, CourseDefinition> getCourseDefinitions(JsonNode courseNode) {
return Utils.getGenericCourseDefinitions(courseNode, CourseType.COURSE, getNotSelectedCourseKey());
}
protected Map<String, CourseDefinition> getSmartCourseDefinitions(JsonNode smartCourseNode) {
return Utils.getGenericCourseDefinitions(smartCourseNode, CourseType.SMART_COURSE, getNotSelectedCourseKey());
}
protected abstract String getNotSelectedCourseKey();
@Override
public final List<DeviceTypes> getSupportedDeviceTypes() {
return List.of(DeviceTypes.DISH_WASHER);
}
protected abstract String getCourseNodeName();
protected abstract String getSmartCourseNodeName();
protected abstract String getMonitorValueNodeName();
}

View File

@ -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<DishWasherCapability> {
private FeatureDefinition doorState = FeatureDefinition.NULL_DEFINITION;
private FeatureDefinition state = FeatureDefinition.NULL_DEFINITION;
private FeatureDefinition processState = FeatureDefinition.NULL_DEFINITION;
private Map<String, CourseDefinition> courses = new LinkedHashMap<>();
public FeatureDefinition getProcessState() {
return processState;
}
public void setProcessState(FeatureDefinition processState) {
this.processState = processState;
}
public Map<String, CourseDefinition> getCourses() {
return courses;
}
public void setCourses(Map<String, CourseDefinition> 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;
}
}

View File

@ -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<LGAPIVerion> 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<String, CommandDefinition> getCommandsDefinition(JsonNode rootNode) {
return Collections.emptyMap();
}
@Override
protected String getNotSelectedCourseKey() {
return "NOT_SELECTED";
}
@Override
protected String getMonitorValueNodeName() {
return "Value";
}
}

View File

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More