mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
Merge 071c1e7391
into d36b2a8d82
This commit is contained in:
commit
e0115f8126
@ -1101,6 +1101,11 @@
|
||||
<artifactId>org.openhab.binding.mffan</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mideaac</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.miele</artifactId>
|
||||
|
13
bundles/org.openhab.binding.mideaac/NOTICE
Normal file
13
bundles/org.openhab.binding.mideaac/NOTICE
Normal file
@ -0,0 +1,13 @@
|
||||
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
|
120
bundles/org.openhab.binding.mideaac/README.md
Normal file
120
bundles/org.openhab.binding.mideaac/README.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Midea AC Binding
|
||||
|
||||
This binding integrates Air Conditioners that use the Midea protocol. Midea is an OEM for many brands.
|
||||
|
||||
An AC device is likely supported if it uses one of the following Android apps or it's iOS equivalent.
|
||||
|
||||
| Application | Comment | Options |
|
||||
|--:-------------------------------------------|--:------------------------------------|--------------|
|
||||
| Midea Air (com.midea.aircondition.obm) | Full Support of key and token updates | Midea Air |
|
||||
| NetHome Plus (com.midea.aircondition) | Full Support of key and token updates | NetHome Plus |
|
||||
| SmartHome/MSmartHome (com.midea.ai.overseas) | Full Support of key and token updates | MSmartHome |
|
||||
|
||||
Note: The Air Conditioner must already be set-up on the WiFi network and have a fixed IP Address with one of the three apps listed above for full discovery and key and token updates.
|
||||
|
||||
## Supported Things
|
||||
|
||||
This binding supports one Thing type `ac`.
|
||||
|
||||
## Discovery
|
||||
|
||||
Once the Air Conditioner is on the network (WiFi active) the other required parameters can be discovered automatically.
|
||||
An IP broadcast message is sent and every responding unit gets added to the Inbox.
|
||||
As an alternative use the python application msmart-ng from <https://github.com/mill1000/midea-msmart> with the msmart-ng discover ipAddress option.
|
||||
|
||||
## Binding Configuration
|
||||
|
||||
No binding configuration is required.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
| Parameter | Required ? | Comment | Default |
|
||||
|--:----------|--:----------|--:----------------------------------------------------------------|---------|
|
||||
| ipAddress | Yes | IP Address of the device. | |
|
||||
| ipPort | Yes | IP port of the device | 6444 |
|
||||
| deviceId | Yes | ID of the device. Leave 0 to do ID discovery (length 6 bytes). | 0 |
|
||||
| cloud | Yes for V.3 | Cloud Provider name for email and password | |
|
||||
| email | No | Email for cloud account chosen in Cloud Provider. | |
|
||||
| password | No | Password for cloud account chosen in Cloud Provider. | |
|
||||
| token | Yes for V.3 | Secret Token (length 128 HEX) | |
|
||||
| key | Yes for V.3 | Secret Key (length 64 HEX) | |
|
||||
| pollingTime | Yes | Polling time in seconds. Minimum time is 30 seconds. | 60 |
|
||||
| timeout | Yes | Connecting timeout. Minimum time is 2 second, maximum 10 seconds. | 4 |
|
||||
| promptTone | Yes | "Ding" tone when command is received and executed. | False |
|
||||
| version | Yes | Version 3 has token, key and cloud requirements. | 0 |
|
||||
|
||||
## Channels
|
||||
|
||||
Following channels are available:
|
||||
|
||||
| Channel | Type | Description | Read only | Advanced |
|
||||
|--:---------------------------|--:-----------------|--:-----------------------------------------------------------------------------------------------------|--:--------|--:-------|
|
||||
| power | Switch | Turn the AC on and off. | | |
|
||||
| target-temperature | Number:Temperature | Target temperature. | | |
|
||||
| operational-mode | String | Operational mode: OFF (turns off), AUTO, COOL, DRY, HEAT, FAN ONLY | | |
|
||||
| fan-speed | String | Fan speed: OFF (turns off), SILENT, LOW, MEDIUM, HIGH, AUTO. Not all modes supported by all units. | | |
|
||||
| swing-mode | String | Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Not all modes supported by all units. | | |
|
||||
| eco-mode | Switch | Eco mode - Cool only (Temperature is set to 24 C (75 F) and fan on AUTO) | | |
|
||||
| turbo-mode | Switch | Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. COOL and HEAT mode only. | | |
|
||||
| sleep-function | Switch | Sleep function ("Moon with a star" icon on IR Remote Controller). | | |
|
||||
| indoor-temperature | Number:Temperature | Indoor temperature measured in the room, where internal unit is installed. | Yes | |
|
||||
| outdoor-temperature | Number:Temperature | Outdoor temperature by external unit. Some units do not report reading when off. | Yes | |
|
||||
| temperature-unit | Switch | Sets the LED display on the evaporator to Fahrenheit (true) or Celsius (false). | | Yes |
|
||||
| on-timer | String | Sets the future time to turn on the AC. | | Yes |
|
||||
| off-timer | String | Sets the future time to turn off the AC. | | Yes |
|
||||
| screen-display | Switch | If device supports across LAN, turns off the LED display. | | Yes |
|
||||
| humidity | Number | If device supports, the indoor humidity. | Yes | Yes |
|
||||
| appliance-error | Switch | If device supports, appliance error | Yes | Yes |
|
||||
| auxiliary-heat | Switch | If device supports, auxiliary heat | Yes | Yes |
|
||||
| alternate-target-temperature | Number:Temperature | Alternate Target Temperature - not currently used | Yes | Yes |
|
||||
|
||||
## Examples
|
||||
|
||||
### `demo.things` Example
|
||||
|
||||
```java
|
||||
Thing mideaac:ac:mideaac "myAC" @ "Room" [ ipAddress="192.168.1.200", ipPort="6444", deviceId="deviceId", cloud="your cloud (e.g NetHome Plus)", email="yourclouduser@email.com", password="yourcloudpassword", token="token", key ="key", pollingTime = 60, timeout=4, promptTone="false", version="3"]
|
||||
```
|
||||
|
||||
Option to use the built-in binding discovery of ipPort, deviceId, token and key.
|
||||
|
||||
```java
|
||||
Thing mideaac:ac:mideaac "myAC" @ "Room" [ ipAddress="192.168.1.200", ipPort="", deviceId="", cloud="your cloud (e.g NetHome Plus)", email="yourclouduser@email.com", password="yourcloudpassword", token="", key ="", pollingTime = 60, timeout=4, promptTone="false", version="3"]
|
||||
```
|
||||
|
||||
### `demo.items` Example
|
||||
|
||||
```java
|
||||
Switch power "Power" { channel="mideaac:ac:mideaac:power" }
|
||||
Number:Temperature target_temperature "Target Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:target-temperature" }
|
||||
String operational_mode "Operational Mode" { channel="mideaac:ac:mideaac:operational-mode" }
|
||||
String fan_speed "Fan Speed" { channel="mideaac:ac:mideaac:fan-speed" }
|
||||
String swing_mode "Swing Mode" { channel="mideaac:ac:mideaac:swing-mode" }
|
||||
Number:Temperature indoor_temperature "Indoor Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:indoor-temperature" }
|
||||
Number:Temperature outdoor_temperature "Current Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:outdoor-temperature" }
|
||||
Switch eco_mode "Eco Mode" { channel="mideaac:ac:mideaac:eco-mode" }
|
||||
Switch turbo_mode "Turbo Mode" { channel="mideaac:ac:mideaac:turbo-mode" }
|
||||
Switch sleep_function "Sleep function" { channel="mideaac:ac:mideaac:sleep-function" }
|
||||
Switch temperature_unit "Fahrenheit or Celsius" { channel="mideaac:ac:mideaac:temperature-unit" }
|
||||
```
|
||||
|
||||
### `demo.sitemap` Example
|
||||
|
||||
```java
|
||||
sitemap midea label="Split AC MBR"{
|
||||
Frame label="AC Unit" {
|
||||
Text item=outdoor_temperature label="Outdoor Temperature [%.1f °F]"
|
||||
Text item=indoor_temperature label="Indoor Temperature [%.1f °F]"
|
||||
Setpoint item=target_temperature label="Target Temperature [%.1f °F]" minValue=63.0 maxValue=78 step=1.0
|
||||
Switch item=power label="Midea AC Power"
|
||||
Switch item=temperature_unit label= "Temp Unit" mappings=[ON="Fahrenheit", OFF="Celsius"]
|
||||
Selection item=fan_speed label="Midea AC Fan Speed"
|
||||
Selection item=operational_mode label="Midea AC Mode"
|
||||
Selection item=swing_mode label="Midea AC Louver Swing Mode"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging and Tracing
|
||||
|
||||
Switch the log level to TRACE or DEBUG on the UI Settings Page (Add-on Settings)
|
17
bundles/org.openhab.binding.mideaac/pom.xml
Normal file
17
bundles/org.openhab.binding.mideaac/pom.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?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.mideaac</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: MideaAC Binding</name>
|
||||
|
||||
</project>
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.mideaac-${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-mideaac" description="MideaAC Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.mideaac/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
@ -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.mideaac.internal;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.measure.Unit;
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link MideaACBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - OH naming conventions
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MideaACBindingConstants {
|
||||
|
||||
private static final String BINDING_ID = "mideaac";
|
||||
|
||||
/**
|
||||
* Thing Type
|
||||
*/
|
||||
public static final ThingTypeUID THING_TYPE_MIDEAAC = new ThingTypeUID(BINDING_ID, "ac");
|
||||
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_MIDEAAC);
|
||||
|
||||
/**
|
||||
* List of all channel IDS
|
||||
*/
|
||||
public static final String CHANNEL_POWER = "power";
|
||||
public static final String CHANNEL_APPLIANCE_ERROR = "appliance-error";
|
||||
public static final String CHANNEL_TARGET_TEMPERATURE = "target-temperature";
|
||||
public static final String CHANNEL_OPERATIONAL_MODE = "operational-mode";
|
||||
public static final String CHANNEL_FAN_SPEED = "fan-speed";
|
||||
public static final String CHANNEL_ON_TIMER = "on-timer";
|
||||
public static final String CHANNEL_OFF_TIMER = "off-timer";
|
||||
public static final String CHANNEL_SWING_MODE = "swing-mode";
|
||||
public static final String CHANNEL_AUXILIARY_HEAT = "auxiliary-heat";
|
||||
public static final String CHANNEL_ECO_MODE = "eco-mode";
|
||||
public static final String CHANNEL_TEMPERATURE_UNIT = "temperature-unit";
|
||||
public static final String CHANNEL_SLEEP_FUNCTION = "sleep-function";
|
||||
public static final String CHANNEL_TURBO_MODE = "turbo-mode";
|
||||
public static final String CHANNEL_INDOOR_TEMPERATURE = "indoor-temperature";
|
||||
public static final String CHANNEL_OUTDOOR_TEMPERATURE = "outdoor-temperature";
|
||||
public static final String CHANNEL_HUMIDITY = "humidity";
|
||||
public static final String CHANNEL_ALTERNATE_TARGET_TEMPERATURE = "alternate-target-temperature";
|
||||
public static final String CHANNEL_SCREEN_DISPLAY = "screen-display";
|
||||
public static final String DROPPED_COMMANDS = "dropped-commands";
|
||||
|
||||
public static final Unit<Temperature> API_TEMPERATURE_UNIT = SIUnits.CELSIUS;
|
||||
|
||||
/**
|
||||
* Commands sent to/from AC wall unit are ASCII
|
||||
*/
|
||||
public static final String CHARSET = "US-ASCII";
|
||||
|
||||
/**
|
||||
* List of all AC thing properties
|
||||
*/
|
||||
public static final String CONFIG_IP_ADDRESS = "ipAddress";
|
||||
public static final String CONFIG_IP_PORT = "ipPort";
|
||||
public static final String CONFIG_DEVICEID = "deviceId";
|
||||
public static final String CONFIG_CLOUD = "cloud";
|
||||
public static final String CONFIG_EMAIL = "email";
|
||||
public static final String CONFIG_PASSWORD = "password";
|
||||
public static final String CONFIG_TOKEN = "token";
|
||||
public static final String CONFIG_KEY = "key";
|
||||
public static final String CONFIG_POLLING_TIME = "pollingTime";
|
||||
public static final String CONFIG_CONNECTING_TIMEOUT = "timeout";
|
||||
public static final String CONFIG_PROMPT_TONE = "promptTone";
|
||||
public static final String CONFIG_VERSION = "version";
|
||||
|
||||
public static final String PROPERTY_SN = "sn";
|
||||
public static final String PROPERTY_SSID = "ssid";
|
||||
public static final String PROPERTY_TYPE = "type";
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.mideaac.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link MideaACConfiguration} class contains fields mapping thing configuration parameters.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - OH addons changes
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MideaACConfiguration {
|
||||
|
||||
/**
|
||||
* IP Address
|
||||
*/
|
||||
public String ipAddress = "";
|
||||
|
||||
/**
|
||||
* IP Port
|
||||
*/
|
||||
public int ipPort = 6444;
|
||||
|
||||
/**
|
||||
* Device ID
|
||||
*/
|
||||
public String deviceId = "0";
|
||||
|
||||
/**
|
||||
* Cloud Account email
|
||||
*/
|
||||
public String email = "";
|
||||
|
||||
/**
|
||||
* Cloud Account Password
|
||||
*/
|
||||
public String password = "";
|
||||
|
||||
/**
|
||||
* Cloud Provider
|
||||
*/
|
||||
public String cloud = "";
|
||||
|
||||
/**
|
||||
* Token
|
||||
*/
|
||||
public String token = "";
|
||||
|
||||
/**
|
||||
* Key
|
||||
*/
|
||||
public String key = "";
|
||||
|
||||
/**
|
||||
* Poll Frequency
|
||||
*/
|
||||
public int pollingTime = 60;
|
||||
|
||||
/**
|
||||
* Socket Timeout
|
||||
*/
|
||||
public int timeout = 4;
|
||||
|
||||
/**
|
||||
* Prompt tone from indoor unit with a Set Command
|
||||
*/
|
||||
public boolean promptTone = false;
|
||||
|
||||
/**
|
||||
* AC Version
|
||||
*/
|
||||
public int version = 0;
|
||||
|
||||
/**
|
||||
* Check during initialization that the params are valid
|
||||
*
|
||||
* @return true(valid), false (not valid)
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return !("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank()
|
||||
|| version <= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check during initialization if discovery is needed
|
||||
*
|
||||
* @return true(discovery needed), false (not needed)
|
||||
*/
|
||||
public boolean isDiscoveryNeeded() {
|
||||
return ("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank()
|
||||
|| !Utils.validateIP(ipAddress) || version <= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check during initialization if key and token can be obtained
|
||||
* from the cloud.
|
||||
*
|
||||
* @return true (yes they can), false (they cannot)
|
||||
*/
|
||||
public boolean isTokenKeyObtainable() {
|
||||
return (!email.isBlank() && !password.isBlank() && !"".equals(cloud));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check during initialization if cloud, key and token are true for v3
|
||||
*
|
||||
* @return true (Valid, all items are present) false (key, token and/or provider missing)
|
||||
*/
|
||||
public boolean isV3ConfigValid() {
|
||||
return (!key.isBlank() && !token.isBlank() && !"".equals(cloud));
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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.mideaac.internal;
|
||||
|
||||
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.SUPPORTED_THING_TYPES_UIDS;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudsDTO;
|
||||
import org.openhab.binding.mideaac.internal.handler.MideaACHandler;
|
||||
import org.openhab.core.i18n.UnitProvider;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link MideaACHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.mideaac", service = ThingHandlerFactory.class)
|
||||
public class MideaACHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
private final CloudsDTO clouds;
|
||||
private final UnitProvider unitProvider;
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MideaACHandlerFactory class parameters
|
||||
*
|
||||
* @param unitProvider OH unitProvider
|
||||
* @param httpClientFactory OH httpClientFactory
|
||||
*/
|
||||
@Activate
|
||||
public MideaACHandlerFactory(@Reference UnitProvider unitProvider, @Reference HttpClientFactory httpClientFactory) {
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
this.unitProvider = unitProvider;
|
||||
clouds = new CloudsDTO();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
|
||||
return new MideaACHandler(thing, unitProvider, httpClientFactory.getCommonHttpClient(), clouds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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.mideaac.internal;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.Random;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jose4j.base64url.Base64;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* The {@link Utils} class defines common byte and String array methods
|
||||
* which are used across the whole binding.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - JavaDoc
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Utils {
|
||||
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
private static final char[] HEX_ARRAY_LOWERCASE = "0123456789abcdef".toCharArray();
|
||||
static byte[] empty = new byte[0];
|
||||
|
||||
/**
|
||||
* Converts byte array to upper case hex string
|
||||
*
|
||||
* @param bytes bytes to convert
|
||||
* @return string of hex chars
|
||||
*/
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts byte array to binary string
|
||||
*
|
||||
* @param bytes bytes to convert
|
||||
* @return string of hex chars
|
||||
*/
|
||||
public static String bytesToBinary(byte[] bytes) {
|
||||
String s1 = "";
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
|
||||
s1 = s1.concat(Integer.toBinaryString(bytes[j] & 255 | 256).substring(1));
|
||||
s1 = s1.concat(" ");
|
||||
|
||||
}
|
||||
return s1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts byte array to lower case hex string
|
||||
*
|
||||
* @param bytes bytes to convert
|
||||
* @return string of hex chars
|
||||
*/
|
||||
public static String bytesToHexLowercase(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY_LOWERCASE[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY_LOWERCASE[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the IP address format
|
||||
*
|
||||
* @param ip string of IP Address
|
||||
* @return IP pattern OK
|
||||
*/
|
||||
public static boolean validateIP(final String ip) {
|
||||
String pattern = "^((0|1\\d?\\d?|2[0-4]?\\d?|25[0-5]?|[3-9]\\d?)\\.){3}(0|1\\d?\\d?|2[0-4]?\\d?|25[0-5]?|[3-9]\\d?)$";
|
||||
|
||||
return ip.matches(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts hex string to a byte array
|
||||
*
|
||||
* @param s string to convert to byte array
|
||||
* @return byte array
|
||||
*/
|
||||
public static byte[] hexStringToByteArray(String s) {
|
||||
int len = s.length();
|
||||
byte[] data = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds two byte arrays together
|
||||
*
|
||||
* @param a input byte array 1
|
||||
* @param b input byte array 2
|
||||
* @return byte array
|
||||
*/
|
||||
public static byte[] concatenateArrays(byte[] a, byte[] b) {
|
||||
byte[] c = new byte[a.length + b.length];
|
||||
System.arraycopy(a, 0, c, 0, a.length);
|
||||
System.arraycopy(b, 0, c, a.length, b.length);
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange byte order
|
||||
*
|
||||
* @param i input
|
||||
* @return @return byte array
|
||||
*/
|
||||
public static byte[] toBytes(short i) {
|
||||
ByteBuffer b = ByteBuffer.allocate(2);
|
||||
b.order(ByteOrder.BIG_ENDIAN); // optional, the initial order of a byte buffer is always BIG_ENDIAN.
|
||||
b.putShort(i);
|
||||
return b.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine byte arrays
|
||||
*
|
||||
* @param array1 input array
|
||||
* @param array2 input array
|
||||
* @return byte array
|
||||
*/
|
||||
public static byte[] strxor(byte[] array1, byte[] array2) {
|
||||
byte[] result = new byte[array1.length];
|
||||
int i = 0;
|
||||
for (byte b : array1) {
|
||||
result[i] = (byte) (b ^ array2[i++]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create String of the v3 Token
|
||||
*
|
||||
* @param nbytes number of bytes
|
||||
* @return String
|
||||
*/
|
||||
public static String tokenHex(int nbytes) {
|
||||
Random r = new Random();
|
||||
StringBuffer sb = new StringBuffer();
|
||||
for (int n = 0; n < nbytes; n++) {
|
||||
sb.append(Integer.toHexString(r.nextInt()));
|
||||
}
|
||||
return sb.toString().substring(0, nbytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL safe token
|
||||
*
|
||||
* @param nbytes number of bytes
|
||||
* @return encoded string
|
||||
*/
|
||||
public static String tokenUrlsafe(int nbytes) {
|
||||
Random r = new Random();
|
||||
byte[] bytes = new byte[nbytes];
|
||||
r.nextBytes(bytes);
|
||||
return Base64.encode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts 6 bits and reorders them based on signed or unsigned
|
||||
*
|
||||
* @param i input
|
||||
* @param order byte order
|
||||
* @return reordered array
|
||||
*/
|
||||
public static byte[] toIntTo6ByteArray(long i, ByteOrder order) {
|
||||
final ByteBuffer bb = ByteBuffer.allocate(8);
|
||||
bb.order(order);
|
||||
|
||||
bb.putLong(i);
|
||||
|
||||
if (order == ByteOrder.BIG_ENDIAN) {
|
||||
return Arrays.copyOfRange(bb.array(), 2, 8);
|
||||
}
|
||||
|
||||
if (order == ByteOrder.LITTLE_ENDIAN) {
|
||||
return Arrays.copyOfRange(bb.array(), 0, 6);
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* String Builder
|
||||
*
|
||||
* @param json JSON object
|
||||
* @return string
|
||||
*/
|
||||
public static String getQueryString(JsonObject json) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Iterator<String> keys = json.keySet().stream().sorted().iterator();
|
||||
while (keys.hasNext()) {
|
||||
@Nullable
|
||||
String key = keys.next();
|
||||
sb.append(key);
|
||||
sb.append("=");
|
||||
sb.append(json.get(key).getAsString());
|
||||
if (keys.hasNext()) {
|
||||
sb.append("&"); // To allow for another argument.
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to reverse (or unreverse) the deviceId
|
||||
*
|
||||
* @param array input array
|
||||
* @return reversed array
|
||||
*/
|
||||
public static byte[] reverse(byte[] array) {
|
||||
int left = 0;
|
||||
int right = array.length - 1;
|
||||
while (left < right) {
|
||||
byte temp = array[left];
|
||||
array[left] = array[right];
|
||||
array[right] = temp;
|
||||
left++;
|
||||
right--;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
}
|
@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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.mideaac.internal.connection;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandSet;
|
||||
import org.openhab.binding.mideaac.internal.handler.Response;
|
||||
import org.openhab.binding.mideaac.internal.handler.Timer;
|
||||
import org.openhab.binding.mideaac.internal.handler.Timer.TimeParser;
|
||||
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.types.StringType;
|
||||
import org.openhab.core.library.unit.ImperialUnits;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link CommandHelper} is a static class that is able to translate {@link Command} to {@link CommandSet}
|
||||
*
|
||||
* @author Leo Siepel - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class CommandHelper {
|
||||
private static Logger logger = LoggerFactory.getLogger(CommandHelper.class);
|
||||
|
||||
private static final StringType OPERATIONAL_MODE_OFF = new StringType("OFF");
|
||||
private static final StringType OPERATIONAL_MODE_AUTO = new StringType("AUTO");
|
||||
private static final StringType OPERATIONAL_MODE_COOL = new StringType("COOL");
|
||||
private static final StringType OPERATIONAL_MODE_DRY = new StringType("DRY");
|
||||
private static final StringType OPERATIONAL_MODE_HEAT = new StringType("HEAT");
|
||||
private static final StringType OPERATIONAL_MODE_FAN_ONLY = new StringType("FAN_ONLY");
|
||||
|
||||
private static final StringType FAN_SPEED_OFF = new StringType("OFF");
|
||||
private static final StringType FAN_SPEED_SILENT = new StringType("SILENT");
|
||||
private static final StringType FAN_SPEED_LOW = new StringType("LOW");
|
||||
private static final StringType FAN_SPEED_MEDIUM = new StringType("MEDIUM");
|
||||
private static final StringType FAN_SPEED_HIGH = new StringType("HIGH");
|
||||
private static final StringType FAN_SPEED_FULL = new StringType("FULL");
|
||||
private static final StringType FAN_SPEED_AUTO = new StringType("AUTO");
|
||||
|
||||
private static final StringType SWING_MODE_OFF = new StringType("OFF");
|
||||
private static final StringType SWING_MODE_VERTICAL = new StringType("VERTICAL");
|
||||
private static final StringType SWING_MODE_HORIZONTAL = new StringType("HORIZONTAL");
|
||||
private static final StringType SWING_MODE_BOTH = new StringType("BOTH");
|
||||
|
||||
/**
|
||||
* Device Power ON OFF
|
||||
*
|
||||
* @param command On or Off
|
||||
*/
|
||||
public static CommandSet handlePower(Command command, Response lastResponse) throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setPowerState(false);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setPowerState(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown power command: {}", command));
|
||||
}
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported AC - Heat Pump modes
|
||||
*
|
||||
* @param command Operational Mode Cool, Heat, etc.
|
||||
*/
|
||||
public static CommandSet handleOperationalMode(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command instanceof StringType) {
|
||||
if (command.equals(OPERATIONAL_MODE_OFF)) {
|
||||
commandSet.setPowerState(false);
|
||||
} else if (command.equals(OPERATIONAL_MODE_AUTO)) {
|
||||
commandSet.setOperationalMode(OperationalMode.AUTO);
|
||||
} else if (command.equals(OPERATIONAL_MODE_COOL)) {
|
||||
commandSet.setOperationalMode(OperationalMode.COOL);
|
||||
} else if (command.equals(OPERATIONAL_MODE_DRY)) {
|
||||
commandSet.setOperationalMode(OperationalMode.DRY);
|
||||
} else if (command.equals(OPERATIONAL_MODE_HEAT)) {
|
||||
commandSet.setOperationalMode(OperationalMode.HEAT);
|
||||
} else if (command.equals(OPERATIONAL_MODE_FAN_ONLY)) {
|
||||
commandSet.setOperationalMode(OperationalMode.FAN_ONLY);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown operational mode command: {}", command));
|
||||
}
|
||||
}
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
private static float limitTargetTemperatureToRange(float temperatureInCelsius) {
|
||||
if (temperatureInCelsius < 17.0f) {
|
||||
return 17.0f;
|
||||
}
|
||||
if (temperatureInCelsius > 30.0f) {
|
||||
return 30.0f;
|
||||
}
|
||||
|
||||
return temperatureInCelsius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device only uses Celsius in 0.5 degree increments
|
||||
* Fahrenheit is rounded to fit (example
|
||||
* setting to 64 F is 18 C but will result in 64.4 F display in OH)
|
||||
* The evaporator only displays 2 digits, so will show 64.
|
||||
*
|
||||
* @param command Target Temperature
|
||||
*/
|
||||
public static CommandSet handleTargetTemperature(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command instanceof DecimalType decimalCommand) {
|
||||
logger.debug("Handle Target Temperature as DecimalType in degrees C");
|
||||
commandSet.setTargetTemperature(limitTargetTemperatureToRange(decimalCommand.floatValue()));
|
||||
} else if (command instanceof QuantityType<?> quantityCommand) {
|
||||
if (quantityCommand.getUnit().equals(ImperialUnits.FAHRENHEIT)) {
|
||||
quantityCommand = Objects.requireNonNull(quantityCommand.toUnit(SIUnits.CELSIUS));
|
||||
}
|
||||
commandSet.setTargetTemperature(limitTargetTemperatureToRange(quantityCommand.floatValue()));
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown target temperature command: {}", command));
|
||||
}
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Speeds vary by V2 or V3 and device. This command also turns the power ON
|
||||
*
|
||||
* @param command Fan Speed Auto, Low, High, etc.
|
||||
*/
|
||||
public static CommandSet handleFanSpeed(Command command, Response lastResponse, int version)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command instanceof StringType) {
|
||||
commandSet.setPowerState(true);
|
||||
if (command.equals(FAN_SPEED_OFF)) {
|
||||
commandSet.setPowerState(false);
|
||||
} else if (command.equals(FAN_SPEED_SILENT)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.SILENT2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.SILENT3);
|
||||
}
|
||||
} else if (command.equals(FAN_SPEED_LOW)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.LOW2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.LOW3);
|
||||
}
|
||||
} else if (command.equals(FAN_SPEED_MEDIUM)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.MEDIUM2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.MEDIUM3);
|
||||
}
|
||||
} else if (command.equals(FAN_SPEED_HIGH)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.HIGH2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.HIGH3);
|
||||
}
|
||||
} else if (command.equals(FAN_SPEED_FULL)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.FULL2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.FULL3);
|
||||
}
|
||||
} else if (command.equals(FAN_SPEED_AUTO)) {
|
||||
if (version == 2) {
|
||||
commandSet.setFanSpeed(FanSpeed.AUTO2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setFanSpeed(FanSpeed.AUTO3);
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown fan speed command: {}", command));
|
||||
}
|
||||
}
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be set in Cool mode. Fan will switch to Auto
|
||||
* and temp will be 24 C or 75 F on unit (75.2 F in OH)
|
||||
*
|
||||
* @param command Eco Mode
|
||||
*/
|
||||
public static CommandSet handleEcoMode(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setEcoMode(false);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setEcoMode(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown eco mode command: {}", command));
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes supported depends on the device
|
||||
* Power is turned on when swing mode is changed
|
||||
*
|
||||
* @param command Swing Mode
|
||||
*/
|
||||
public static CommandSet handleSwingMode(Command command, Response lastResponse, int version)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
commandSet.setPowerState(true);
|
||||
|
||||
if (command instanceof StringType) {
|
||||
if (command.equals(SWING_MODE_OFF)) {
|
||||
if (version == 2) {
|
||||
commandSet.setSwingMode(SwingMode.OFF2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setSwingMode(SwingMode.OFF3);
|
||||
}
|
||||
} else if (command.equals(SWING_MODE_VERTICAL)) {
|
||||
if (version == 2) {
|
||||
commandSet.setSwingMode(SwingMode.VERTICAL2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setSwingMode(SwingMode.VERTICAL3);
|
||||
}
|
||||
} else if (command.equals(SWING_MODE_HORIZONTAL)) {
|
||||
if (version == 2) {
|
||||
commandSet.setSwingMode(SwingMode.HORIZONTAL2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setSwingMode(SwingMode.HORIZONTAL3);
|
||||
}
|
||||
} else if (command.equals(SWING_MODE_BOTH)) {
|
||||
if (version == 2) {
|
||||
commandSet.setSwingMode(SwingMode.BOTH2);
|
||||
} else if (version == 3) {
|
||||
commandSet.setSwingMode(SwingMode.BOTH3);
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown swing mode command: {}", command));
|
||||
}
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turbo mode is only with Heat or Cool to quickly change
|
||||
* Room temperature. Power is turned on.
|
||||
*
|
||||
* @param command Turbo mode - Fast cooling or Heating
|
||||
*/
|
||||
public static CommandSet handleTurboMode(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
commandSet.setPowerState(true);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setTurboMode(false);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setTurboMode(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown turbo mode command: {}", command));
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* May not be supported via LAN in all models - IR only
|
||||
*
|
||||
* @param command Screen Display Toggle to ON or Off - One command
|
||||
*/
|
||||
public static CommandSet handleScreenDisplay(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setScreenDisplay(true);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setScreenDisplay(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown screen display command: {}", command));
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is only for the AC LED device display units, calcs always in Celsius
|
||||
*
|
||||
* @param command Temp unit on the indoor evaporator
|
||||
*/
|
||||
public static CommandSet handleTempUnit(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setFahrenheit(false);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setFahrenheit(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown temperature unit command: {}", command));
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Power turned on with Sleep Mode Change
|
||||
* Sleep mode increases temp slightly in first 2 hours of sleep
|
||||
*
|
||||
* @param command Sleep function
|
||||
*/
|
||||
public static CommandSet handleSleepFunction(Command command, Response lastResponse)
|
||||
throws UnsupportedOperationException {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
|
||||
commandSet.setPowerState(true);
|
||||
|
||||
if (command.equals(OnOffType.OFF)) {
|
||||
commandSet.setSleepMode(false);
|
||||
} else if (command.equals(OnOffType.ON)) {
|
||||
commandSet.setSleepMode(true);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown sleep mode command: {}", command));
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the time (from now) that the device will turn on at it's current settings
|
||||
*
|
||||
* @param command Sets On Timer
|
||||
*/
|
||||
public static CommandSet handleOnTimer(Command command, Response lastResponse) {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
int hours = 0;
|
||||
int minutes = 0;
|
||||
Timer timer = new Timer(true, hours, minutes);
|
||||
TimeParser timeParser = timer.new TimeParser();
|
||||
if (command instanceof StringType) {
|
||||
String timeString = ((StringType) command).toString();
|
||||
if (!timeString.matches("\\d{2}:\\d{2}")) {
|
||||
logger.debug("Invalid time format. Expected HH:MM.");
|
||||
commandSet.setOnTimer(false, hours, minutes);
|
||||
} else {
|
||||
int[] timeParts = timeParser.parseTime(timeString);
|
||||
boolean on = true;
|
||||
hours = timeParts[0];
|
||||
minutes = timeParts[1];
|
||||
// Validate minutes and hours
|
||||
if (minutes < 0 || minutes > 59 || hours > 24 || hours < 0) {
|
||||
logger.debug("Invalid hours (24 max) and or minutes (59 max)");
|
||||
hours = 0;
|
||||
minutes = 0;
|
||||
}
|
||||
if (hours == 0 && minutes == 0) {
|
||||
commandSet.setOnTimer(false, hours, minutes);
|
||||
} else {
|
||||
commandSet.setOnTimer(on, hours, minutes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("Command must be of type StringType: {}", command);
|
||||
commandSet.setOnTimer(false, hours, minutes);
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the time (from now) that the device will turn off
|
||||
*
|
||||
* @param command Sets Off Timer
|
||||
*/
|
||||
public static CommandSet handleOffTimer(Command command, Response lastResponse) {
|
||||
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
|
||||
int hours = 0;
|
||||
int minutes = 0;
|
||||
Timer timer = new Timer(true, hours, minutes);
|
||||
TimeParser timeParser = timer.new TimeParser();
|
||||
if (command instanceof StringType) {
|
||||
String timeString = ((StringType) command).toString();
|
||||
if (!timeString.matches("\\d{2}:\\d{2}")) {
|
||||
logger.debug("Invalid time format. Expected HH:MM.");
|
||||
commandSet.setOffTimer(false, hours, minutes);
|
||||
} else {
|
||||
int[] timeParts = timeParser.parseTime(timeString);
|
||||
boolean on = true;
|
||||
hours = timeParts[0];
|
||||
minutes = timeParts[1];
|
||||
// Validate minutes and hours
|
||||
if (minutes < 0 || minutes > 59 || hours > 24 || hours < 0) {
|
||||
logger.debug("Invalid hours (24 max) and or minutes (59 max)");
|
||||
hours = 0;
|
||||
minutes = 0;
|
||||
}
|
||||
if (hours == 0 && minutes == 0) {
|
||||
commandSet.setOffTimer(false, hours, minutes);
|
||||
} else {
|
||||
commandSet.setOffTimer(on, hours, minutes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("Command must be of type StringType: {}", command);
|
||||
commandSet.setOffTimer(false, hours, minutes);
|
||||
}
|
||||
|
||||
return commandSet;
|
||||
}
|
||||
}
|
@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 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.mideaac.internal.connection;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaAuthenticationException;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaConnectionException;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaException;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
|
||||
import org.openhab.binding.mideaac.internal.handler.Callback;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandSet;
|
||||
import org.openhab.binding.mideaac.internal.handler.Packet;
|
||||
import org.openhab.binding.mideaac.internal.handler.Response;
|
||||
import org.openhab.binding.mideaac.internal.security.Decryption8370Result;
|
||||
import org.openhab.binding.mideaac.internal.security.Security;
|
||||
import org.openhab.binding.mideaac.internal.security.Security.MsgType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link ConnectionManager} class is responsible for managing the state of the TCP connection to the
|
||||
* indoor AC unit evaporator.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - Revised logic to reconnect with security before each poll or command
|
||||
*
|
||||
* This gets around the issue that any command needs to be within 30 seconds of the authorization
|
||||
* in testing this only adds 50 ms, but allows polls at longer intervals
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ConnectionManager {
|
||||
private Logger logger = LoggerFactory.getLogger(ConnectionManager.class);
|
||||
|
||||
private final String ipAddress;
|
||||
private final int ipPort;
|
||||
private final int timeout;
|
||||
private String key;
|
||||
private String token;
|
||||
private final String cloud;
|
||||
private final String deviceId;
|
||||
private Response lastResponse;
|
||||
private CloudProviderDTO cloudProvider;
|
||||
private Security security;
|
||||
private final int version;
|
||||
private final boolean promptTone;
|
||||
private boolean deviceIsConnected;
|
||||
private int droppedCommands = 0;
|
||||
|
||||
/**
|
||||
* True allows command retry if null response
|
||||
*/
|
||||
private boolean retry = true;
|
||||
|
||||
public ConnectionManager(String ipAddress, int ipPort, int timeout, String key, String token, String cloud,
|
||||
String email, String password, String deviceId, int version, boolean promptTone) {
|
||||
this.deviceIsConnected = false;
|
||||
this.ipAddress = ipAddress;
|
||||
this.ipPort = ipPort;
|
||||
this.timeout = timeout;
|
||||
this.key = key;
|
||||
this.token = token;
|
||||
this.cloud = cloud;
|
||||
this.deviceId = deviceId;
|
||||
this.version = version;
|
||||
this.promptTone = promptTone;
|
||||
this.lastResponse = new Response(HexFormat.of().parseHex("C00042667F7F003C0000046066000000000000000000F9ECDB"),
|
||||
version, "query", (byte) 0xc0);
|
||||
this.cloudProvider = CloudProviderDTO.getCloudProvider(cloud);
|
||||
this.security = new Security(cloudProvider);
|
||||
}
|
||||
|
||||
private Socket socket = new Socket();
|
||||
private InputStream inputStream = new ByteArrayInputStream(new byte[0]);
|
||||
private DataOutputStream writer = new DataOutputStream(System.out);
|
||||
|
||||
/**
|
||||
* Gets last response
|
||||
*
|
||||
* @return byte array of last response
|
||||
*/
|
||||
public Response getLastResponse() {
|
||||
return this.lastResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if String is blank
|
||||
*
|
||||
* @param str string to be evaluated
|
||||
* @return boolean true or false
|
||||
*/
|
||||
public static boolean isBlank(String str) {
|
||||
return str.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* After checking if the key and token need to be updated (Default = 0 Never)
|
||||
* The socket is established with the writer and inputStream (for reading responses)
|
||||
* The device is considered connected. V2 devices will proceed to send the poll or the
|
||||
* set command. V3 devices will proceed to authenticate
|
||||
*/
|
||||
public synchronized void connect() throws MideaConnectionException, MideaAuthenticationException {
|
||||
logger.trace("Connecting to {}:{}", ipAddress, ipPort);
|
||||
|
||||
int maxTries = 3;
|
||||
int retryCount = 0;
|
||||
|
||||
// Open socket
|
||||
// Retry addresses most common wifi connection problems- wait 5 seconds and try again
|
||||
while (retryCount < maxTries) {
|
||||
try {
|
||||
socket = new Socket();
|
||||
socket.setSoTimeout(timeout * 1000);
|
||||
socket.connect(new InetSocketAddress(ipAddress, ipPort), timeout * 1000);
|
||||
break;
|
||||
} catch (IOException e) {
|
||||
retryCount++;
|
||||
if (retryCount < maxTries) {
|
||||
try {
|
||||
Thread.sleep(5000);
|
||||
} catch (InterruptedException ex) {
|
||||
logger.debug("An interupted error (socket retry) has occured {}", ex.getMessage());
|
||||
}
|
||||
logger.debug("Socket retry count {}, IOException connecting to {}: {}", retryCount, ipAddress,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retryCount == maxTries) {
|
||||
deviceIsConnected = false;
|
||||
logger.info("Failed to connect after {} tries. Try again with next scheduled poll", maxTries);
|
||||
throw new MideaConnectionException("Failed to connect after maximum tries");
|
||||
}
|
||||
|
||||
// Create streams
|
||||
try {
|
||||
writer = new DataOutputStream(socket.getOutputStream());
|
||||
inputStream = socket.getInputStream();
|
||||
} catch (IOException e) {
|
||||
logger.debug("IOException getting streams for {}: {}", ipAddress, e.getMessage(), e);
|
||||
deviceIsConnected = false;
|
||||
throw new MideaConnectionException(e);
|
||||
}
|
||||
|
||||
if (version == 3) {
|
||||
logger.debug("Device at IP: {} requires authentication, going to authenticate", ipAddress);
|
||||
try {
|
||||
authenticate();
|
||||
} catch (MideaAuthenticationException | MideaConnectionException e) {
|
||||
deviceIsConnected = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deviceIsConnected) {
|
||||
logger.info("Connected to IP {}", ipAddress);
|
||||
}
|
||||
logger.debug("Connected to IP {}", ipAddress);
|
||||
deviceIsConnected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* For V3 devices only. This method checks for the Cloud Provider
|
||||
* key and token (and goes offline if any are missing). It will retrieve the
|
||||
* missing key and/or token if the account email and password are provided.
|
||||
*
|
||||
* @throws MideaAuthenticationException
|
||||
* @throws MideaConnectionException
|
||||
*/
|
||||
public void authenticate() throws MideaConnectionException, MideaAuthenticationException {
|
||||
logger.trace("Key: {}", key);
|
||||
logger.trace("Token: {}", token);
|
||||
logger.trace("Cloud {}", cloud);
|
||||
|
||||
if (!isBlank(token) && !isBlank(key) && !"".equals(cloud)) {
|
||||
logger.debug("Device at IP: {} authenticating", ipAddress);
|
||||
doV3Handshake();
|
||||
} else {
|
||||
throw new MideaAuthenticationException("Token, Key and / or cloud provider missing");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the Handshake Request to the V3 device. Generally quick response
|
||||
* Without the 1000 ms sleep delay there are problems in sending the Poll/Command
|
||||
* Suspect that the socket write and read streams need a moment to clear
|
||||
* as they will be reused in the SendCommand method
|
||||
*/
|
||||
private void doV3Handshake() throws MideaConnectionException, MideaAuthenticationException {
|
||||
byte[] request = security.encode8370(Utils.hexStringToByteArray(token), MsgType.MSGTYPE_HANDSHAKE_REQUEST);
|
||||
try {
|
||||
logger.trace("Device at IP: {} writing handshake_request: {}", ipAddress, Utils.bytesToHex(request));
|
||||
|
||||
write(request);
|
||||
byte[] response = read();
|
||||
|
||||
if (response != null && response.length > 0) {
|
||||
logger.trace("Device at IP: {} response for handshake_request length:{}", ipAddress, response.length);
|
||||
if (response.length == 72) {
|
||||
boolean success = security.tcpKey(Arrays.copyOfRange(response, 8, 72),
|
||||
Utils.hexStringToByteArray(key));
|
||||
if (success) {
|
||||
logger.debug("Authentication successful");
|
||||
// Altering the sleep caused or can cause write errors problems. Use caution.
|
||||
// At 500 ms the first write usually fails. Works, but no backup
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
logger.debug("An interupted error (success) has occured {}", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
throw new MideaAuthenticationException("Invalid Key. Correct Key in configuration.");
|
||||
}
|
||||
} else if (Arrays.equals(new String("ERROR").getBytes(), response)) {
|
||||
throw new MideaAuthenticationException("Authentication failed!");
|
||||
} else {
|
||||
logger.warn("Authentication reponse unexpected data length ({} instead of 72)!", response.length);
|
||||
throw new MideaAuthenticationException("Unexpected authentication response length");
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new MideaConnectionException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the routine polling command from the DoPoll
|
||||
* in the MideaACHandler
|
||||
*
|
||||
* @param callback
|
||||
* @throws MideaConnectionException
|
||||
* @throws MideaAuthenticationException
|
||||
* @throws MideaException
|
||||
*/
|
||||
public void getStatus(Callback callback)
|
||||
throws MideaConnectionException, MideaAuthenticationException, MideaException {
|
||||
CommandBase requestStatusCommand = new CommandBase();
|
||||
sendCommand(requestStatusCommand, callback);
|
||||
}
|
||||
|
||||
private void ensureConnected() throws MideaConnectionException, MideaAuthenticationException {
|
||||
disconnect();
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the packet byte array together. There is a check to
|
||||
* make sure to make sure the input stream is empty before sending
|
||||
* the new command and another check if input stream is empty after 1.5 seconds.
|
||||
* Normal device response in 0.75 - 1 second range
|
||||
* If still empty, send the bytes again. If there are bytes, the read method is called.
|
||||
* If the socket times out with no response the command is dropped. There will be another poll
|
||||
* in the time set by the user (30 seconds min). A Set command will need to be resent.
|
||||
*
|
||||
* @param command either the set or polling command
|
||||
* @throws MideaAuthenticationException
|
||||
* @throws MideaConnectionException
|
||||
*/
|
||||
public synchronized void sendCommand(CommandBase command, @Nullable Callback callback)
|
||||
throws MideaConnectionException, MideaAuthenticationException {
|
||||
ensureConnected();
|
||||
|
||||
if (command instanceof CommandSet) {
|
||||
((CommandSet) command).setPromptTone(promptTone);
|
||||
}
|
||||
Packet packet = new Packet(command, deviceId, security);
|
||||
packet.compose();
|
||||
|
||||
try {
|
||||
byte[] bytes = packet.getBytes();
|
||||
logger.debug("Writing to {} bytes.length: {}", ipAddress, bytes.length);
|
||||
|
||||
if (version == 3) {
|
||||
bytes = security.encode8370(bytes, MsgType.MSGTYPE_ENCRYPTED_REQUEST);
|
||||
}
|
||||
|
||||
// Ensure input stream is empty before writing packet
|
||||
if (inputStream.available() == 0) {
|
||||
logger.debug("Input stream empty sending write {}", command);
|
||||
write(bytes);
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException e) {
|
||||
logger.debug("An interupted error (retrycommand2) has occured {}", e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
// Note, but continue anyway for second write.
|
||||
}
|
||||
|
||||
if (inputStream.available() == 0) {
|
||||
logger.debug("Input stream empty sending second write {}", command);
|
||||
write(bytes);
|
||||
}
|
||||
|
||||
// Socket timeout (UI parameter) 2 seconds minimum up to 10 seconds.
|
||||
byte[] responseBytes = read();
|
||||
|
||||
if (responseBytes != null) {
|
||||
retry = true;
|
||||
if (version == 3) {
|
||||
Decryption8370Result result = security.decode8370(responseBytes);
|
||||
for (byte[] response : result.getResponses()) {
|
||||
logger.debug("Response length: {} IP address: {} ", response.length, ipAddress);
|
||||
if (response.length > 40 + 16) {
|
||||
byte[] data = security.aesDecrypt(Arrays.copyOfRange(response, 40, response.length - 16));
|
||||
|
||||
logger.trace("Bytes in HEX, decoded and with header: length: {}, data: {}", data.length,
|
||||
Utils.bytesToHex(data));
|
||||
byte bodyType2 = data[0xa];
|
||||
|
||||
// data[3]: Device Type - 0xAC = AC
|
||||
// https://github.com/georgezhao2010/midea_ac_lan/blob/06fc4b582a012bbbfd6bd5942c92034270eca0eb/custom_components/midea_ac_lan/midea_devices.py#L96
|
||||
|
||||
// data[9]: MessageType - set, query, notify1, notify2, exception, querySN, exception2,
|
||||
// querySubtype
|
||||
// https://github.com/georgezhao2010/midea_ac_lan/blob/30d0ff5ff14f150da10b883e97b2f280767aa89a/custom_components/midea_ac_lan/midea/core/message.py#L22-L29
|
||||
String responseType = "";
|
||||
switch (data[0x9]) {
|
||||
case 0x02:
|
||||
responseType = "set";
|
||||
break;
|
||||
case 0x03:
|
||||
responseType = "query";
|
||||
break;
|
||||
case 0x04:
|
||||
responseType = "notify1";
|
||||
break;
|
||||
case 0x05:
|
||||
responseType = "notify2";
|
||||
break;
|
||||
case 0x06:
|
||||
responseType = "exception";
|
||||
break;
|
||||
case 0x07:
|
||||
responseType = "querySN";
|
||||
break;
|
||||
case 0x0A:
|
||||
responseType = "exception2";
|
||||
break;
|
||||
case 0x09: // Helyesen: 0xA0
|
||||
responseType = "querySubtype";
|
||||
break;
|
||||
default:
|
||||
logger.debug("Invalid response type: {}", data[0x9]);
|
||||
}
|
||||
logger.trace("Response Type: {} and bodyType: {}", responseType, bodyType2);
|
||||
|
||||
// The response data from the appliance includes a packet header which we don't want
|
||||
data = Arrays.copyOfRange(data, 10, data.length);
|
||||
byte bodyType = data[0x0];
|
||||
logger.trace("Response Type expected: {} and bodyType: {}", responseType, bodyType);
|
||||
logger.trace("Bytes in HEX, decoded and stripped without header: length: {}, data: {}",
|
||||
data.length, Utils.bytesToHex(data));
|
||||
logger.debug("Bytes in BINARY, decoded and stripped without header: length: {}, data: {}",
|
||||
data.length, Utils.bytesToBinary(data));
|
||||
|
||||
if (data.length < 21) {
|
||||
logger.warn("Response data is {} long, minimum is 21!", data.length);
|
||||
return;
|
||||
}
|
||||
if (bodyType != -64) {
|
||||
if (bodyType == 30) {
|
||||
logger.warn("Error response 0x1E received {} from IP Address:{}", bodyType,
|
||||
ipAddress);
|
||||
return;
|
||||
}
|
||||
logger.warn("Unexpected response bodyType {}", bodyType);
|
||||
return;
|
||||
}
|
||||
lastResponse = new Response(data, version, responseType, bodyType);
|
||||
try {
|
||||
logger.trace("Data length is {}, version is {}, IP address is {}", data.length, version,
|
||||
ipAddress);
|
||||
if (callback != null) {
|
||||
callback.updateChannels(lastResponse);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Processing response exception: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (responseBytes.length > 40 + 16) {
|
||||
byte[] data = security
|
||||
.aesDecrypt(Arrays.copyOfRange(responseBytes, 40, responseBytes.length - 16));
|
||||
logger.trace("V2 Bytes decoded with header: length: {}, data: {}", data.length,
|
||||
Utils.bytesToHex(data));
|
||||
|
||||
// The response data from the appliance includes a packet header which we don't want
|
||||
data = Arrays.copyOfRange(data, 10, data.length);
|
||||
byte bodyType = data[0x0];
|
||||
logger.trace("V2 Bytes decoded and stripped without header: length: {}, data: {}", data.length,
|
||||
Utils.bytesToHex(data));
|
||||
|
||||
if (data.length < 21) {
|
||||
logger.warn("Response data is {} long, minimum is 21!", data.length);
|
||||
return;
|
||||
}
|
||||
if (bodyType != -64) {
|
||||
if (bodyType == 30) {
|
||||
logger.warn("Error response 0x1E received {} from IP Address:{}", bodyType, ipAddress);
|
||||
return;
|
||||
}
|
||||
logger.warn("Unexpected response bodyType {}", bodyType);
|
||||
return;
|
||||
}
|
||||
lastResponse = new Response(data, version, "", bodyType);
|
||||
try {
|
||||
logger.trace("Data length is {}, version is {}, Ip Address is {}", data.length, version,
|
||||
ipAddress);
|
||||
if (callback != null) {
|
||||
callback.updateChannels(lastResponse);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.warn("Processing response exception: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (retry) {
|
||||
logger.debug("Resending Command {}", command);
|
||||
retry = false;
|
||||
sendCommand(command, callback);
|
||||
} else {
|
||||
droppedCommands = droppedCommands + 1;
|
||||
logger.info("Problem with reading response, skipping {} skipped count since startup {}", command,
|
||||
droppedCommands);
|
||||
retry = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (SocketException e) {
|
||||
droppedCommands = droppedCommands + 1;
|
||||
logger.debug("Socket exception on IP: {}, skipping command {} skipped count since startup {}", ipAddress,
|
||||
command, droppedCommands);
|
||||
throw new MideaConnectionException(e);
|
||||
} catch (IOException e) {
|
||||
droppedCommands = droppedCommands + 1;
|
||||
logger.debug("IO exception on IP: {}, skipping command {} skipped count since startup {}", ipAddress,
|
||||
command, droppedCommands);
|
||||
throw new MideaConnectionException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all elements of the connection before starting a new one
|
||||
* Makes sure writer, inputStream and socket are closed before each command is started
|
||||
*/
|
||||
public synchronized void disconnect() {
|
||||
logger.debug("Disconnecting from device at {}", ipAddress);
|
||||
|
||||
InputStream inputStream = this.inputStream;
|
||||
DataOutputStream writer = this.writer;
|
||||
Socket socket = this.socket;
|
||||
try {
|
||||
writer.close();
|
||||
inputStream.close();
|
||||
socket.close();
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.warn("IOException closing connection to device at {}: {}", ipAddress, e.getMessage(), e);
|
||||
}
|
||||
socket = null;
|
||||
inputStream = null;
|
||||
writer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the inputStream byte array
|
||||
*
|
||||
* @return byte array or null
|
||||
*/
|
||||
public synchronized byte @Nullable [] read() {
|
||||
byte[] bytes = new byte[512];
|
||||
InputStream inputStream = this.inputStream;
|
||||
|
||||
try {
|
||||
int len = inputStream.read(bytes);
|
||||
if (len > 0) {
|
||||
logger.debug("Response received length: {} from device at IP: {}", len, ipAddress);
|
||||
bytes = Arrays.copyOfRange(bytes, 0, len);
|
||||
return bytes;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
String message = e.getMessage();
|
||||
logger.debug(" Byte read exception {}", message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the packet that will be sent to the device
|
||||
*
|
||||
* @param buffer socket writer
|
||||
* @throws IOException writer could be null
|
||||
*/
|
||||
public synchronized void write(byte[] buffer) throws IOException {
|
||||
DataOutputStream writer = this.writer;
|
||||
|
||||
try {
|
||||
writer.write(buffer, 0, buffer.length);
|
||||
} catch (IOException e) {
|
||||
String message = e.getMessage();
|
||||
logger.debug("Write error {}", message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the AC device
|
||||
*
|
||||
* @param force
|
||||
*/
|
||||
public void dispose(boolean force) {
|
||||
disconnect();
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.connection.exception;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link MideaAuthenticationException} represents a binding specific {@link Exception}.
|
||||
*
|
||||
* @author Leo Siepel - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class MideaAuthenticationException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MideaAuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MideaAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public MideaAuthenticationException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.connection.exception;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link MideaConnectionException} represents a binding specific {@link Exception}.
|
||||
*
|
||||
* @author Leo Siepel - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class MideaConnectionException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MideaConnectionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MideaConnectionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public MideaConnectionException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.connection.exception;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link MideaException} represents a binding specific {@link Exception}.
|
||||
*
|
||||
* @author Leo Siepel - Initial contribution
|
||||
*/
|
||||
|
||||
@NonNullByDefault
|
||||
public class MideaException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MideaException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MideaException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public MideaException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.mideaac.internal.discovery;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link Connection} Manages the discovery connection to a Midea AC.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Connection implements Closeable {
|
||||
|
||||
/**
|
||||
* UDP port1 to send command.
|
||||
*/
|
||||
public static final int MIDEAAC_SEND_PORT1 = 6445;
|
||||
/**
|
||||
* UDP port2 to send command.
|
||||
*/
|
||||
public static final int MIDEAAC_SEND_PORT2 = 20086;
|
||||
/**
|
||||
* UDP port devices send discover replies back.
|
||||
*/
|
||||
public static final int MIDEAAC_RECEIVE_PORT = 6440;
|
||||
|
||||
private final InetAddress iNetAddress;
|
||||
private final DatagramSocket socket;
|
||||
|
||||
/**
|
||||
* Initializes a connection to the given IP address.
|
||||
*
|
||||
* @param ipAddress IP address of the connection
|
||||
* @throws UnknownHostException if ipAddress could not be resolved.
|
||||
* @throws SocketException if no Datagram socket connection could be made.
|
||||
*/
|
||||
public Connection(String ipAddress) throws SocketException, UnknownHostException {
|
||||
iNetAddress = InetAddress.getByName(ipAddress);
|
||||
socket = new DatagramSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the 9 bytes command to the Midea AC device.
|
||||
*
|
||||
* @param command the 9 bytes command
|
||||
* @throws IOException Connection to the LED failed
|
||||
*/
|
||||
public void sendCommand(byte[] command) throws IOException {
|
||||
{
|
||||
DatagramPacket sendPkt = new DatagramPacket(command, command.length, iNetAddress, MIDEAAC_SEND_PORT1);
|
||||
socket.send(sendPkt);
|
||||
}
|
||||
{
|
||||
DatagramPacket sendPkt = new DatagramPacket(command, command.length, iNetAddress, MIDEAAC_SEND_PORT2);
|
||||
socket.send(sendPkt);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
socket.close();
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.mideaac.internal.discovery;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
|
||||
/**
|
||||
* Discovery {@link DiscoveryHandler}
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DiscoveryHandler {
|
||||
/**
|
||||
* Discovery result
|
||||
*
|
||||
* @param discoveryResult AC device
|
||||
*/
|
||||
public void discovered(DiscoveryResult discoveryResult);
|
||||
}
|
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 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.mideaac.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase;
|
||||
import org.openhab.binding.mideaac.internal.security.Security;
|
||||
import org.openhab.core.config.discovery.AbstractDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.config.discovery.DiscoveryService;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link MideaACDiscoveryService} service for Midea AC.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - OH naming conventions
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = DiscoveryService.class, configurationPid = "discovery.mideaac")
|
||||
public class MideaACDiscoveryService extends AbstractDiscoveryService {
|
||||
|
||||
private static int discoveryTimeoutSeconds = 5;
|
||||
private final int receiveJobTimeout = 20000;
|
||||
private final int udpPacketTimeout = receiveJobTimeout - 50;
|
||||
private final String mideaacNamePrefix = "MideaAC";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(MideaACDiscoveryService.class);
|
||||
|
||||
///// Network
|
||||
private byte[] buffer = new byte[512];
|
||||
@Nullable
|
||||
private DatagramSocket discoverSocket;
|
||||
|
||||
@Nullable
|
||||
DiscoveryHandler discoveryHandler;
|
||||
|
||||
private Security security;
|
||||
|
||||
/**
|
||||
* Discovery Service Uses the default decryption for all devices
|
||||
*/
|
||||
public MideaACDiscoveryService() {
|
||||
super(SUPPORTED_THING_TYPES_UIDS, discoveryTimeoutSeconds, false);
|
||||
this.security = new Security(CloudProviderDTO.getCloudProvider(""));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
logger.debug("Start scan for Midea AC devices.");
|
||||
discoverThings();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void stopScan() {
|
||||
logger.debug("Stop scan for Midea AC devices.");
|
||||
closeDiscoverSocket();
|
||||
super.stopScan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual discovery of Midea AC devices (things).
|
||||
*/
|
||||
private void discoverThings() {
|
||||
try {
|
||||
final DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
|
||||
// No need to call close first, because the caller of this method already has done it.
|
||||
startDiscoverSocket();
|
||||
// Runs until the socket call gets a time out and throws an exception. When a time out is triggered it means
|
||||
// no data was present and nothing new to discover.
|
||||
while (true) {
|
||||
// Set packet length in case a previous call reduced the size.
|
||||
receivePacket.setLength(buffer.length);
|
||||
DatagramSocket discoverSocket = this.discoverSocket;
|
||||
if (discoverSocket == null) {
|
||||
break;
|
||||
} else {
|
||||
discoverSocket.receive(receivePacket);
|
||||
}
|
||||
logger.debug("Midea AC device discovery returned package with length {}", receivePacket.getLength());
|
||||
if (receivePacket.getLength() > 0) {
|
||||
thingDiscovered(receivePacket);
|
||||
}
|
||||
}
|
||||
} catch (SocketTimeoutException e) {
|
||||
logger.debug("Discovering poller timeout...");
|
||||
} catch (IOException e) {
|
||||
logger.debug("Error during discovery: {}", e.getMessage());
|
||||
} finally {
|
||||
closeDiscoverSocket();
|
||||
removeOlderResults(getTimestampOfLastScan());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual discovery of a specific Midea AC device (thing)
|
||||
*
|
||||
* @param ipAddress IP Address
|
||||
* @param discoveryHandler Discovery Handler
|
||||
*/
|
||||
public void discoverThing(String ipAddress, DiscoveryHandler discoveryHandler) {
|
||||
try {
|
||||
final DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
|
||||
// No need to call close first, because the caller of this method already has done it.
|
||||
startDiscoverSocket(ipAddress, discoveryHandler);
|
||||
// Runs until the socket call gets a time out and throws an exception. When a time out is triggered it means
|
||||
// no data was present and nothing new to discover.
|
||||
while (true) {
|
||||
// Set packet length in case a previous call reduced the size.
|
||||
receivePacket.setLength(buffer.length);
|
||||
DatagramSocket discoverSocket = this.discoverSocket;
|
||||
if (discoverSocket == null) {
|
||||
break;
|
||||
} else {
|
||||
discoverSocket.receive(receivePacket);
|
||||
}
|
||||
logger.debug("Midea AC device discovery returned package with length {}", receivePacket.getLength());
|
||||
if (receivePacket.getLength() > 0) {
|
||||
thingDiscovered(receivePacket);
|
||||
}
|
||||
}
|
||||
} catch (SocketTimeoutException e) {
|
||||
logger.trace("Discovering poller timeout...");
|
||||
} catch (IOException e) {
|
||||
logger.debug("Error during discovery: {}", e.getMessage());
|
||||
} finally {
|
||||
closeDiscoverSocket();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a {@link DatagramSocket} and sends a packet for discovery of Midea AC devices.
|
||||
*
|
||||
* @throws SocketException
|
||||
* @throws IOException
|
||||
*/
|
||||
private void startDiscoverSocket() throws SocketException, IOException {
|
||||
startDiscoverSocket("255.255.255.255", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the discovery Socket
|
||||
*
|
||||
* @param ipAddress broadcast IP Address
|
||||
* @param discoveryHandler Discovery handler
|
||||
* @throws SocketException Socket Exception
|
||||
* @throws IOException IO Exception
|
||||
*/
|
||||
public void startDiscoverSocket(String ipAddress, @Nullable DiscoveryHandler discoveryHandler)
|
||||
throws SocketException, IOException {
|
||||
logger.trace("Discovering: {}", ipAddress);
|
||||
this.discoveryHandler = discoveryHandler;
|
||||
discoverSocket = new DatagramSocket(new InetSocketAddress(Connection.MIDEAAC_RECEIVE_PORT));
|
||||
DatagramSocket discoverSocket = this.discoverSocket;
|
||||
if (discoverSocket != null) {
|
||||
discoverSocket.setBroadcast(true);
|
||||
discoverSocket.setSoTimeout(udpPacketTimeout);
|
||||
final InetAddress broadcast = InetAddress.getByName(ipAddress);
|
||||
{
|
||||
final DatagramPacket discoverPacket = new DatagramPacket(CommandBase.discover(),
|
||||
CommandBase.discover().length, broadcast, Connection.MIDEAAC_SEND_PORT1);
|
||||
discoverSocket.send(discoverPacket);
|
||||
logger.trace("Broadcast discovery package sent to port: {}", Connection.MIDEAAC_SEND_PORT1);
|
||||
}
|
||||
{
|
||||
final DatagramPacket discoverPacket = new DatagramPacket(CommandBase.discover(),
|
||||
CommandBase.discover().length, broadcast, Connection.MIDEAAC_SEND_PORT2);
|
||||
discoverSocket.send(discoverPacket);
|
||||
logger.trace("Broadcast discovery package sent to port: {}", Connection.MIDEAAC_SEND_PORT2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the discovery socket and cleans the value. No need for synchronization as this method is called from a
|
||||
* synchronized context.
|
||||
*/
|
||||
private void closeDiscoverSocket() {
|
||||
DatagramSocket discoverSocket = this.discoverSocket;
|
||||
if (discoverSocket != null) {
|
||||
discoverSocket.close();
|
||||
this.discoverSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a device (thing) with the discovered properties.
|
||||
*
|
||||
* @param packet containing data of detected device
|
||||
*/
|
||||
private void thingDiscovered(DatagramPacket packet) {
|
||||
DiscoveryResult dr = discoveryPacketReceived(packet);
|
||||
if (dr != null) {
|
||||
DiscoveryHandler discoveryHandler = this.discoveryHandler;
|
||||
if (discoveryHandler != null) {
|
||||
discoveryHandler.discovered(dr);
|
||||
} else {
|
||||
thingDiscovered(dr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the packet to extract the device properties
|
||||
*
|
||||
* @param packet returned paket from device
|
||||
* @return extracted device properties
|
||||
*/
|
||||
@Nullable
|
||||
public DiscoveryResult discoveryPacketReceived(DatagramPacket packet) {
|
||||
final String ipAddress = packet.getAddress().getHostAddress();
|
||||
byte[] data = Arrays.copyOfRange(packet.getData(), 0, packet.getLength());
|
||||
|
||||
logger.trace("Midea AC discover data ({}) from {}: '{}'", data.length, ipAddress, Utils.bytesToHex(data));
|
||||
|
||||
if (data.length >= 104 && (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")
|
||||
|| Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A"))) {
|
||||
logger.trace("Device supported");
|
||||
String mSmartId, mSmartip = "", mSmartSN = "", mSmartSSID = "", mSmartType = "", mSmartPort = "",
|
||||
mSmartVersion = "";
|
||||
|
||||
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")) {
|
||||
mSmartVersion = "2";
|
||||
}
|
||||
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
|
||||
mSmartVersion = "3";
|
||||
}
|
||||
if (Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A")) {
|
||||
data = Arrays.copyOfRange(data, 8, data.length - 16);
|
||||
}
|
||||
|
||||
logger.debug("Version: {}", mSmartVersion);
|
||||
|
||||
byte[] id = Arrays.copyOfRange(data, 20, 26);
|
||||
logger.trace("Id Bytes: {}", Utils.bytesToHex(id));
|
||||
|
||||
byte[] idReverse = Utils.reverse(id);
|
||||
|
||||
BigInteger bigId = new BigInteger(1, idReverse);
|
||||
mSmartId = bigId.toString(10);
|
||||
|
||||
logger.debug("Id: '{}'", mSmartId);
|
||||
|
||||
byte[] encryptData = Arrays.copyOfRange(data, 40, data.length - 16);
|
||||
logger.trace("Encrypt data: '{}'", Utils.bytesToHex(encryptData));
|
||||
|
||||
byte[] reply = security.aesDecrypt(encryptData);
|
||||
logger.trace("Length: {}, Reply: '{}'", reply.length, Utils.bytesToHex(reply));
|
||||
|
||||
mSmartip = Byte.toUnsignedInt(reply[3]) + "." + Byte.toUnsignedInt(reply[2]) + "."
|
||||
+ Byte.toUnsignedInt(reply[1]) + "." + Byte.toUnsignedInt(reply[0]);
|
||||
logger.debug("IP: '{}'", mSmartip);
|
||||
|
||||
byte[] portIdBytes = Utils.reverse(Arrays.copyOfRange(reply, 4, 8));
|
||||
BigInteger portId = new BigInteger(1, portIdBytes);
|
||||
mSmartPort = portId.toString(10);
|
||||
logger.debug("Port: '{}'", mSmartPort);
|
||||
|
||||
mSmartSN = new String(reply, 8, 40 - 8, StandardCharsets.UTF_8);
|
||||
logger.debug("SN: '{}'", mSmartSN);
|
||||
|
||||
logger.trace("SSID length: '{}'", Byte.toUnsignedInt(reply[40]));
|
||||
|
||||
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
|
||||
logger.debug("SSID: '{}'", mSmartSSID);
|
||||
|
||||
mSmartType = mSmartSSID.split("_")[1];
|
||||
logger.debug("Type: '{}'", mSmartType);
|
||||
|
||||
String thingName = createThingName(packet.getAddress().getAddress(), mSmartId);
|
||||
ThingUID thingUID = new ThingUID(THING_TYPE_MIDEAAC, thingName.toLowerCase());
|
||||
|
||||
return DiscoveryResultBuilder.create(thingUID).withLabel(thingName)
|
||||
.withRepresentationProperty(CONFIG_IP_ADDRESS).withThingType(THING_TYPE_MIDEAAC)
|
||||
.withProperties(collectProperties(ipAddress, mSmartVersion, mSmartId, mSmartPort, mSmartSN,
|
||||
mSmartSSID, mSmartType))
|
||||
.build();
|
||||
} else if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 6)).equals("3C3F786D6C20")) {
|
||||
logger.debug("Midea AC v1 device was detected, supported, but not implemented yet.");
|
||||
return null;
|
||||
} else {
|
||||
logger.debug(
|
||||
"Midea AC device was detected, but the retrieved data is incomplete or not supported. Device not registered");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a OH name for the Midea AC device.
|
||||
*
|
||||
* @return the name for the device
|
||||
*/
|
||||
private String createThingName(final byte[] byteIP, String id) {
|
||||
return mideaacNamePrefix + "-" + Byte.toUnsignedInt(byteIP[3]) + "-" + id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects properties into a map.
|
||||
*
|
||||
* @param ipAddress IP address of the thing
|
||||
* @param version Version 2 or 3
|
||||
* @param id ID of the device
|
||||
* @param port Port of the device
|
||||
* @param sn Serial number of the device
|
||||
* @param ssid Serial id converted with StandardCharsets.UTF_8
|
||||
* @param type Type of device (ac)
|
||||
* @return Map with properties
|
||||
*/
|
||||
private Map<String, Object> collectProperties(String ipAddress, String version, String id, String port, String sn,
|
||||
String ssid, String type) {
|
||||
Map<String, Object> properties = new TreeMap<>();
|
||||
properties.put(CONFIG_IP_ADDRESS, ipAddress);
|
||||
properties.put(CONFIG_IP_PORT, port);
|
||||
properties.put(CONFIG_DEVICEID, id);
|
||||
properties.put(CONFIG_VERSION, version);
|
||||
properties.put(PROPERTY_SN, sn);
|
||||
properties.put(PROPERTY_SSID, ssid);
|
||||
properties.put(PROPERTY_TYPE, type);
|
||||
|
||||
return properties;
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.dto;
|
||||
|
||||
import java.nio.ByteOrder;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.security.Security;
|
||||
import org.openhab.binding.mideaac.internal.security.TokenKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* The {@link CloudDTO} class connects to the Cloud Provider
|
||||
* with user supplied information to retrieve the Security
|
||||
* Token and Key.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - JavaDoc
|
||||
*/
|
||||
public class CloudDTO {
|
||||
private final Logger logger = LoggerFactory.getLogger(CloudDTO.class);
|
||||
|
||||
private static final int CLIENT_TYPE = 1; // Android
|
||||
private static final int FORMAT = 2; // JSON
|
||||
private static final String LANGUAGE = "en_US";
|
||||
|
||||
private Date tokenRequestedAt = new Date();
|
||||
|
||||
private void setTokenRequested() {
|
||||
tokenRequestedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Token rquested date
|
||||
*
|
||||
* @return tokenRequestedAt
|
||||
*/
|
||||
public Date getTokenRequested() {
|
||||
return tokenRequestedAt;
|
||||
}
|
||||
|
||||
private HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* Client for Http requests
|
||||
*
|
||||
* @return httpClient
|
||||
*/
|
||||
public HttpClient getHttpClient() {
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets Http Client
|
||||
*
|
||||
* @param httpClient Http Client
|
||||
*/
|
||||
public void setHttpClient(HttpClient httpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
private String errMsg;
|
||||
|
||||
/**
|
||||
* Gets error message
|
||||
*
|
||||
* @return errMsg
|
||||
*/
|
||||
public String getErrMsg() {
|
||||
return errMsg;
|
||||
}
|
||||
|
||||
private @Nullable String accessToken = "";
|
||||
|
||||
private String loginAccount;
|
||||
private String password;
|
||||
private CloudProviderDTO cloudProvider;
|
||||
private Security security;
|
||||
|
||||
private @Nullable String loginId;
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* Parameters for Cloud Provider
|
||||
*
|
||||
* @param email email
|
||||
* @param password password
|
||||
* @param cloudProvider Cloud Provider
|
||||
*/
|
||||
public CloudDTO(String email, String password, CloudProviderDTO cloudProvider) {
|
||||
this.loginAccount = email;
|
||||
this.password = password;
|
||||
this.cloudProvider = cloudProvider;
|
||||
this.security = new Security(cloudProvider);
|
||||
logger.debug("Cloud provider: {}", cloudProvider.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the initial data payload with the global variable set
|
||||
*/
|
||||
private JsonObject apiRequest(String endpoint, JsonObject args, JsonObject data) {
|
||||
if (data == null) {
|
||||
data = new JsonObject();
|
||||
data.addProperty("appId", cloudProvider.appid());
|
||||
data.addProperty("format", FORMAT);
|
||||
data.addProperty("clientType", CLIENT_TYPE);
|
||||
data.addProperty("language", LANGUAGE);
|
||||
data.addProperty("src", cloudProvider.appid());
|
||||
data.addProperty("stamp", new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()));
|
||||
}
|
||||
|
||||
// Add the method parameters for the endpoint
|
||||
if (args != null) {
|
||||
for (Map.Entry<String, JsonElement> entry : args.entrySet()) {
|
||||
data.add(entry.getKey(), entry.getValue().getAsJsonPrimitive());
|
||||
}
|
||||
}
|
||||
|
||||
// Add the login information to the payload
|
||||
if (!data.has("reqId") && !Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
data.addProperty("reqId", Utils.tokenHex(16));
|
||||
}
|
||||
|
||||
String url = cloudProvider.apiurl() + endpoint;
|
||||
|
||||
int time = (int) (new Date().getTime() / 1000);
|
||||
|
||||
String random = String.valueOf(time);
|
||||
|
||||
// Add the sign to the header
|
||||
String json = data.toString();
|
||||
logger.debug("Request json: {}", json);
|
||||
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.POST).timeout(15, TimeUnit.SECONDS);
|
||||
|
||||
// .version(HttpVersion.HTTP_1_1)
|
||||
request.agent("Dalvik/2.1.0 (Linux; U; Android 7.0; SM-G935F Build/NRD90M)");
|
||||
|
||||
if (!Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
request.header("Content-Type", "application/json");
|
||||
} else {
|
||||
request.header("Content-Type", "application/x-www-form-urlencoded");
|
||||
}
|
||||
request.header("secretVersion", "1");
|
||||
if (!Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
String sign = security.newSign(json, random);
|
||||
request.header("sign", sign);
|
||||
} else {
|
||||
if (!Objects.isNull(sessionId) && !sessionId.isBlank()) {
|
||||
data.addProperty("sessionId", sessionId);
|
||||
}
|
||||
String sign = security.sign(url, data);
|
||||
data.addProperty("sign", sign);
|
||||
request.header("sign", sign);
|
||||
}
|
||||
|
||||
request.header("random", random);
|
||||
request.header("accessToken", accessToken);
|
||||
|
||||
logger.debug("Request headers: {}", request.getHeaders().toString());
|
||||
|
||||
if (!Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
request.content(new StringContentProvider(json));
|
||||
} else {
|
||||
String body = Utils.getQueryString(data);
|
||||
logger.debug("Request body: {}", body);
|
||||
request.content(new StringContentProvider(body));
|
||||
}
|
||||
|
||||
// POST the endpoint with the payload
|
||||
ContentResponse cr = null;
|
||||
try {
|
||||
cr = request.send();
|
||||
} catch (InterruptedException e) {
|
||||
logger.warn("an interupted error has occurred{}", e.getMessage());
|
||||
} catch (TimeoutException e) {
|
||||
logger.warn("a timeout error has occurred{}", e.getMessage());
|
||||
} catch (ExecutionException e) {
|
||||
logger.warn("an execution error has occurred{}", e.getMessage());
|
||||
}
|
||||
|
||||
if (cr != null) {
|
||||
logger.debug("Response json: {}", cr.getContentAsString());
|
||||
JsonObject result = Objects.requireNonNull(new Gson().fromJson(cr.getContentAsString(), JsonObject.class));
|
||||
|
||||
int code = -1;
|
||||
|
||||
if (result.get("errorCode") != null) {
|
||||
code = result.get("errorCode").getAsInt();
|
||||
} else if (result.get("code") != null) {
|
||||
code = result.get("code").getAsInt();
|
||||
} else {
|
||||
errMsg = "No code in cloud response";
|
||||
logger.warn("Error logging to Cloud: {}", errMsg);
|
||||
return null;
|
||||
}
|
||||
|
||||
String msg = result.get("msg").getAsString();
|
||||
if (code != 0) {
|
||||
errMsg = msg;
|
||||
handleApiError(code, msg);
|
||||
logger.warn("Error logging to Cloud: {}", msg);
|
||||
return null;
|
||||
} else {
|
||||
logger.debug("Api response ok: {} ({})", code, msg);
|
||||
if (!Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
return result.get("data").getAsJsonObject();
|
||||
} else {
|
||||
return result.get("result").getAsJsonObject();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn("No response from cloud!");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a user login with the credentials supplied to the constructor
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean login() {
|
||||
if (loginId == null) {
|
||||
if (!getLoginId()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Don't try logging in again, someone beat this thread to it
|
||||
if (!Objects.isNull(sessionId) && !sessionId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.trace("Using loginId: {}", loginId);
|
||||
logger.trace("Using password: {}", password);
|
||||
|
||||
if (!Objects.isNull(cloudProvider.proxied()) && !cloudProvider.proxied().isBlank()) {
|
||||
JsonObject newData = new JsonObject();
|
||||
|
||||
JsonObject data = new JsonObject();
|
||||
data.addProperty("platform", FORMAT);
|
||||
newData.add("data", data);
|
||||
|
||||
JsonObject iotData = new JsonObject();
|
||||
iotData.addProperty("appId", cloudProvider.appid());
|
||||
iotData.addProperty("clientType", CLIENT_TYPE);
|
||||
iotData.addProperty("iampwd", security.encryptIamPassword(loginId, password));
|
||||
iotData.addProperty("loginAccount", loginAccount);
|
||||
iotData.addProperty("password", security.encryptPassword(loginId, password));
|
||||
iotData.addProperty("pushToken", Utils.tokenUrlsafe(120));
|
||||
iotData.addProperty("reqId", Utils.tokenHex(16));
|
||||
iotData.addProperty("src", cloudProvider.appid());
|
||||
iotData.addProperty("stamp", new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()));
|
||||
newData.add("iotData", iotData);
|
||||
|
||||
JsonObject response = apiRequest("/mj/user/login", null, newData);
|
||||
|
||||
if (response == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
accessToken = response.getAsJsonObject("mdata").get("accessToken").getAsString();
|
||||
} else {
|
||||
String passwordEncrypted = security.encryptPassword(loginId, password);
|
||||
|
||||
JsonObject data = new JsonObject();
|
||||
data.addProperty("loginAccount", loginAccount);
|
||||
data.addProperty("password", passwordEncrypted);
|
||||
|
||||
JsonObject response = apiRequest("/v1/user/login", data, null);
|
||||
|
||||
if (response == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
accessToken = response.get("accessToken").getAsString();
|
||||
sessionId = response.get("sessionId").getAsString();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tokenlist with udpid
|
||||
*
|
||||
* @param udpid udp id
|
||||
* @return token and key
|
||||
*/
|
||||
public TokenKey getToken(String udpid) {
|
||||
long i = Long.valueOf(udpid);
|
||||
|
||||
JsonObject args = new JsonObject();
|
||||
args.addProperty("udpid", security.getUdpId(Utils.toIntTo6ByteArray(i, ByteOrder.BIG_ENDIAN)));
|
||||
JsonObject response = apiRequest("/v1/iot/secure/getToken", args, null);
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonArray tokenlist = response.getAsJsonArray("tokenlist");
|
||||
JsonObject el = tokenlist.get(0).getAsJsonObject();
|
||||
String token = el.getAsJsonPrimitive("token").getAsString();
|
||||
String key = el.getAsJsonPrimitive("key").getAsString();
|
||||
|
||||
setTokenRequested();
|
||||
|
||||
return new TokenKey(token, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login ID from the email address
|
||||
*/
|
||||
private boolean getLoginId() {
|
||||
JsonObject args = new JsonObject();
|
||||
args.addProperty("loginAccount", loginAccount);
|
||||
JsonObject response = apiRequest("/v1/user/login/id/get", args, null);
|
||||
if (response == null) {
|
||||
return false;
|
||||
}
|
||||
loginId = response.get("loginId").getAsString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleApiError(int asInt, String asString) {
|
||||
logger.debug("Api error in Cloud class");
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link CloudProviderDTO} class contains the information
|
||||
* to allow encryption and decryption for the supported Cloud Providers
|
||||
*
|
||||
* @param name Cloud provider
|
||||
* @param appkey application key
|
||||
* @param appid application id
|
||||
* @param apiurl application url
|
||||
* @param signkey sign key for AES
|
||||
* @param proxied proxy - MSmarthome only
|
||||
* @param iotkey iot key - MSmarthome only
|
||||
* @param hmackey hmac key - MSmarthome only
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - JavaDoc and conversion to record
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public record CloudProviderDTO(String name, String appkey, String appid, String apiurl, String signkey, String proxied,
|
||||
String iotkey, String hmackey) {
|
||||
|
||||
/**
|
||||
* Cloud provider information for record
|
||||
* All providers use the same signkey for AES encryption and Decryption.
|
||||
* V2 Devices do not require a Cloud Provider entry as they only use AES
|
||||
*
|
||||
* @param name Cloud provider
|
||||
* @return Cloud provider information (appkey, appid, apiurl,signkey, proxied, iotkey, hmackey)
|
||||
*/
|
||||
public static CloudProviderDTO getCloudProvider(String name) {
|
||||
switch (name) {
|
||||
case "NetHome Plus":
|
||||
return new CloudProviderDTO("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
|
||||
"https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
|
||||
case "Midea Air":
|
||||
return new CloudProviderDTO("Midea Air", "ff0cf6f5f0c3471de36341cab3f7a9af", "1117",
|
||||
"https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
|
||||
case "MSmartHome":
|
||||
return new CloudProviderDTO("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "1010",
|
||||
"https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S",
|
||||
"meicloud", "PROD_VnoClJI9aikS8dyy", "v5");
|
||||
}
|
||||
return new CloudProviderDTO("", "", "", "", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link Clouds} class securely stores email and password
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - JavaDoc
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CloudsDTO {
|
||||
|
||||
private final HashMap<Integer, CloudDTO> clouds;
|
||||
|
||||
/**
|
||||
* Cloud Provider data
|
||||
*/
|
||||
public CloudsDTO() {
|
||||
clouds = new HashMap<Integer, CloudDTO>();
|
||||
}
|
||||
|
||||
private CloudDTO add(String email, String password, CloudProviderDTO cloudProvider) {
|
||||
int hash = (email + password + cloudProvider.name()).hashCode();
|
||||
CloudDTO cloud = new CloudDTO(email, password, cloudProvider);
|
||||
clouds.put(hash, cloud);
|
||||
return cloud;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user provided cloud provider data
|
||||
*
|
||||
* @param email your email
|
||||
* @param password your password
|
||||
* @param cloudProvider your Cloud Provider
|
||||
* @return parameters for cloud provider
|
||||
*/
|
||||
public @Nullable CloudDTO get(String email, String password, CloudProviderDTO cloudProvider) {
|
||||
int hash = (email + password + cloudProvider.name()).hashCode();
|
||||
if (clouds.containsKey(hash)) {
|
||||
return clouds.get(hash);
|
||||
}
|
||||
return add(email, password, cloudProvider);
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link Response} performs the byte data stream decoding
|
||||
*
|
||||
* @author Leo Siepel - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface Callback {
|
||||
/**
|
||||
* Updates channels with the response
|
||||
*
|
||||
* @param response Byte response from the device used to update channels
|
||||
*/
|
||||
void updateChannels(Response response);
|
||||
}
|
@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.security.Crc8;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link CommandBase} has the discover command and the routine poll command
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - Add Java Docs, minor fixes
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CommandBase {
|
||||
private final Logger logger = LoggerFactory.getLogger(CommandBase.class);
|
||||
|
||||
private static final byte[] DISCOVER_COMMAND = new byte[] { (byte) 0x5a, (byte) 0x5a, (byte) 0x01, (byte) 0x11,
|
||||
(byte) 0x48, (byte) 0x00, (byte) 0x92, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
|
||||
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
|
||||
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
|
||||
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
|
||||
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x7f, (byte) 0x75, (byte) 0xbd, (byte) 0x6b,
|
||||
(byte) 0x3e, (byte) 0x4f, (byte) 0x8b, (byte) 0x76, (byte) 0x2e, (byte) 0x84, (byte) 0x9c, (byte) 0x6e,
|
||||
(byte) 0x57, (byte) 0x8d, (byte) 0x65, (byte) 0x90, (byte) 0x03, (byte) 0x6e, (byte) 0x9d, (byte) 0x43,
|
||||
(byte) 0x42, (byte) 0xa5, (byte) 0x0f, (byte) 0x1f, (byte) 0x56, (byte) 0x9e, (byte) 0xb8, (byte) 0xec,
|
||||
(byte) 0x91, (byte) 0x8e, (byte) 0x92, (byte) 0xe5 };
|
||||
|
||||
protected byte[] data;
|
||||
|
||||
/**
|
||||
* Operational Modes
|
||||
*/
|
||||
public enum OperationalMode {
|
||||
AUTO(1),
|
||||
COOL(2),
|
||||
DRY(3),
|
||||
HEAT(4),
|
||||
FAN_ONLY(5),
|
||||
UNKNOWN(0);
|
||||
|
||||
private final int value;
|
||||
|
||||
private OperationalMode(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Operational Mode value
|
||||
*
|
||||
* @return value
|
||||
*/
|
||||
public int getId() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides Operational Mode Common name
|
||||
*
|
||||
* @param id integer from byte response
|
||||
* @return type
|
||||
*/
|
||||
public static OperationalMode fromId(int id) {
|
||||
for (OperationalMode type : values()) {
|
||||
if (type.getId() == id) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts byte value to the Swing Mode label by version
|
||||
* Two versions of V3, Supported Swing or Non-Supported (4)
|
||||
* V2 set without leading 3, but reports with it (1)
|
||||
*/
|
||||
public enum SwingMode {
|
||||
OFF3(0x30, 3),
|
||||
OFF4(0x00, 3),
|
||||
VERTICAL3(0x3C, 3),
|
||||
VERTICAL4(0xC, 3),
|
||||
HORIZONTAL3(0x33, 3),
|
||||
HORIZONTAL4(0x3, 3),
|
||||
BOTH3(0x3F, 3),
|
||||
BOTH4(0xF, 3),
|
||||
OFF2(0, 2),
|
||||
VERTICAL2(0xC, 2),
|
||||
VERTICAL1(0x3C, 2),
|
||||
HORIZONTAL2(0x3, 2),
|
||||
HORIZONTAL1(0x33, 2),
|
||||
BOTH2(0xF, 2),
|
||||
BOTH1(0x3F, 2),
|
||||
|
||||
UNKNOWN(0xFF, 0);
|
||||
|
||||
private final int value;
|
||||
private final int version;
|
||||
|
||||
private SwingMode(int value, int version) {
|
||||
this.value = value;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Swing Mode value
|
||||
*
|
||||
* @return value
|
||||
*/
|
||||
public int getId() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets device version for swing mode
|
||||
*
|
||||
* @return version
|
||||
*/
|
||||
public int getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Swing mode in common language horiontal, vertical, off, etc.
|
||||
*
|
||||
* @param id integer from byte response
|
||||
* @param version device version
|
||||
* @return type
|
||||
*/
|
||||
public static SwingMode fromId(int id, int version) {
|
||||
for (SwingMode type : values()) {
|
||||
if (type.getId() == id && type.getVersion() == version) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// Drops the trailing 1 (V2 report) 2, 3 or 4 (nonsupported V3) from the swing mode
|
||||
return super.toString().replace("1", "").replace("2", "").replace("3", "").replace("4", "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts byte value to the Fan Speed label by version.
|
||||
* Some devices do not support all speeds
|
||||
*/
|
||||
public enum FanSpeed {
|
||||
AUTO2(102, 2),
|
||||
FULL2(100, 2),
|
||||
HIGH2(80, 2),
|
||||
MEDIUM2(50, 2),
|
||||
LOW2(30, 2),
|
||||
SILENT2(20, 2),
|
||||
UNKNOWN2(0, 2),
|
||||
|
||||
AUTO3(102, 3),
|
||||
FULL3(0, 3),
|
||||
HIGH3(80, 3),
|
||||
MEDIUM3(60, 3),
|
||||
LOW3(40, 3),
|
||||
SILENT3(30, 3),
|
||||
UNKNOWN3(0, 3),
|
||||
|
||||
UNKNOWN(0, 0);
|
||||
|
||||
private final int value;
|
||||
|
||||
private final int version;
|
||||
|
||||
private FanSpeed(int value, int version) {
|
||||
this.value = value;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Fan Speed value
|
||||
*
|
||||
* @return value
|
||||
*/
|
||||
public int getId() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets device version for Fan Speed
|
||||
*
|
||||
* @return version
|
||||
*/
|
||||
public int getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Fan Speed high, medium, low, etc
|
||||
*
|
||||
* @param id integer from byte response
|
||||
* @param version version
|
||||
* @return type
|
||||
*/
|
||||
public static FanSpeed fromId(int id, int version) {
|
||||
for (FanSpeed type : values()) {
|
||||
if (type.getId() == id && type.getVersion() == version) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// Drops the trailing 2 or 3 from the fan speed
|
||||
return super.toString().replace("2", "").replace("3", "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the command to discover devices.
|
||||
* Command is defined above
|
||||
*
|
||||
* @return discover command
|
||||
*/
|
||||
public static byte[] discover() {
|
||||
return DISCOVER_COMMAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte Array structure for Base commands
|
||||
*/
|
||||
public CommandBase() {
|
||||
data = new byte[] { (byte) 0xaa,
|
||||
// request is 0x20; setting is 0x23
|
||||
(byte) 0x20,
|
||||
// device type
|
||||
(byte) 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// request is 0x03; setting is 0x02
|
||||
(byte) 0x03,
|
||||
// Byte0 - Data request/response type: 0x41 - check status; 0x40 - Set up
|
||||
(byte) 0x41,
|
||||
// Byte1
|
||||
(byte) 0x81,
|
||||
// Byte2 - operational_mode
|
||||
0x00,
|
||||
// Byte3
|
||||
(byte) 0xff,
|
||||
// Byte4
|
||||
0x03,
|
||||
// Byte5
|
||||
(byte) 0xff,
|
||||
// Byte6
|
||||
0x00,
|
||||
// Byte7 - Room Temperature Request: 0x02 - indoor_temperature, 0x03 - outdoor_temperature
|
||||
// when set, this is swing_mode
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// Message ID
|
||||
0x00 };
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
data[data.length - 1] = (byte) now.getSecond();
|
||||
data[0x02] = (byte) 0xAC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the elements of the Base command together
|
||||
*/
|
||||
public void compose() {
|
||||
logger.trace("Base Bytes before crypt {}", Utils.bytesToHex(data));
|
||||
byte crc8 = (byte) Crc8.calculate(Arrays.copyOfRange(data, 10, data.length));
|
||||
byte[] newData1 = new byte[data.length + 1];
|
||||
System.arraycopy(data, 0, newData1, 0, data.length);
|
||||
newData1[data.length] = crc8;
|
||||
data = newData1;
|
||||
byte chksum = checksum(Arrays.copyOfRange(data, 1, data.length));
|
||||
byte[] newData2 = new byte[data.length + 1];
|
||||
System.arraycopy(data, 0, newData2, 0, data.length);
|
||||
newData2[data.length] = chksum;
|
||||
data = newData2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets byte array
|
||||
*
|
||||
* @return data array
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
return data;
|
||||
}
|
||||
|
||||
private static byte checksum(byte[] bytes) {
|
||||
int sum = 0;
|
||||
for (byte value : bytes) {
|
||||
sum = (byte) (sum + value);
|
||||
}
|
||||
sum = (byte) ((255 - (sum % 256)) + 1);
|
||||
return (byte) sum;
|
||||
}
|
||||
}
|
@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.handler.Timer.TimerData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This {@link CommandSet} class handles the allowed changes originating from
|
||||
* the items linked to the Midea AC channels. Not all devices
|
||||
* support all commands. The general process is to clear the
|
||||
* bit(s) the set them to the commanded value.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - Add Java Docs, minor fixes
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CommandSet extends CommandBase {
|
||||
private final Logger logger = LoggerFactory.getLogger(CommandSet.class);
|
||||
|
||||
/**
|
||||
* Byte array structure for Command set
|
||||
*/
|
||||
public CommandSet() {
|
||||
data[0x01] = (byte) 0x23;
|
||||
data[0x09] = (byte) 0x02;
|
||||
// Set up Mode
|
||||
data[0x0a] = (byte) 0x40;
|
||||
|
||||
byte[] extra = { 0x00, 0x00, 0x00 };
|
||||
byte[] newData = new byte[data.length + 3];
|
||||
System.arraycopy(data, 0, newData, 0, data.length);
|
||||
newData[data.length] = extra[0];
|
||||
newData[data.length + 1] = extra[1];
|
||||
newData[data.length + 2] = extra[2];
|
||||
data = newData;
|
||||
}
|
||||
|
||||
/**
|
||||
* These provide continuity so a new command on another channel
|
||||
* doesn't delete the current states of the other channels
|
||||
*
|
||||
* @param response response from last poll or set command
|
||||
* @return commandSet
|
||||
*/
|
||||
public static CommandSet fromResponse(Response response) {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
|
||||
commandSet.setPowerState(response.getPowerState());
|
||||
commandSet.setTargetTemperature(response.getTargetTemperature());
|
||||
commandSet.setOperationalMode(response.getOperationalMode());
|
||||
commandSet.setFanSpeed(response.getFanSpeed());
|
||||
commandSet.setFahrenheit(response.getFahrenheit());
|
||||
commandSet.setTurboMode(response.getTurboMode());
|
||||
commandSet.setSwingMode(response.getSwingMode());
|
||||
commandSet.setEcoMode(response.getEcoMode());
|
||||
commandSet.setSleepMode(response.getSleepFunction());
|
||||
commandSet.setOnTimer(response.getOnTimerData());
|
||||
commandSet.setOffTimer(response.getOffTimerData());
|
||||
return commandSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Causes indoor evaporator to beep when Set command received
|
||||
*
|
||||
* @param feedbackEnabled will indoor unit beep
|
||||
*/
|
||||
public void setPromptTone(boolean feedbackEnabled) {
|
||||
if (!feedbackEnabled) {
|
||||
data[0x0b] &= ~(byte) 0x40; // Clear
|
||||
} else {
|
||||
data[0x0b] |= (byte) 0x40; // Set
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns device On or Off
|
||||
*
|
||||
* @param state on or off
|
||||
*/
|
||||
public void setPowerState(boolean state) {
|
||||
if (!state) {
|
||||
data[0x0b] &= ~0x01;
|
||||
} else {
|
||||
data[0x0b] |= 0x01;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get result
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean getPowerState() {
|
||||
return (data[0x0b] & 0x1) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cool, Heat, Fan Only, etc. See Command Base class
|
||||
*
|
||||
* @param mode cool, heat, etc.
|
||||
*/
|
||||
public void setOperationalMode(OperationalMode mode) {
|
||||
data[0x0c] &= ~(byte) 0xe0;
|
||||
data[0x0c] |= ((byte) mode.getId() << 5) & (byte) 0xe0;
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get result
|
||||
*
|
||||
* @return operational mode
|
||||
*/
|
||||
public int getOperationalMode() {
|
||||
return data[0x0c] &= (byte) 0xe0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear, then set the temperature bits, including the 0.5 bit
|
||||
* This is all degrees C
|
||||
*
|
||||
* @param temperature target temperature
|
||||
*/
|
||||
public void setTargetTemperature(float temperature) {
|
||||
data[0x0c] &= ~0x0f;
|
||||
data[0x0c] |= (int) (Math.round(temperature * 2) / 2) & 0xf;
|
||||
setTemperatureDot5((Math.round(temperature * 2)) % 2 != 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get Setpoint results
|
||||
*
|
||||
* @return target temperature as a number
|
||||
*/
|
||||
public float getTargetTemperature() {
|
||||
return (data[0x0c] & 0xf) + 16.0f + (((data[0x0c] & 0x10) > 0) ? 0.5f : 0.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low, Medium, High, Auto etc. See Command Base class
|
||||
*
|
||||
* @param speed Set fan speed
|
||||
*/
|
||||
public void setFanSpeed(FanSpeed speed) {
|
||||
data[0x0d] = (byte) (speed.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get Fan Speed results
|
||||
*
|
||||
* @return fan speed as a number
|
||||
*/
|
||||
public int getFanSpeed() {
|
||||
return data[0x0d];
|
||||
}
|
||||
|
||||
/**
|
||||
* In cool mode sets Fan to Auto and temp to 24 C
|
||||
*
|
||||
* @param ecoModeEnabled true or false
|
||||
*/
|
||||
public void setEcoMode(boolean ecoModeEnabled) {
|
||||
if (!ecoModeEnabled) {
|
||||
data[0x13] &= ~0x80;
|
||||
} else {
|
||||
data[0x13] |= 0x80;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If unit supports, set the vertical and/or horzontal louver
|
||||
*
|
||||
* @param mode sets swing mode
|
||||
*/
|
||||
public void setSwingMode(SwingMode mode) {
|
||||
data[0x11] &= ~0x3f; // Clear the mode bits
|
||||
data[0x11] |= mode.getId() & 0x3f;
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get Swing result
|
||||
*
|
||||
* @return swing mode
|
||||
*/
|
||||
public int getSwingMode() {
|
||||
return data[0x11];
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the sleep function. Setpoint Temp increases in first
|
||||
* two hours of sleep by 1 degree in Cool mode
|
||||
*
|
||||
* @param sleepModeEnabled true or false
|
||||
*/
|
||||
public void setSleepMode(boolean sleepModeEnabled) {
|
||||
if (sleepModeEnabled) {
|
||||
data[0x14] |= 0x01;
|
||||
} else {
|
||||
data[0x14] &= (~0x01);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Turbo mode for maximum cooling or heat
|
||||
*
|
||||
* @param turboModeEnabled true or false
|
||||
*/
|
||||
public void setTurboMode(boolean turboModeEnabled) {
|
||||
if (turboModeEnabled) {
|
||||
data[0x14] |= 0x02;
|
||||
} else {
|
||||
data[0x14] &= (~0x02);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Indoor Unit display to Fahrenheit from Celsius
|
||||
*
|
||||
* @param fahrenheitEnabled true or false
|
||||
*/
|
||||
public void setFahrenheit(boolean fahrenheitEnabled) {
|
||||
if (fahrenheitEnabled) {
|
||||
data[0x14] |= 0x04;
|
||||
} else {
|
||||
data[0x14] &= (~0x04);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the LED display.
|
||||
* This uses the request format, so needed modification, but need to keep
|
||||
* current beep and operating state.
|
||||
*
|
||||
* @param screenDisplayToggle true (On) or false (off)
|
||||
*/
|
||||
public void setScreenDisplay(boolean screenDisplayToggle) {
|
||||
modifyBytesForDisplayOff();
|
||||
removeExtraBytes();
|
||||
logger.trace(" Set Bytes before crypt {}", Utils.bytesToHex(data));
|
||||
}
|
||||
|
||||
private void modifyBytesForDisplayOff() {
|
||||
data[0x01] = (byte) 0x20;
|
||||
data[0x09] = (byte) 0x03;
|
||||
data[0x0a] = (byte) 0x41;
|
||||
data[0x0b] |= 0x02; // Set
|
||||
data[0x0b] &= ~(byte) 0x80; // Clear
|
||||
data[0x0c] = (byte) 0x00;
|
||||
data[0x0d] = (byte) 0xff;
|
||||
data[0x0e] = (byte) 0x02;
|
||||
data[0x0f] = (byte) 0x00;
|
||||
data[0x10] = (byte) 0x02;
|
||||
data[0x11] = (byte) 0x00;
|
||||
data[0x12] = (byte) 0x00;
|
||||
data[0x13] = (byte) 0x00;
|
||||
data[0x14] = (byte) 0x00;
|
||||
}
|
||||
|
||||
private void removeExtraBytes() {
|
||||
byte[] newData = new byte[data.length - 3];
|
||||
System.arraycopy(data, 0, newData, 0, newData.length);
|
||||
data = newData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add 0.5C to the temperature value. If needed
|
||||
* Target_temperature setter calls this method
|
||||
*/
|
||||
private void setTemperatureDot5(boolean temperatureDot5Enabled) {
|
||||
if (temperatureDot5Enabled) {
|
||||
data[0x0c] |= 0x10;
|
||||
} else {
|
||||
data[0x0c] &= (~0x10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ON timer for AC device start.
|
||||
*
|
||||
* @param timerData status (On or Off), hours, minutes
|
||||
*/
|
||||
public void setOnTimer(TimerData timerData) {
|
||||
setOnTimer(timerData.status, timerData.hours, timerData.minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates remaining time until On
|
||||
*
|
||||
* @param on is timer on
|
||||
* @param hours hours remaining
|
||||
* @param minutes minutes remaining
|
||||
*/
|
||||
public void setOnTimer(boolean on, int hours, int minutes) {
|
||||
// Process minutes (1 bit = 15 minutes)
|
||||
int bits = (int) Math.floor(minutes / 15);
|
||||
int subtract = 0;
|
||||
if (bits != 0) {
|
||||
subtract = (15 - (int) (minutes - bits * 15));
|
||||
}
|
||||
if (bits == 0 && minutes != 0) {
|
||||
subtract = (15 - minutes);
|
||||
}
|
||||
data[0x0e] &= ~(byte) 0xff; // Clear
|
||||
data[0x10] &= ~(byte) 0xf0;
|
||||
if (on) {
|
||||
data[0x0e] |= 0x80;
|
||||
data[0x0e] |= (hours << 2) & 0x7c;
|
||||
data[0x0e] |= bits & 0x03;
|
||||
data[0x10] |= (subtract << 4) & 0xf0;
|
||||
} else {
|
||||
data[0x0e] = 0x7f;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get On Timer result
|
||||
*
|
||||
* @return timer data base
|
||||
*/
|
||||
public int getOnTimer() {
|
||||
return (data[0x0e] & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get On Timer result (subtraction amount)
|
||||
*
|
||||
* @return timer data subtraction
|
||||
*/
|
||||
public int getOnTimer2() {
|
||||
return ((data[0x10] & (byte) 0xf0) >> 4) & 0x0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the timer for AC device stop.
|
||||
*
|
||||
* @param timerData status (On or Off), hours, minutes
|
||||
*/
|
||||
public void setOffTimer(TimerData timerData) {
|
||||
setOffTimer(timerData.status, timerData.hours, timerData.minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates remaining time until Off
|
||||
*
|
||||
* @param on is timer on
|
||||
* @param hours hours remaining
|
||||
* @param minutes minutes remaining
|
||||
*/
|
||||
public void setOffTimer(boolean on, int hours, int minutes) {
|
||||
int bits = (int) Math.floor(minutes / 15);
|
||||
int subtract = 0;
|
||||
if (bits != 0) {
|
||||
subtract = (15 - (int) (minutes - bits * 15));
|
||||
}
|
||||
if (bits == 0 && minutes != 0) {
|
||||
subtract = (15 - minutes);
|
||||
}
|
||||
data[0x0f] &= ~(byte) 0xff; // Clear
|
||||
data[0x10] &= ~(byte) 0x0f;
|
||||
if (on) {
|
||||
data[0x0f] |= 0x80;
|
||||
data[0x0f] |= (hours << 2) & 0x7c;
|
||||
data[0x0f] |= bits & 0x03;
|
||||
data[0x10] |= subtract & 0x0f;
|
||||
} else {
|
||||
data[0x0f] = 0x7f;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get Off Timer result
|
||||
*
|
||||
* @return hours and minutes
|
||||
*/
|
||||
public int getOffTimer() {
|
||||
return (data[0x0f] & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* For Testing assertion get Off Timer result (subtraction)
|
||||
*
|
||||
* @return minutes to subtract
|
||||
*/
|
||||
public int getOffTimer2() {
|
||||
return ((data[0x10] & (byte) 0x0f)) & 0x0f;
|
||||
}
|
||||
}
|
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.mideaac.internal.MideaACConfiguration;
|
||||
import org.openhab.binding.mideaac.internal.connection.CommandHelper;
|
||||
import org.openhab.binding.mideaac.internal.connection.ConnectionManager;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaAuthenticationException;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaConnectionException;
|
||||
import org.openhab.binding.mideaac.internal.connection.exception.MideaException;
|
||||
import org.openhab.binding.mideaac.internal.discovery.DiscoveryHandler;
|
||||
import org.openhab.binding.mideaac.internal.discovery.MideaACDiscoveryService;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudDTO;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudsDTO;
|
||||
import org.openhab.binding.mideaac.internal.security.TokenKey;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.i18n.UnitProvider;
|
||||
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.types.StringType;
|
||||
import org.openhab.core.library.unit.ImperialUnits;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
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.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link MideaACHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Justan Oldman - Last Response added
|
||||
* @author Bob Eckhoff - Longer Polls and OH developer guidelines
|
||||
* @author Leo Siepel - Refactored class, improved seperation of concerns
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MideaACHandler extends BaseThingHandler implements DiscoveryHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(MideaACHandler.class);
|
||||
private final CloudsDTO clouds;
|
||||
private final boolean imperialUnits;
|
||||
private boolean isPollRunning = false;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private MideaACConfiguration config = new MideaACConfiguration();
|
||||
private Map<String, String> properties = new HashMap<>();
|
||||
// Default parameters are the same as in the MideaACConfiguration class
|
||||
private ConnectionManager connectionManager = new ConnectionManager("", 6444, 4, "", "", "", "", "", "", 0, false);
|
||||
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
||||
private @Nullable ScheduledFuture<?> scheduledTask = null;
|
||||
|
||||
private Callback callbackLambda = (response) -> {
|
||||
this.updateChannels(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial creation of the Midea AC Handler
|
||||
*
|
||||
* @param thing Thing
|
||||
* @param unitProvider OH core unit provider
|
||||
* @param httpClient http Client
|
||||
* @param clouds CloudsDTO
|
||||
*/
|
||||
public MideaACHandler(Thing thing, UnitProvider unitProvider, HttpClient httpClient, CloudsDTO clouds) {
|
||||
super(thing);
|
||||
this.thing = thing;
|
||||
this.imperialUnits = unitProvider.getMeasurementSystem() instanceof ImperialUnits;
|
||||
this.httpClient = httpClient;
|
||||
this.clouds = clouds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Cloud Provider
|
||||
*
|
||||
* @return clouds
|
||||
*/
|
||||
public CloudsDTO getClouds() {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method handles the AC Channels that can be set (non-read only)
|
||||
* The command set is formed using the previous command to only
|
||||
* change the item requested and leave the others the same.
|
||||
* The command set which is then sent to the device via the connectionManager.
|
||||
*/
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
logger.debug("Handling channelUID {} with command {}", channelUID.getId(), command.toString());
|
||||
ConnectionManager connectionManager = this.connectionManager;
|
||||
|
||||
if (command instanceof RefreshType) {
|
||||
try {
|
||||
connectionManager.getStatus(callbackLambda);
|
||||
} catch (MideaAuthenticationException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
|
||||
} catch (MideaConnectionException | MideaException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Response lastresponse = connectionManager.getLastResponse();
|
||||
if (channelUID.getId().equals(CHANNEL_POWER)) {
|
||||
connectionManager.sendCommand(CommandHelper.handlePower(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_OPERATIONAL_MODE)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleOperationalMode(command, lastresponse),
|
||||
callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_TARGET_TEMPERATURE)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleTargetTemperature(command, lastresponse),
|
||||
callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_FAN_SPEED)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleFanSpeed(command, lastresponse, config.version),
|
||||
callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_ECO_MODE)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleEcoMode(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_TURBO_MODE)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleTurboMode(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_SWING_MODE)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleSwingMode(command, lastresponse, config.version),
|
||||
callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_SCREEN_DISPLAY)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleScreenDisplay(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_TEMPERATURE_UNIT)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleTempUnit(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_SLEEP_FUNCTION)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleSleepFunction(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_ON_TIMER)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleOnTimer(command, lastresponse), callbackLambda);
|
||||
} else if (channelUID.getId().equals(CHANNEL_OFF_TIMER)) {
|
||||
connectionManager.sendCommand(CommandHelper.handleOffTimer(command, lastresponse), callbackLambda);
|
||||
}
|
||||
} catch (MideaConnectionException | MideaAuthenticationException e) {
|
||||
logger.warn("Unable to proces command: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize is called on first pass or when a device parameter is changed
|
||||
* The basic check is if the information from Discovery (or the user update)
|
||||
* is valid. Because V2 devices do not require a cloud provider (or token/key)
|
||||
* The first check is for the IP, port and deviceID. The second part
|
||||
* checks the security configuration if required (V3 device).
|
||||
*/
|
||||
@Override
|
||||
public void initialize() {
|
||||
if (isPollRunning) {
|
||||
stopScheduler();
|
||||
}
|
||||
config = getConfigAs(MideaACConfiguration.class);
|
||||
|
||||
if (!config.isValid()) {
|
||||
logger.warn("Configuration invalid for {}", thing.getUID());
|
||||
if (config.isDiscoveryNeeded()) {
|
||||
logger.warn("Discovery needed, discovering....{}", thing.getUID());
|
||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING,
|
||||
"Configuration missing, discovery needed. Discovering...");
|
||||
MideaACDiscoveryService discoveryService = new MideaACDiscoveryService();
|
||||
|
||||
try {
|
||||
discoveryService.discoverThing(config.ipAddress, this);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
logger.error("Discovery failure for {}: {}", thing.getUID(), e.getMessage());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Discovery failure. Check configuration.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.debug("MideaACHandler config of {} is invalid. Check configuration", thing.getUID());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Invalid MideaAC config. Check configuration.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.debug("Non-security Configuration valid for {}", thing.getUID());
|
||||
}
|
||||
|
||||
if (config.version == 3 && !config.isV3ConfigValid()) {
|
||||
if (config.isTokenKeyObtainable()) {
|
||||
logger.info("Retrieving Token and/or Key from cloud");
|
||||
CloudProviderDTO cloudProvider = CloudProviderDTO.getCloudProvider(config.cloud);
|
||||
getTokenKeyCloud(cloudProvider);
|
||||
return;
|
||||
} else {
|
||||
logger.warn("Configuration invalid for {} and no account info to retrieve from cloud", thing.getUID());
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.debug("Security Configuration (V3 Device) valid for {}", thing.getUID());
|
||||
}
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
connectionManager = new org.openhab.binding.mideaac.internal.connection.ConnectionManager(config.ipAddress,
|
||||
config.ipPort, config.timeout, config.key, config.token, config.cloud, config.email, config.password,
|
||||
config.deviceId, config.version, config.promptTone);
|
||||
|
||||
startScheduler(2, config.pollingTime, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Scheduler for the Polling
|
||||
*
|
||||
* @param initialDelay Seconds before first Poll
|
||||
* @param delay Seconds between Polls
|
||||
* @param unit Seconds
|
||||
*/
|
||||
private void startScheduler(long initialDelay, long delay, TimeUnit unit) {
|
||||
if (scheduledTask == null) {
|
||||
isPollRunning = true;
|
||||
scheduledTask = scheduler.scheduleWithFixedDelay(this::pollJob, initialDelay, delay, unit);
|
||||
logger.debug("Scheduled task started");
|
||||
} else {
|
||||
logger.debug("Scheduler already running");
|
||||
}
|
||||
}
|
||||
|
||||
private void pollJob() {
|
||||
ConnectionManager connectionManager = this.connectionManager;
|
||||
|
||||
try {
|
||||
connectionManager.getStatus(callbackLambda);
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} catch (MideaAuthenticationException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
|
||||
} catch (MideaConnectionException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
} catch (MideaException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChannel(String channelName, State state) {
|
||||
if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
|
||||
return;
|
||||
}
|
||||
Channel channel = thing.getChannel(channelName);
|
||||
if (channel != null && isLinked(channel.getUID())) {
|
||||
updateState(channel.getUID(), state);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChannels(Response response) {
|
||||
updateChannel(CHANNEL_POWER, OnOffType.from(response.getPowerState()));
|
||||
updateChannel(CHANNEL_APPLIANCE_ERROR, OnOffType.from(response.getApplianceError()));
|
||||
updateChannel(CHANNEL_OPERATIONAL_MODE, new StringType(response.getOperationalMode().toString()));
|
||||
updateChannel(CHANNEL_FAN_SPEED, new StringType(response.getFanSpeed().toString()));
|
||||
updateChannel(CHANNEL_ON_TIMER, new StringType(response.getOnTimer().toChannel()));
|
||||
updateChannel(CHANNEL_OFF_TIMER, new StringType(response.getOffTimer().toChannel()));
|
||||
updateChannel(CHANNEL_SWING_MODE, new StringType(response.getSwingMode().toString()));
|
||||
updateChannel(CHANNEL_AUXILIARY_HEAT, OnOffType.from(response.getAuxHeat()));
|
||||
updateChannel(CHANNEL_ECO_MODE, OnOffType.from(response.getEcoMode()));
|
||||
updateChannel(CHANNEL_TEMPERATURE_UNIT, OnOffType.from(response.getFahrenheit()));
|
||||
updateChannel(CHANNEL_SLEEP_FUNCTION, OnOffType.from(response.getSleepFunction()));
|
||||
updateChannel(CHANNEL_TURBO_MODE, OnOffType.from(response.getTurboMode()));
|
||||
updateChannel(CHANNEL_SCREEN_DISPLAY, OnOffType.from(response.getDisplayOn()));
|
||||
updateChannel(CHANNEL_HUMIDITY, new DecimalType(response.getHumidity()));
|
||||
|
||||
QuantityType<Temperature> targetTemperature = new QuantityType<Temperature>(response.getTargetTemperature(),
|
||||
SIUnits.CELSIUS);
|
||||
QuantityType<Temperature> alternateTemperature = new QuantityType<Temperature>(
|
||||
response.getAlternateTargetTemperature(), SIUnits.CELSIUS);
|
||||
QuantityType<Temperature> outdoorTemperature = new QuantityType<Temperature>(response.getOutdoorTemperature(),
|
||||
SIUnits.CELSIUS);
|
||||
QuantityType<Temperature> indoorTemperature = new QuantityType<Temperature>(response.getIndoorTemperature(),
|
||||
SIUnits.CELSIUS);
|
||||
|
||||
if (imperialUnits) {
|
||||
targetTemperature = Objects.requireNonNull(targetTemperature.toUnit(ImperialUnits.FAHRENHEIT));
|
||||
alternateTemperature = Objects.requireNonNull(alternateTemperature.toUnit(ImperialUnits.FAHRENHEIT));
|
||||
indoorTemperature = Objects.requireNonNull(indoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
|
||||
outdoorTemperature = Objects.requireNonNull(outdoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
|
||||
}
|
||||
|
||||
updateChannel(CHANNEL_TARGET_TEMPERATURE, targetTemperature);
|
||||
updateChannel(CHANNEL_ALTERNATE_TARGET_TEMPERATURE, alternateTemperature);
|
||||
updateChannel(CHANNEL_INDOOR_TEMPERATURE, indoorTemperature);
|
||||
updateChannel(CHANNEL_OUTDOOR_TEMPERATURE, outdoorTemperature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discovered(DiscoveryResult discoveryResult) {
|
||||
logger.debug("Discovered {}", thing.getUID());
|
||||
Map<String, Object> discoveryProps = discoveryResult.getProperties();
|
||||
Configuration configuration = editConfiguration();
|
||||
|
||||
Object propertyDeviceId = Objects.requireNonNull(discoveryProps.get(CONFIG_DEVICEID));
|
||||
configuration.put(CONFIG_DEVICEID, propertyDeviceId.toString());
|
||||
|
||||
Object propertyIpPort = Objects.requireNonNull(discoveryProps.get(CONFIG_IP_PORT));
|
||||
configuration.put(CONFIG_IP_PORT, propertyIpPort.toString());
|
||||
|
||||
Object propertyVersion = Objects.requireNonNull(discoveryProps.get(CONFIG_VERSION));
|
||||
BigDecimal bigDecimalVersion = new BigDecimal((String) propertyVersion);
|
||||
logger.trace("Property Version in Handler {}", bigDecimalVersion.intValue());
|
||||
configuration.put(CONFIG_VERSION, bigDecimalVersion.intValue());
|
||||
|
||||
updateConfiguration(configuration);
|
||||
|
||||
properties = editProperties();
|
||||
|
||||
Object propertySN = Objects.requireNonNull(discoveryProps.get(PROPERTY_SN));
|
||||
properties.put(PROPERTY_SN, propertySN.toString());
|
||||
|
||||
Object propertySSID = Objects.requireNonNull(discoveryProps.get(PROPERTY_SSID));
|
||||
properties.put(PROPERTY_SSID, propertySSID.toString());
|
||||
|
||||
Object propertyType = Objects.requireNonNull(discoveryProps.get(PROPERTY_TYPE));
|
||||
properties.put(PROPERTY_TYPE, propertyType.toString());
|
||||
|
||||
updateProperties(properties);
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the token and key from the Cloud
|
||||
*
|
||||
* @param cloudProvider Cloud Provider account
|
||||
*/
|
||||
public void getTokenKeyCloud(CloudProviderDTO cloudProvider) {
|
||||
CloudDTO cloud = getClouds().get(config.email, config.password, cloudProvider);
|
||||
if (cloud != null) {
|
||||
cloud.setHttpClient(httpClient);
|
||||
if (cloud.login()) {
|
||||
TokenKey tk = cloud.getToken(config.deviceId);
|
||||
Configuration configuration = editConfiguration();
|
||||
|
||||
configuration.put(CONFIG_TOKEN, tk.token());
|
||||
configuration.put(CONFIG_KEY, tk.key());
|
||||
updateConfiguration(configuration);
|
||||
|
||||
logger.trace("Token: {}", tk.token());
|
||||
logger.trace("Key: {}", tk.key());
|
||||
logger.info("Token and Key obtained from cloud, saving, back to initialize");
|
||||
initialize();
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format(
|
||||
"Can't retrieve Token and Key from Cloud; email, password and/or cloud parameter error"));
|
||||
logger.warn("Can't retrieve Token and Key from Cloud; email, password and/or cloud parameter error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stopScheduler() {
|
||||
ScheduledFuture<?> localScheduledTask = this.scheduledTask;
|
||||
|
||||
if (localScheduledTask != null && !localScheduledTask.isCancelled()) {
|
||||
localScheduledTask.cancel(true);
|
||||
logger.debug("Scheduled task cancelled.");
|
||||
isPollRunning = false;
|
||||
scheduledTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
stopScheduler();
|
||||
connectionManager.dispose(true);
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.handler;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.security.Security;
|
||||
|
||||
/**
|
||||
* The {@link Packet} class for Midea AC creates the
|
||||
* byte array that is sent to the device
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Packet {
|
||||
private CommandBase command;
|
||||
private byte[] packet;
|
||||
private Security security;
|
||||
|
||||
/**
|
||||
* The Packet class parameters
|
||||
*
|
||||
* @param command command from Command Base
|
||||
* @param deviceId the device ID
|
||||
* @param security the Security class
|
||||
*/
|
||||
public Packet(CommandBase command, String deviceId, Security security) {
|
||||
this.command = command;
|
||||
this.security = security;
|
||||
|
||||
packet = new byte[] {
|
||||
// 2 bytes - StaticHeader
|
||||
(byte) 0x5a, (byte) 0x5a,
|
||||
// 2 bytes - mMessageType
|
||||
(byte) 0x01, (byte) 0x11,
|
||||
// 2 bytes - PacketLength
|
||||
(byte) 0x00, (byte) 0x00,
|
||||
// 2 bytes
|
||||
(byte) 0x20, (byte) 0x00,
|
||||
// 4 bytes - MessageId
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// 8 bytes - Date&Time
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// 6 bytes - mDeviceID
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
// 14 bytes
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
byte[] datetimeBytes = { (byte) (now.getYear() / 100), (byte) (now.getYear() % 100), (byte) now.getMonthValue(),
|
||||
(byte) now.getDayOfMonth(), (byte) now.getHour(), (byte) now.getMinute(), (byte) now.getSecond(),
|
||||
(byte) System.currentTimeMillis() };
|
||||
|
||||
System.arraycopy(datetimeBytes, 0, packet, 12, 8);
|
||||
|
||||
byte[] idBytes = new BigInteger(deviceId).toByteArray();
|
||||
byte[] idBytesRev = Utils.reverse(idBytes);
|
||||
System.arraycopy(idBytesRev, 0, packet, 20, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final composure of the byte array with the encrypted command
|
||||
*/
|
||||
public void compose() {
|
||||
command.compose();
|
||||
|
||||
// Append the command data(48 bytes) to the packet
|
||||
byte[] cmdEncrypted = security.aesEncrypt(command.getBytes());
|
||||
|
||||
// Ensure 48 bytes
|
||||
if (cmdEncrypted.length < 48) {
|
||||
byte[] paddedCmdEncrypted = new byte[48];
|
||||
System.arraycopy(cmdEncrypted, 0, paddedCmdEncrypted, 0, cmdEncrypted.length);
|
||||
cmdEncrypted = paddedCmdEncrypted;
|
||||
}
|
||||
|
||||
byte[] newPacket = new byte[packet.length + cmdEncrypted.length];
|
||||
System.arraycopy(packet, 0, newPacket, 0, packet.length);
|
||||
System.arraycopy(cmdEncrypted, 0, newPacket, packet.length, cmdEncrypted.length);
|
||||
packet = newPacket;
|
||||
|
||||
// Override packet length bytes with actual values
|
||||
byte[] lenBytes = { (byte) (packet.length + 16), 0 };
|
||||
System.arraycopy(lenBytes, 0, packet, 4, 2);
|
||||
|
||||
// calculate checksum data
|
||||
byte[] checksumData = security.encode32Data(packet);
|
||||
|
||||
// Append a basic checksum data(16 bytes) to the packet
|
||||
byte[] newPacketTwo = new byte[packet.length + checksumData.length];
|
||||
System.arraycopy(packet, 0, newPacketTwo, 0, packet.length);
|
||||
System.arraycopy(checksumData, 0, newPacketTwo, packet.length, checksumData.length);
|
||||
packet = newPacketTwo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the packet for sending
|
||||
*
|
||||
* @return packet for socket writer
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
return packet;
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.handler;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
|
||||
import org.openhab.binding.mideaac.internal.handler.Timer.TimerData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link Response} performs the byte data stream decoding
|
||||
* The original reference is
|
||||
* https://github.com/georgezhao2010/midea_ac_lan/blob/06fc4b582a012bbbfd6bd5942c92034270eca0eb/custom_components/midea_ac_lan/midea/devices/ac/message.py#L418
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - Add Java Docs, minor fixes
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Response {
|
||||
byte[] data;
|
||||
|
||||
// set empty to match the return from an empty byte avoid null
|
||||
float empty = (float) -19.0;
|
||||
private Logger logger = LoggerFactory.getLogger(Response.class);
|
||||
|
||||
private final int version;
|
||||
String responseType;
|
||||
byte bodyType;
|
||||
|
||||
private int getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response class Parameters
|
||||
*
|
||||
* @param data byte array from device
|
||||
* @param version version of the device
|
||||
* @param responseType response type
|
||||
* @param bodyType Body type
|
||||
*/
|
||||
public Response(byte[] data, int version, String responseType, byte bodyType) {
|
||||
this.data = data;
|
||||
this.version = version;
|
||||
this.bodyType = bodyType;
|
||||
this.responseType = responseType;
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Power State: {}", getPowerState());
|
||||
logger.debug("Target Temperature: {}", getTargetTemperature());
|
||||
logger.debug("Operational Mode: {}", getOperationalMode());
|
||||
logger.debug("Fan Speed: {}", getFanSpeed());
|
||||
logger.debug("On Timer: {}", getOnTimer());
|
||||
logger.debug("Off Timer: {}", getOffTimer());
|
||||
logger.debug("Swing Mode: {}", getSwingMode());
|
||||
logger.debug("Sleep Function: {}", getSleepFunction());
|
||||
logger.debug("Turbo Mode: {}", getTurboMode());
|
||||
logger.debug("Eco Mode: {}", getEcoMode());
|
||||
logger.debug("Indoor Temperature: {}", getIndoorTemperature());
|
||||
logger.debug("Outdoor Temperature: {}", getOutdoorTemperature());
|
||||
logger.debug("LED Display: {}", getDisplayOn());
|
||||
}
|
||||
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Prompt Tone: {}", getPromptTone());
|
||||
logger.trace("Appliance Error: {}", getApplianceError());
|
||||
logger.trace("Auxiliary Heat: {}", getAuxHeat());
|
||||
logger.trace("Fahrenheit: {}", getFahrenheit());
|
||||
logger.trace("Humidity: {}", getHumidity());
|
||||
logger.trace("Alternate Target Temperature {}", getAlternateTargetTemperature());
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace Log Response and Body Type for V3. V2 set at "" and 0x00
|
||||
* This was for future development since only 0xC0 is currently used
|
||||
*/
|
||||
if (version == 3) {
|
||||
logger.trace("Response and Body Type: {}, {}", responseType, bodyType);
|
||||
if ("notify2".equals(responseType) && bodyType == -95) { // 0xA0 = -95
|
||||
logger.trace("Response Handler: XA0Message");
|
||||
} else if ("notify1".equals(responseType) && bodyType == -91) { // 0xA1 = -91
|
||||
logger.trace("Response Handler: XA1Message");
|
||||
} else if (("notify2".equals(responseType) || "set".equals(responseType) || "query".equals(responseType))
|
||||
&& (bodyType == 0xB0 || bodyType == 0xB1 || bodyType == 0xB5)) {
|
||||
logger.trace("Response Handler: XBXMessage");
|
||||
} else if (("set".equals(responseType) || "query".equals(responseType)) && bodyType == -64) { // 0xC0 = -64
|
||||
logger.trace("Response Handler: XCOMessage");
|
||||
} else if ("query".equals(responseType) && bodyType == 0xC1) {
|
||||
logger.trace("Response Handler: XC1Message");
|
||||
} else {
|
||||
logger.trace("Response Handler: _general_");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Device On or Off
|
||||
*
|
||||
* @return power state true or false
|
||||
*/
|
||||
public boolean getPowerState() {
|
||||
return (data[0x01] & 0x1) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only
|
||||
*
|
||||
* @return prompt tone true or false
|
||||
*/
|
||||
public boolean getPromptTone() {
|
||||
return (data[0x01] & 0x40) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only
|
||||
*
|
||||
* @return appliance error true or false
|
||||
*/
|
||||
public boolean getApplianceError() {
|
||||
return (data[0x01] & 0x80) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setpoint for Heat Pump
|
||||
*
|
||||
* @return current setpoint in degrees C
|
||||
*/
|
||||
public float getTargetTemperature() {
|
||||
return (data[0x02] & 0xf) + 16.0f + (((data[0x02] & 0x10) > 0) ? 0.5f : 0.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cool, Heat, Fan Only, etc. See Command Base class
|
||||
*
|
||||
* @return Cool, Heat, Fan Only, etc.
|
||||
*/
|
||||
public OperationalMode getOperationalMode() {
|
||||
return OperationalMode.fromId((data[0x02] & 0xe0) >> 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low, Medium, High, Auto etc. See Command Base class
|
||||
*
|
||||
* @return Low, Medium, High, Auto etc.
|
||||
*/
|
||||
public FanSpeed getFanSpeed() {
|
||||
return FanSpeed.fromId(data[0x03] & 0x7f, getVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates String representation of the On timer to the channel
|
||||
*
|
||||
* @return String of HH:MM
|
||||
*/
|
||||
public Timer getOnTimer() {
|
||||
return new Timer((data[0x04] & 0x80) > 0, ((data[0x04] & (byte) 0x7c) >> 2),
|
||||
((data[0x04] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf0) >> 4) & 0x0f)));
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to carry the current On Timer (last response) through
|
||||
* subsequent Set commands, so it is not overwritten.
|
||||
*
|
||||
* @return status plus String of HH:MM
|
||||
*/
|
||||
public TimerData getOnTimerData() {
|
||||
int hours = 0;
|
||||
int minutes = 0;
|
||||
Timer timer = new Timer(true, hours, minutes);
|
||||
boolean status = (data[0x04] & 0x80) > 0;
|
||||
hours = ((data[0x04] & (byte) 0x7c) >> 2);
|
||||
minutes = ((data[0x04] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf0) >> 4) & 0x0f));
|
||||
return timer.new TimerData(status, hours, minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates String representation of the Off timer to the channel
|
||||
*
|
||||
* @return String of HH:MM
|
||||
*/
|
||||
public Timer getOffTimer() {
|
||||
return new Timer((data[0x05] & 0x80) > 0, ((data[0x05] & (byte) 0x7c) >> 2),
|
||||
((data[0x05] & 0x3) * 15 + 15 - (data[0x06] & (byte) 0xf)));
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to carry the Off timer (last response) through
|
||||
* subsequent Set commands, so it is not overwritten.
|
||||
*
|
||||
* @return status plus String of HH:MM
|
||||
*/
|
||||
public TimerData getOffTimerData() {
|
||||
int hours = 0;
|
||||
int minutes = 0;
|
||||
Timer timer = new Timer(true, hours, minutes);
|
||||
boolean status = (data[0x05] & 0x80) > 0;
|
||||
hours = ((data[0x05] & (byte) 0x7c) >> 2);
|
||||
minutes = (data[0x05] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf) & 0x0f));
|
||||
return timer.new TimerData(status, hours, minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of the vertical and/or horzontal louver
|
||||
*
|
||||
* @return Vertical, Horizontal, Off, Both
|
||||
*/
|
||||
public SwingMode getSwingMode() {
|
||||
return SwingMode.fromId(data[0x07] & 0x3f, getVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only - heat mode only
|
||||
*
|
||||
* @return auxiliary heat active
|
||||
*/
|
||||
public boolean getAuxHeat() {
|
||||
return (data[0x09] & (byte) 0x08) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ecomode status - Fan to Auto and temp to 24 C
|
||||
*
|
||||
* @return Eco mode on (true) or (false)
|
||||
*/
|
||||
public boolean getEcoMode() {
|
||||
return (data[0x09] & (byte) 0x10) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep function status. Setpoint Temp increases in first
|
||||
* two hours of sleep by 1 degree in Cool mode
|
||||
*
|
||||
* @return Sleep mode on (true) or (false)
|
||||
*/
|
||||
public boolean getSleepFunction() {
|
||||
return (data[0x0a] & (byte) 0x01) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turbo mode status for maximum cooling or heat
|
||||
*
|
||||
* @return Turbo mode on (true) or (false)
|
||||
*/
|
||||
public boolean getTurboMode() {
|
||||
return (data[0x0a] & (byte) 0x02) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* If true display on indoor unit is degrees F, else C
|
||||
*
|
||||
* @return Fahrenheit on (true) or Celsius
|
||||
*/
|
||||
public boolean getFahrenheit() {
|
||||
return (data[0x0a] & (byte) 0x04) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* There is some variation in how this is handled by different
|
||||
* AC models. This covers at least 2 versions found.
|
||||
*
|
||||
* @return Indoor temperature
|
||||
*/
|
||||
public Float getIndoorTemperature() {
|
||||
double indoorTempInteger;
|
||||
double indoorTempDecimal;
|
||||
|
||||
if (data[0] == (byte) 0xc0) {
|
||||
if (((Byte.toUnsignedInt(data[11]) - 50) / 2.0) < -19) {
|
||||
return (float) -19;
|
||||
}
|
||||
if (((Byte.toUnsignedInt(data[11]) - 50) / 2.0) > 50) {
|
||||
return (float) 50;
|
||||
} else {
|
||||
indoorTempInteger = (float) ((Byte.toUnsignedInt(data[11]) - 50f) / 2.0f);
|
||||
}
|
||||
|
||||
indoorTempDecimal = (float) ((data[15] & 0x0F) * 0.1f);
|
||||
|
||||
if (Byte.toUnsignedInt(data[11]) > 49) {
|
||||
return (float) (indoorTempInteger + indoorTempDecimal);
|
||||
} else {
|
||||
return (float) (indoorTempInteger - indoorTempDecimal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not observed or tested, but left in from original author
|
||||
* This was for future development since only 0xC0 is currently used
|
||||
*/
|
||||
if (data[0] == (byte) 0xa0 || data[0] == (byte) 0xa1) {
|
||||
if (data[0] == (byte) 0xa0) {
|
||||
if ((data[1] >> 2) - 4 == 0) {
|
||||
indoorTempInteger = -1;
|
||||
} else {
|
||||
indoorTempInteger = (data[1] >> 2) + 12;
|
||||
}
|
||||
|
||||
if (((data[1] >> 1) & 0x01) == 1) {
|
||||
indoorTempDecimal = 0.5f;
|
||||
} else {
|
||||
indoorTempDecimal = 0;
|
||||
}
|
||||
}
|
||||
if (data[0] == (byte) 0xa1) {
|
||||
if (((Byte.toUnsignedInt(data[13]) - 50) / 2.0f) < -19) {
|
||||
return (float) -19;
|
||||
}
|
||||
if (((Byte.toUnsignedInt(data[13]) - 50) / 2.0f) > 50) {
|
||||
return (float) 50;
|
||||
} else {
|
||||
indoorTempInteger = (float) (Byte.toUnsignedInt(data[13]) - 50f) / 2.0f;
|
||||
}
|
||||
indoorTempDecimal = (data[18] & 0x0f) * 0.1f;
|
||||
|
||||
if (Byte.toUnsignedInt(data[13]) > 49) {
|
||||
return (float) (indoorTempInteger + indoorTempDecimal);
|
||||
} else {
|
||||
return (float) (indoorTempInteger - indoorTempDecimal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* There is some variation in how this is handled by different
|
||||
* AC models. This covers at least 2 versions. Some models
|
||||
* do not report outside temp when the AC is off. Returns 0.0 in that case.
|
||||
*
|
||||
* @return Outdoor temperature
|
||||
*/
|
||||
public Float getOutdoorTemperature() {
|
||||
if (data[12] != (byte) 0xff) {
|
||||
double tempInteger = (float) (Byte.toUnsignedInt(data[12]) - 50f) / 2.0f;
|
||||
double tempDecimal = ((data[15] & 0xf0) >> 4) * 0.1f;
|
||||
if (Byte.toUnsignedInt(data[12]) > 49) {
|
||||
return (float) (tempInteger + tempDecimal);
|
||||
} else {
|
||||
return (float) (tempInteger - tempDecimal);
|
||||
}
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Alternative Target Temperature (not used)
|
||||
*
|
||||
* @return Alternate target Temperature
|
||||
*/
|
||||
public Float getAlternateTargetTemperature() {
|
||||
if ((data[13] & 0x1f) != 0) {
|
||||
return (data[13] & 0x1f) + 12.0f + (((data[0x02] & 0x10) > 0) ? 0.5f : 0.0f);
|
||||
} else {
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns status of Device LEDs
|
||||
*
|
||||
* @return LEDs on (true) or (false)
|
||||
*/
|
||||
public boolean getDisplayOn() {
|
||||
return (data[14] & (byte) 0x70) != (byte) 0x70;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not observed with units being tested
|
||||
* From reference Document
|
||||
*
|
||||
* @return humidity
|
||||
*/
|
||||
public int getHumidity() {
|
||||
return (data[19] & (byte) 0x7f);
|
||||
}
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link Timer} class returns the On and Off AC Timer values
|
||||
* to the channels.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial contribution
|
||||
* @author Bob Eckhoff - Add TimeParser and TimeData classes
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Timer {
|
||||
|
||||
private boolean status;
|
||||
private int hours;
|
||||
private int minutes;
|
||||
|
||||
/**
|
||||
* Timer class parameters
|
||||
*
|
||||
* @param status on or off
|
||||
* @param hours hours
|
||||
* @param minutes minutes
|
||||
*/
|
||||
public Timer(boolean status, int hours, int minutes) {
|
||||
this.status = status;
|
||||
this.hours = hours;
|
||||
this.minutes = minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer format for the trace log
|
||||
*/
|
||||
public String toString() {
|
||||
if (status) {
|
||||
return String.format("enabled: %s, hours: %d, minutes: %d", status, hours, minutes);
|
||||
} else {
|
||||
return String.format("enabled: %s", status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer format of the OH channel
|
||||
*
|
||||
* @return conforming String
|
||||
*/
|
||||
public String toChannel() {
|
||||
if (status) {
|
||||
return String.format("%02d:%02d", hours, minutes);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This splits the On or off timer channels command back to hours and minutes
|
||||
* so the AC start and stop timers can be set
|
||||
*/
|
||||
public class TimeParser {
|
||||
/**
|
||||
* Parse Time string into components
|
||||
*
|
||||
* @param time conforming string
|
||||
* @return hours and minutes
|
||||
*/
|
||||
public int[] parseTime(String time) {
|
||||
String[] parts = time.split(":");
|
||||
int hours = Integer.parseInt(parts[0]);
|
||||
int minutes = Integer.parseInt(parts[1]);
|
||||
|
||||
return new int[] { hours, minutes };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This allows the continuity of the current timer settings
|
||||
* when new commands on other channels are set.
|
||||
*/
|
||||
public class TimerData {
|
||||
/**
|
||||
* Status if timer is on
|
||||
*/
|
||||
public boolean status;
|
||||
|
||||
/**
|
||||
* Current hours
|
||||
*/
|
||||
public int hours;
|
||||
|
||||
/**
|
||||
* Current minutes
|
||||
*/
|
||||
public int minutes;
|
||||
|
||||
/**
|
||||
* Sets the TimerData from the response
|
||||
*
|
||||
* @param status true if timer is on
|
||||
* @param hours hours left
|
||||
* @param minutes minutes left
|
||||
*/
|
||||
public TimerData(boolean status, int hours, int minutes) {
|
||||
this.status = status;
|
||||
this.hours = hours;
|
||||
this.minutes = minutes;
|
||||
}
|
||||
}
|
||||
}
|
@ -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.mideaac.internal.security;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link Crc8} calculation.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Crc8 {
|
||||
private static final byte[] CRC8_854_TABLE = { (byte) 0x00, (byte) 0x5E, (byte) 0xBC, (byte) 0xE2, (byte) 0x61,
|
||||
(byte) 0x3F, (byte) 0xDD, (byte) 0x83, (byte) 0xC2, (byte) 0x9C, (byte) 0x7E, (byte) 0x20, (byte) 0xA3,
|
||||
(byte) 0xFD, (byte) 0x1F, (byte) 0x41, (byte) 0x9D, (byte) 0xC3, (byte) 0x21, (byte) 0x7F, (byte) 0xFC,
|
||||
(byte) 0xA2, (byte) 0x40, (byte) 0x1E, (byte) 0x5F, (byte) 0x01, (byte) 0xE3, (byte) 0xBD, (byte) 0x3E,
|
||||
(byte) 0x60, (byte) 0x82, (byte) 0xDC, (byte) 0x23, (byte) 0x7D, (byte) 0x9F, (byte) 0xC1, (byte) 0x42,
|
||||
(byte) 0x1C, (byte) 0xFE, (byte) 0xA0, (byte) 0xE1, (byte) 0xBF, (byte) 0x5D, (byte) 0x03, (byte) 0x80,
|
||||
(byte) 0xDE, (byte) 0x3C, (byte) 0x62, (byte) 0xBE, (byte) 0xE0, (byte) 0x02, (byte) 0x5C, (byte) 0xDF,
|
||||
(byte) 0x81, (byte) 0x63, (byte) 0x3D, (byte) 0x7C, (byte) 0x22, (byte) 0xC0, (byte) 0x9E, (byte) 0x1D,
|
||||
(byte) 0x43, (byte) 0xA1, (byte) 0xFF, (byte) 0x46, (byte) 0x18, (byte) 0xFA, (byte) 0xA4, (byte) 0x27,
|
||||
(byte) 0x79, (byte) 0x9B, (byte) 0xC5, (byte) 0x84, (byte) 0xDA, (byte) 0x38, (byte) 0x66, (byte) 0xE5,
|
||||
(byte) 0xBB, (byte) 0x59, (byte) 0x07, (byte) 0xDB, (byte) 0x85, (byte) 0x67, (byte) 0x39, (byte) 0xBA,
|
||||
(byte) 0xE4, (byte) 0x06, (byte) 0x58, (byte) 0x19, (byte) 0x47, (byte) 0xA5, (byte) 0xFB, (byte) 0x78,
|
||||
(byte) 0x26, (byte) 0xC4, (byte) 0x9A, (byte) 0x65, (byte) 0x3B, (byte) 0xD9, (byte) 0x87, (byte) 0x04,
|
||||
(byte) 0x5A, (byte) 0xB8, (byte) 0xE6, (byte) 0xA7, (byte) 0xF9, (byte) 0x1B, (byte) 0x45, (byte) 0xC6,
|
||||
(byte) 0x98, (byte) 0x7A, (byte) 0x24, (byte) 0xF8, (byte) 0xA6, (byte) 0x44, (byte) 0x1A, (byte) 0x99,
|
||||
(byte) 0xC7, (byte) 0x25, (byte) 0x7B, (byte) 0x3A, (byte) 0x64, (byte) 0x86, (byte) 0xD8, (byte) 0x5B,
|
||||
(byte) 0x05, (byte) 0xE7, (byte) 0xB9, (byte) 0x8C, (byte) 0xD2, (byte) 0x30, (byte) 0x6E, (byte) 0xED,
|
||||
(byte) 0xB3, (byte) 0x51, (byte) 0x0F, (byte) 0x4E, (byte) 0x10, (byte) 0xF2, (byte) 0xAC, (byte) 0x2F,
|
||||
(byte) 0x71, (byte) 0x93, (byte) 0xCD, (byte) 0x11, (byte) 0x4F, (byte) 0xAD, (byte) 0xF3, (byte) 0x70,
|
||||
(byte) 0x2E, (byte) 0xCC, (byte) 0x92, (byte) 0xD3, (byte) 0x8D, (byte) 0x6F, (byte) 0x31, (byte) 0xB2,
|
||||
(byte) 0xEC, (byte) 0x0E, (byte) 0x50, (byte) 0xAF, (byte) 0xF1, (byte) 0x13, (byte) 0x4D, (byte) 0xCE,
|
||||
(byte) 0x90, (byte) 0x72, (byte) 0x2C, (byte) 0x6D, (byte) 0x33, (byte) 0xD1, (byte) 0x8F, (byte) 0x0C,
|
||||
(byte) 0x52, (byte) 0xB0, (byte) 0xEE, (byte) 0x32, (byte) 0x6C, (byte) 0x8E, (byte) 0xD0, (byte) 0x53,
|
||||
(byte) 0x0D, (byte) 0xEF, (byte) 0xB1, (byte) 0xF0, (byte) 0xAE, (byte) 0x4C, (byte) 0x12, (byte) 0x91,
|
||||
(byte) 0xCF, (byte) 0x2D, (byte) 0x73, (byte) 0xCA, (byte) 0x94, (byte) 0x76, (byte) 0x28, (byte) 0xAB,
|
||||
(byte) 0xF5, (byte) 0x17, (byte) 0x49, (byte) 0x08, (byte) 0x56, (byte) 0xB4, (byte) 0xEA, (byte) 0x69,
|
||||
(byte) 0x37, (byte) 0xD5, (byte) 0x8B, (byte) 0x57, (byte) 0x09, (byte) 0xEB, (byte) 0xB5, (byte) 0x36,
|
||||
(byte) 0x68, (byte) 0x8A, (byte) 0xD4, (byte) 0x95, (byte) 0xCB, (byte) 0x29, (byte) 0x77, (byte) 0xF4,
|
||||
(byte) 0xAA, (byte) 0x48, (byte) 0x16, (byte) 0xE9, (byte) 0xB7, (byte) 0x55, (byte) 0x0B, (byte) 0x88,
|
||||
(byte) 0xD6, (byte) 0x34, (byte) 0x6A, (byte) 0x2B, (byte) 0x75, (byte) 0x97, (byte) 0xC9, (byte) 0x4A,
|
||||
(byte) 0x14, (byte) 0xF6, (byte) 0xA8, (byte) 0x74, (byte) 0x2A, (byte) 0xC8, (byte) 0x96, (byte) 0x15,
|
||||
(byte) 0x4B, (byte) 0xA9, (byte) 0xF7, (byte) 0xB6, (byte) 0xE8, (byte) 0x0A, (byte) 0x54, (byte) 0xD7,
|
||||
(byte) 0x89, (byte) 0x6B, (byte) 0x35 };
|
||||
|
||||
/**
|
||||
* Calculate crc value
|
||||
*
|
||||
* @param bytes input bytes
|
||||
* @return crcValue
|
||||
*/
|
||||
public static int calculate(byte[] bytes) {
|
||||
int crcValue = 0;
|
||||
for (byte m : bytes) {
|
||||
int k = (byte) (crcValue ^ m);
|
||||
if (k > 256) {
|
||||
k -= 256;
|
||||
}
|
||||
if (k < 0) {
|
||||
k += 256;
|
||||
}
|
||||
crcValue = CRC8_854_TABLE[k];
|
||||
}
|
||||
return crcValue;
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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.mideaac.internal.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link Decryption8370Result} Protocol. V3 Only
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - JavaDoc
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Decryption8370Result {
|
||||
/**
|
||||
* Set up for decryption
|
||||
*
|
||||
* @return responses
|
||||
*/
|
||||
public ArrayList<byte[]> getResponses() {
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer
|
||||
*
|
||||
* @return buffer
|
||||
*/
|
||||
public byte[] getBuffer() {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
ArrayList<byte[]> responses;
|
||||
byte[] buffer;
|
||||
|
||||
/**
|
||||
* Decryption result
|
||||
*
|
||||
* @param responses responses
|
||||
* @param buffer buffer
|
||||
*/
|
||||
public Decryption8370Result(ArrayList<byte[]> responses, byte[] buffer) {
|
||||
super();
|
||||
this.responses = responses;
|
||||
this.buffer = buffer;
|
||||
}
|
||||
}
|
@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 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.mideaac.internal.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* The {@link Security} class provides Security coding and decoding.
|
||||
* The basic aes Protocol is used by both V2 and V3 devices.
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - JavaDoc
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Security {
|
||||
|
||||
private @Nullable SecretKeySpec encKey = null;
|
||||
private Logger logger = LoggerFactory.getLogger(Security.class);
|
||||
private IvParameterSpec iv = new IvParameterSpec(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
||||
|
||||
CloudProviderDTO cloudProvider;
|
||||
|
||||
/**
|
||||
* Set Cloud Provider
|
||||
*
|
||||
* @param cloudProvider Name of Cloud provider
|
||||
*/
|
||||
public Security(CloudProviderDTO cloudProvider) {
|
||||
this.cloudProvider = cloudProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Decryption for all devices using common signkey
|
||||
*
|
||||
* @param encryptData encrypted array
|
||||
* @return decypted array
|
||||
*/
|
||||
public byte[] aesDecrypt(byte[] encryptData) {
|
||||
byte[] plainText = {};
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
SecretKeySpec key = getEncKey();
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("AES decryption error: InvalidKeyException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
try {
|
||||
plainText = cipher.doFinal(encryptData);
|
||||
} catch (IllegalBlockSizeException e) {
|
||||
logger.warn("AES decryption error: IllegalBlockSizeException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (BadPaddingException e) {
|
||||
logger.warn("AES decryption error: BadPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("AES decryption error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (NoSuchPaddingException e) {
|
||||
logger.warn("AES decryption error: NoSuchPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
return plainText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Encryption for all devices using common signkey
|
||||
*
|
||||
* @param plainText Plain Text
|
||||
* @return encrpted byte[] array
|
||||
*/
|
||||
public byte[] aesEncrypt(byte[] plainText) {
|
||||
byte[] encryptData = {};
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
|
||||
SecretKeySpec key = getEncKey();
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("AES encryption error: InvalidKeyException: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
encryptData = cipher.doFinal(plainText);
|
||||
} catch (IllegalBlockSizeException e) {
|
||||
logger.warn("AES encryption error: IllegalBlockSizeException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (BadPaddingException e) {
|
||||
logger.warn("AES encryption error: BadPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("AES encryption error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (NoSuchPaddingException e) {
|
||||
logger.warn("AES encryption error: NoSuchPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
return encryptData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret key using MD5
|
||||
*
|
||||
* @return encKey
|
||||
* @throws NoSuchAlgorithmException missing algorithm
|
||||
*/
|
||||
public @Nullable SecretKeySpec getEncKey() throws NoSuchAlgorithmException {
|
||||
if (encKey == null) {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
md.update(cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII));
|
||||
byte[] key = md.digest();
|
||||
SecretKeySpec skeySpec = new SecretKeySpec(key, "AES");
|
||||
|
||||
encKey = skeySpec;
|
||||
}
|
||||
|
||||
return encKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode32 Data
|
||||
*
|
||||
* @param raw byte array
|
||||
* @return byte[]
|
||||
*/
|
||||
public byte[] encode32Data(byte[] raw) {
|
||||
byte[] combine = ByteBuffer
|
||||
.allocate(raw.length + cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII).length).put(raw)
|
||||
.put(cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII)).array();
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance("MD5");
|
||||
md.update(combine);
|
||||
return md.digest();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
}
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Message types
|
||||
*/
|
||||
public enum MsgType {
|
||||
MSGTYPE_HANDSHAKE_REQUEST(0x0),
|
||||
MSGTYPE_HANDSHAKE_RESPONSE(0x1),
|
||||
MSGTYPE_ENCRYPTED_RESPONSE(0x3),
|
||||
MSGTYPE_ENCRYPTED_REQUEST(0x6),
|
||||
MSGTYPE_TRANSPARENT(0xf);
|
||||
|
||||
private final int value;
|
||||
|
||||
private MsgType(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message type Id
|
||||
*
|
||||
* @return message type
|
||||
*/
|
||||
public int getId() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain language message
|
||||
*
|
||||
* @param id id
|
||||
* @return message type
|
||||
*/
|
||||
public static MsgType fromId(int id) {
|
||||
for (MsgType type : values()) {
|
||||
if (type.getId() == id) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return MSGTYPE_TRANSPARENT;
|
||||
}
|
||||
}
|
||||
|
||||
private int requestCount = 0;
|
||||
private int responseCount = 0;
|
||||
private byte[] tcpKey = new byte[0];
|
||||
|
||||
/**
|
||||
* Advanced Encryption for V3 devices
|
||||
*
|
||||
* @param data input data array
|
||||
* @param msgtype message type
|
||||
* @return encoded byte array
|
||||
*/
|
||||
public byte[] encode8370(byte[] data, MsgType msgtype) {
|
||||
ByteBuffer headerBuffer = ByteBuffer.allocate(256);
|
||||
ByteBuffer dataBuffer = ByteBuffer.allocate(256);
|
||||
|
||||
headerBuffer.put(new byte[] { (byte) 0x83, (byte) 0x70 });
|
||||
|
||||
int size = data.length;
|
||||
int padding = 0;
|
||||
|
||||
logger.trace("Size: {}", size);
|
||||
byte[] paddingData = null;
|
||||
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
|
||||
if ((size + 2) % 16 != 0) {
|
||||
padding = 16 - (size + 2 & 0xf);
|
||||
size += padding + 32;
|
||||
logger.trace("Padding size: {}, size: {}", padding, size);
|
||||
paddingData = getRandomBytes(padding);
|
||||
}
|
||||
}
|
||||
headerBuffer.put(Utils.toBytes((short) size));
|
||||
|
||||
headerBuffer.put(new byte[] { 0x20, (byte) (padding << 4 | msgtype.value) });
|
||||
|
||||
if (requestCount > 0xfff) {
|
||||
logger.trace("requestCount is too big to convert: {}, changing requestCount to 0", requestCount);
|
||||
requestCount = 0;
|
||||
}
|
||||
|
||||
dataBuffer.put(Utils.toBytes((short) requestCount));
|
||||
requestCount += 1;
|
||||
|
||||
dataBuffer.put(data);
|
||||
if (paddingData != null) {
|
||||
dataBuffer.put(paddingData);
|
||||
}
|
||||
|
||||
headerBuffer.flip();
|
||||
byte[] finalHeader = new byte[headerBuffer.remaining()];
|
||||
headerBuffer.get(finalHeader);
|
||||
|
||||
dataBuffer.flip();
|
||||
byte[] finalData = new byte[dataBuffer.remaining()];
|
||||
dataBuffer.get(finalData);
|
||||
|
||||
logger.trace("Header: {}", Utils.bytesToHex(finalHeader));
|
||||
|
||||
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
|
||||
byte[] sign = sha256(Utils.concatenateArrays(finalHeader, finalData));
|
||||
logger.trace("Sign: {}", Utils.bytesToHex(sign));
|
||||
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
|
||||
|
||||
finalData = Utils.concatenateArrays(aesCbcEncrypt(finalData, tcpKey), sign);
|
||||
}
|
||||
|
||||
byte[] result = Utils.concatenateArrays(finalHeader, finalData);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Decryption for V3 devices
|
||||
*
|
||||
* @param data input data array
|
||||
* @return decrypted byte array
|
||||
* @throws IOException IO exception
|
||||
*/
|
||||
public Decryption8370Result decode8370(byte[] data) throws IOException {
|
||||
if (data.length < 6) {
|
||||
return new Decryption8370Result(new ArrayList<byte[]>(), data);
|
||||
}
|
||||
byte[] header = Arrays.copyOfRange(data, 0, 6);
|
||||
logger.trace("Header: {}", Utils.bytesToHex(header));
|
||||
if (header[0] != (byte) 0x83 || header[1] != (byte) 0x70) {
|
||||
logger.warn("Not an 8370 message");
|
||||
return new Decryption8370Result(new ArrayList<byte[]>(), data);
|
||||
}
|
||||
ByteBuffer dataBuffer = ByteBuffer.wrap(data);
|
||||
int size = dataBuffer.getShort(2) + 8;
|
||||
logger.trace("Size: {}", size);
|
||||
byte[] leftover = null;
|
||||
if (data.length < size) {
|
||||
return new Decryption8370Result(new ArrayList<byte[]>(), data);
|
||||
} else if (data.length > size) {
|
||||
leftover = Arrays.copyOfRange(data, size, data.length);
|
||||
data = Arrays.copyOfRange(data, 0, size);
|
||||
}
|
||||
int padding = header[5] >> 4;
|
||||
logger.trace("Padding: {}", padding);
|
||||
MsgType msgtype = MsgType.fromId(header[5] & 0xf);
|
||||
logger.trace("MsgType: {}", msgtype.toString());
|
||||
data = Arrays.copyOfRange(data, 6, data.length);
|
||||
|
||||
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
|
||||
byte[] sign = Arrays.copyOfRange(data, data.length - 32, data.length);
|
||||
data = Arrays.copyOfRange(data, 0, data.length - 32);
|
||||
data = aesCbcDecrypt(data, tcpKey);
|
||||
byte[] signLocal = sha256(Utils.concatenateArrays(header, data));
|
||||
|
||||
logger.trace("Sign: {}", Utils.bytesToHex(sign));
|
||||
logger.trace("SignLocal: {}", Utils.bytesToHex(signLocal));
|
||||
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
|
||||
logger.trace("Data: {}", Utils.bytesToHex(data));
|
||||
|
||||
if (!Arrays.equals(sign, signLocal)) {
|
||||
logger.warn("Sign does not match");
|
||||
return new Decryption8370Result(new ArrayList<byte[]>(), data);
|
||||
}
|
||||
|
||||
if (padding > 0) {
|
||||
data = Arrays.copyOfRange(data, 0, data.length - padding);
|
||||
}
|
||||
} else {
|
||||
logger.warn("MsgType: {}", msgtype.toString());
|
||||
throw new IOException(msgtype.toString() + " response was received");
|
||||
}
|
||||
|
||||
dataBuffer = ByteBuffer.wrap(data);
|
||||
responseCount = dataBuffer.getShort(0);
|
||||
logger.trace("responseCount: {}", responseCount);
|
||||
logger.trace("requestCount: {}", requestCount);
|
||||
data = Arrays.copyOfRange(data, 2, data.length);
|
||||
if (leftover != null) {
|
||||
Decryption8370Result r = decode8370(leftover);
|
||||
ArrayList<byte[]> responses = r.getResponses();
|
||||
responses.add(0, data);
|
||||
return new Decryption8370Result(responses, r.buffer);
|
||||
}
|
||||
|
||||
ArrayList<byte[]> responses = new ArrayList<byte[]>();
|
||||
responses.add(data);
|
||||
return new Decryption8370Result(responses, new byte[] {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve TCP key
|
||||
*
|
||||
* @param response message
|
||||
* @param key key
|
||||
* @return tcp key
|
||||
*/
|
||||
public boolean tcpKey(byte[] response, byte key[]) {
|
||||
byte[] payload = Arrays.copyOfRange(response, 0, 32);
|
||||
byte[] sign = Arrays.copyOfRange(response, 32, 64);
|
||||
byte[] plain = aesCbcDecrypt(payload, key);
|
||||
byte[] signLocal = sha256(plain);
|
||||
|
||||
logger.trace("Payload: {}", Utils.bytesToHex(payload));
|
||||
logger.trace("Sign: {}", Utils.bytesToHex(sign));
|
||||
logger.trace("SignLocal: {}", Utils.bytesToHex(signLocal));
|
||||
logger.trace("Plain: {}", Utils.bytesToHex(plain));
|
||||
|
||||
if (!Arrays.equals(sign, signLocal)) {
|
||||
logger.warn("Sign does not match");
|
||||
return false;
|
||||
}
|
||||
tcpKey = Utils.strxor(plain, key);
|
||||
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
|
||||
return true;
|
||||
}
|
||||
|
||||
private byte[] aesCbcDecrypt(byte[] encryptData, byte[] decrypt_key) {
|
||||
byte[] plainText = {};
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
|
||||
SecretKeySpec key = new SecretKeySpec(decrypt_key, "AES");
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, iv);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("AES decryption error: InvalidKeyException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (InvalidAlgorithmParameterException e) {
|
||||
logger.warn("AES decryption error: InvalidAlgorithmParameterException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
try {
|
||||
plainText = cipher.doFinal(encryptData);
|
||||
} catch (IllegalBlockSizeException e) {
|
||||
logger.warn("AES decryption error: IllegalBlockSizeException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (BadPaddingException e) {
|
||||
logger.warn("AES decryption error: BadPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("AES decryption error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (NoSuchPaddingException e) {
|
||||
logger.warn("AES decryption error: NoSuchPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
return plainText;
|
||||
}
|
||||
|
||||
private byte[] aesCbcEncrypt(byte[] plainText, byte[] encrypt_key) {
|
||||
byte[] encryptData = {};
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
|
||||
|
||||
SecretKeySpec key = new SecretKeySpec(encrypt_key, "AES");
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("AES encryption error: InvalidKeyException: {}", e.getMessage());
|
||||
} catch (InvalidAlgorithmParameterException e) {
|
||||
logger.warn("AES encryption error: InvalidAlgorithmParameterException: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
encryptData = cipher.doFinal(plainText);
|
||||
} catch (IllegalBlockSizeException e) {
|
||||
logger.warn("AES encryption error: IllegalBlockSizeException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (BadPaddingException e) {
|
||||
logger.warn("AES encryption error: BadPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("AES encryption error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
} catch (NoSuchPaddingException e) {
|
||||
logger.warn("AES encryption error: NoSuchPaddingException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
return encryptData;
|
||||
}
|
||||
|
||||
private byte[] sha256(byte[] bytes) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("SHA256 digest error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getRandomBytes(int size) {
|
||||
byte[] random = new byte[size];
|
||||
new Random().nextBytes(random);
|
||||
return random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to cloud provider
|
||||
*
|
||||
* @param url url of cloud provider
|
||||
* @param payload message
|
||||
* @return lower case hex string
|
||||
*/
|
||||
public @Nullable String sign(String url, JsonObject payload) {
|
||||
logger.trace("url: {}", url);
|
||||
String path;
|
||||
try {
|
||||
path = new URI(url).getPath();
|
||||
|
||||
String query = Utils.getQueryString(payload);
|
||||
|
||||
String sign = path + query + cloudProvider.appkey();
|
||||
logger.trace("sign: {}", sign);
|
||||
return Utils.bytesToHexLowercase(sha256((sign).getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (URISyntaxException e) {
|
||||
logger.warn("Syntax error{}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a randown iotKey for Cloud Providers that do not have one
|
||||
*
|
||||
* @param data input data array
|
||||
* @param random random values
|
||||
* @return sign
|
||||
*/
|
||||
public @Nullable String newSign(String data, String random) {
|
||||
String msg = cloudProvider.iotkey();
|
||||
if (!data.isEmpty()) {
|
||||
msg += data;
|
||||
}
|
||||
msg += random;
|
||||
String sign;
|
||||
|
||||
try {
|
||||
sign = hmac(msg, cloudProvider.hmackey(), "HmacSHA256");
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("HMAC digest error: InvalidKeyException: {}", e.getMessage());
|
||||
return null;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("HMAC digest error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
return sign; // .hexdigest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parameters to lower case string for communication with cloud
|
||||
*
|
||||
* @param data data array
|
||||
* @param key key
|
||||
* @param algorithm method
|
||||
* @throws NoSuchAlgorithmException no Algorithm
|
||||
* @throws InvalidKeyException bad key
|
||||
* @return lower case string
|
||||
*/
|
||||
public String hmac(String data, String key, String algorithm) throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), algorithm);
|
||||
Mac mac = Mac.getInstance(algorithm);
|
||||
mac.init(secretKeySpec);
|
||||
return Utils.bytesToHexLowercase(mac.doFinal(data.getBytes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts password for cloud API using SHA-256
|
||||
*
|
||||
* @param loginId Login ID
|
||||
* @param password Login password
|
||||
* @return string
|
||||
*/
|
||||
public @Nullable String encryptPassword(@Nullable String loginId, String password) {
|
||||
try {
|
||||
// Hash the password
|
||||
MessageDigest m = MessageDigest.getInstance("SHA-256");
|
||||
m.update(password.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
// Create the login hash with the loginID + password hash + appKey, then hash it all AGAIN
|
||||
String loginHash = loginId + Utils.bytesToHexLowercase(m.digest()) + cloudProvider.appkey();
|
||||
m = MessageDigest.getInstance("SHA-256");
|
||||
m.update(loginHash.getBytes(StandardCharsets.US_ASCII));
|
||||
return Utils.bytesToHexLowercase(m.digest());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("encryptPassword error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts password for cloud API using MD5
|
||||
*
|
||||
* @param loginId Login ID
|
||||
* @param password Login password
|
||||
* @return string
|
||||
*/
|
||||
public @Nullable String encryptIamPassword(@Nullable String loginId, String password) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
md.update(password.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
MessageDigest mdSecond = MessageDigest.getInstance("MD5");
|
||||
mdSecond.update(Utils.bytesToHexLowercase(md.digest()).getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
// if self._use_china_server:
|
||||
// return mdSecond.hexdigest()
|
||||
|
||||
String loginHash = loginId + Utils.bytesToHexLowercase(mdSecond.digest()) + cloudProvider.appkey();
|
||||
return Utils.bytesToHexLowercase(sha256(loginHash.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
logger.warn("encryptIamPasswordt error: NoSuchAlgorithmException: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets UDPID from byte data
|
||||
*
|
||||
* @param data data array
|
||||
* @return string of lower case bytes
|
||||
*/
|
||||
public String getUdpId(byte[] data) {
|
||||
byte[] b = sha256(data);
|
||||
byte[] b1 = Arrays.copyOfRange(b, 0, 16);
|
||||
byte[] b2 = Arrays.copyOfRange(b, 16, b.length);
|
||||
byte[] b3 = new byte[16];
|
||||
int i = 0;
|
||||
while (i < b1.length) {
|
||||
b3[i] = (byte) (b1[i] ^ b2[i]);
|
||||
i++;
|
||||
}
|
||||
return Utils.bytesToHexLowercase(b3);
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.mideaac.internal.security;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link TokenKey} returns the active Token and Key.
|
||||
*
|
||||
* @param token For coding/decoding messages
|
||||
* @param key For coding/decoding messages
|
||||
*
|
||||
* @author Jacek Dobrowolski - Initial Contribution
|
||||
* @author Bob Eckhoff - JavaDoc and OH addons review
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public record TokenKey(String token, String key) {
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="mideaac" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>binding</type>
|
||||
<name>MideaAC Binding</name>
|
||||
<description>This is the binding for MideaAC.</description>
|
||||
<connection>local</connection>
|
||||
<discovery-methods>
|
||||
<discovery-method>
|
||||
<service-type>mdns</service-type>
|
||||
<discovery-parameters>
|
||||
<discovery-parameter>
|
||||
<name>mdnsServiceType</name>
|
||||
<value>_mideaair._tcp.local.</value>
|
||||
</discovery-parameter>
|
||||
</discovery-parameters>
|
||||
</discovery-method>
|
||||
</discovery-methods>
|
||||
</addon:addon>
|
@ -0,0 +1,95 @@
|
||||
# add-on
|
||||
|
||||
addon.mideaac.name = MideaAC Binding
|
||||
addon.mideaac.description = This is the binding for MideaAC.
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.mideaac.ac.label = Midea Air Conditioner
|
||||
thing-type.mideaac.ac.description = Midea Air Conditioner with USB WIFI stick. There are 2 versions: v2 - without encryption, v3 - with encryption - Token and Key must be provided, it can be automatically obtained from Cloud.
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.mideaac.ac.cloud.label = Cloud Provider
|
||||
thing-type.config.mideaac.ac.cloud.description = Cloud Provider name for email and password.
|
||||
thing-type.config.mideaac.ac.cloud.option.MSmartHome = MSmartHome
|
||||
thing-type.config.mideaac.ac.cloud.option.Midea\ Air = Midea Air
|
||||
thing-type.config.mideaac.ac.cloud.option.NetHome\ Plus = NetHome Plus
|
||||
thing-type.config.mideaac.ac.deviceId.label = Device ID
|
||||
thing-type.config.mideaac.ac.deviceId.description = ID of the device. Leave 0 to do ID discovery.
|
||||
thing-type.config.mideaac.ac.email.label = Email
|
||||
thing-type.config.mideaac.ac.email.description = Email for cloud account chosen in Cloud Provider.
|
||||
thing-type.config.mideaac.ac.ipAddress.label = IP Address
|
||||
thing-type.config.mideaac.ac.ipAddress.description = IP Address of the device.
|
||||
thing-type.config.mideaac.ac.ipPort.label = IP Port
|
||||
thing-type.config.mideaac.ac.ipPort.description = IP port of the device.
|
||||
thing-type.config.mideaac.ac.key.label = Key
|
||||
thing-type.config.mideaac.ac.key.description = Secret Key (length 64 HEX) used for secure connection authentication used with devices v3 (if not known, enter email and password for Cloud to retrieve it).
|
||||
thing-type.config.mideaac.ac.password.label = Password
|
||||
thing-type.config.mideaac.ac.password.description = Password for cloud account chosen in Cloud Provider.
|
||||
thing-type.config.mideaac.ac.pollingTime.label = Polling time
|
||||
thing-type.config.mideaac.ac.pollingTime.description = Polling time in seconds. Minimum time is 30 seconds, default 60 seconds.
|
||||
thing-type.config.mideaac.ac.promptTone.label = Prompt tone
|
||||
thing-type.config.mideaac.ac.promptTone.description = After sending a command device will play "ding" tone when command is received and executed.
|
||||
thing-type.config.mideaac.ac.timeout.label = Timeout
|
||||
thing-type.config.mideaac.ac.timeout.description = Connecting timeout. Minimum time is 2 second, maximum 10 seconds (4 seconds default).
|
||||
thing-type.config.mideaac.ac.token.label = Token
|
||||
thing-type.config.mideaac.ac.token.description = Secret Token (length 128 HEX) used for secure connection authentication used with devices v3 (if not known, enter email and password for Cloud to retrieve it).
|
||||
thing-type.config.mideaac.ac.version.label = AC Version
|
||||
thing-type.config.mideaac.ac.version.description = Version 3 requires Token, Key and Cloud provider. Version 2 doesn't.
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.mideaac.alternate-target-temperature.label = Alternate Target Temperature
|
||||
channel-type.mideaac.alternate-target-temperature.description = Alternate Target Temperature (Read Only).
|
||||
channel-type.mideaac.appliance-error.label = Appliance error
|
||||
channel-type.mideaac.appliance-error.description = Appliance error (Read Only).
|
||||
channel-type.mideaac.auxiliary-heat.label = Auxiliary heat
|
||||
channel-type.mideaac.auxiliary-heat.description = Auxiliary heat (Read Only).
|
||||
channel-type.mideaac.dropped-commands.label = Dropped Command Monitor
|
||||
channel-type.mideaac.dropped-commands.description = Commands dropped due to TCP read() issues.
|
||||
channel-type.mideaac.eco-mode.label = Eco mode
|
||||
channel-type.mideaac.eco-mode.description = Eco mode, Cool only, Temp: min. 24C, Fan: AUTO.
|
||||
channel-type.mideaac.fan-speed.label = Fan speed
|
||||
channel-type.mideaac.fan-speed.description = Fan speed: SILENT, LOW, MEDIUM, HIGH, FULL, AUTO.
|
||||
channel-type.mideaac.fan-speed.state.option.SILENT = SILENT
|
||||
channel-type.mideaac.fan-speed.state.option.LOW = LOW
|
||||
channel-type.mideaac.fan-speed.state.option.MEDIUM = MEDIUM
|
||||
channel-type.mideaac.fan-speed.state.option.HIGH = HIGH
|
||||
channel-type.mideaac.fan-speed.state.option.FULL = FULL
|
||||
channel-type.mideaac.fan-speed.state.option.AUTO = AUTO
|
||||
channel-type.mideaac.humidity.label = Humidity
|
||||
channel-type.mideaac.humidity.description = Humidity measured in the room by the indoor unit.
|
||||
channel-type.mideaac.indoor-temperature.label = Indoor temperature
|
||||
channel-type.mideaac.indoor-temperature.description = Indoor temperature measured by the internal unit. Not frequent when unit is off
|
||||
channel-type.mideaac.off-timer.label = OFF Timer
|
||||
channel-type.mideaac.off-timer.description = OFF Timer (HH:MM) to set.
|
||||
channel-type.mideaac.on-timer.label = ON Timer
|
||||
channel-type.mideaac.on-timer.description = ON Timer (HH:MM) to set.
|
||||
channel-type.mideaac.operational-mode.label = Operational mode
|
||||
channel-type.mideaac.operational-mode.description = Operational mode: AUTO, COOL, DRY, HEAT.
|
||||
channel-type.mideaac.operational-mode.state.option.AUTO = AUTO
|
||||
channel-type.mideaac.operational-mode.state.option.COOL = COOL
|
||||
channel-type.mideaac.operational-mode.state.option.DRY = DRY
|
||||
channel-type.mideaac.operational-mode.state.option.HEAT = HEAT
|
||||
channel-type.mideaac.operational-mode.state.option.FAN_ONLY = FAN ONLY
|
||||
channel-type.mideaac.outdoor-temperature.label = Outdoor temperature
|
||||
channel-type.mideaac.outdoor-temperature.description = Outdoor temperature from the external unit. Not frequent when unit is off
|
||||
channel-type.mideaac.power.label = Power
|
||||
channel-type.mideaac.power.description = Turn the AC on and off.
|
||||
channel-type.mideaac.screen-display.label = Screen display
|
||||
channel-type.mideaac.screen-display.description = Status of LEDs on the device. Not all models work on LAN (only IR). No confirmation possible either.
|
||||
channel-type.mideaac.sleep-function.label = Sleep function
|
||||
channel-type.mideaac.sleep-function.description = Sleep function ("Moon with a star" icon on IR Remote Controller).
|
||||
channel-type.mideaac.swing-mode.label = Swing mode
|
||||
channel-type.mideaac.swing-mode.description = Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Some V3 versions do not support
|
||||
channel-type.mideaac.swing-mode.state.option.OFF = OFF
|
||||
channel-type.mideaac.swing-mode.state.option.VERTICAL = VERTICAL
|
||||
channel-type.mideaac.swing-mode.state.option.HORIZONTAL = HORIZONTAL
|
||||
channel-type.mideaac.swing-mode.state.option.BOTH = BOTH
|
||||
channel-type.mideaac.target-temperature.label = Target temperature
|
||||
channel-type.mideaac.target-temperature.description = Target temperature.
|
||||
channel-type.mideaac.temperature-unit.label = Temperature unit on LED Display
|
||||
channel-type.mideaac.temperature-unit.description = On = Farenheit on Indoor AC unit LED display, Off = Celsius.
|
||||
channel-type.mideaac.turbo-mode.label = Turbo mode
|
||||
channel-type.mideaac.turbo-mode.description = Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. Only works in COOL and HEAT mode.
|
@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="mideaac"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<!-- Thing Type -->
|
||||
<thing-type id="ac">
|
||||
<label>Midea Air Conditioner</label>
|
||||
<description>Midea Air Conditioner with USB WIFI stick. There are 2 versions: v2 - without encryption, v3 - with
|
||||
encryption - Token and Key must be provided, it can be automatically obtained from Cloud.
|
||||
</description>
|
||||
|
||||
<channels>
|
||||
<channel id="power" typeId="power"/>
|
||||
<channel id="target-temperature" typeId="target-temperature"/>
|
||||
<channel id="operational-mode" typeId="operational-mode"/>
|
||||
<channel id="fan-speed" typeId="fan-speed"/>
|
||||
<channel id="swing-mode" typeId="swing-mode"/>
|
||||
<channel id="eco-mode" typeId="eco-mode"/>
|
||||
<channel id="turbo-mode" typeId="turbo-mode"/>
|
||||
<channel id="indoor-temperature" typeId="indoor-temperature"/>
|
||||
<channel id="outdoor-temperature" typeId="outdoor-temperature"/>
|
||||
<channel id="sleep-function" typeId="sleep-function"/>
|
||||
<channel id="temperature-unit" typeId="temperature-unit"/>
|
||||
|
||||
<channel id="on-timer" typeId="on-timer"/>
|
||||
<channel id="off-timer" typeId="off-timer"/>
|
||||
<channel id="appliance-error" typeId="appliance-error"/>
|
||||
<channel id="auxiliary-heat" typeId="auxiliary-heat"/>
|
||||
<channel id="humidity" typeId="humidity"/>
|
||||
<channel id="screen-display" typeId="screen-display"/>
|
||||
<channel id="alternate-target-temperature" typeId="alternate-target-temperature"/>
|
||||
</channels>
|
||||
|
||||
<representation-property>ipAddress</representation-property>
|
||||
|
||||
<config-description>
|
||||
<parameter name="ipAddress" type="text" required="true">
|
||||
<context>ipAddress</context>
|
||||
<label>IP Address</label>
|
||||
<description>IP Address of the device.</description>
|
||||
</parameter>
|
||||
<parameter name="ipPort" type="decimal" required="true">
|
||||
<context>ipPort</context>
|
||||
<label>IP Port</label>
|
||||
<description>IP port of the device.</description>
|
||||
<default>6444</default>
|
||||
</parameter>
|
||||
<parameter name="deviceId" type="text" required="true">
|
||||
<context>deviceId</context>
|
||||
<label>Device ID</label>
|
||||
<description>ID of the device. Leave 0 to do ID discovery.</description>
|
||||
<default>0</default>
|
||||
</parameter>
|
||||
<parameter name="cloud" type="text" required="false">
|
||||
<context>cloud</context>
|
||||
<label>Cloud Provider</label>
|
||||
<description>Cloud Provider name for email and password.</description>
|
||||
<options>
|
||||
<option value=""></option>
|
||||
<option value="MSmartHome">MSmartHome</option>
|
||||
<option value="Midea Air">Midea Air</option>
|
||||
<option value="NetHome Plus">NetHome Plus</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
<default></default>
|
||||
</parameter>
|
||||
<parameter name="email" type="text" required="false">
|
||||
<context>email</context>
|
||||
<label>Email</label>
|
||||
<description>Email for cloud account chosen in Cloud Provider.</description>
|
||||
</parameter>
|
||||
<parameter name="password" type="text" required="false">
|
||||
<context>password</context>
|
||||
<label>Password</label>
|
||||
<description>Password for cloud account chosen in Cloud Provider.</description>
|
||||
</parameter>
|
||||
<parameter name="token" type="text" required="false">
|
||||
<context>token</context>
|
||||
<label>Token</label>
|
||||
<description>Secret Token (length 128 HEX) used for secure connection authentication used with devices v3 (if not
|
||||
known, enter email and password for Cloud to retrieve it).</description>
|
||||
</parameter>
|
||||
<parameter name="key" type="text" required="false">
|
||||
<context>key</context>
|
||||
<label>Key</label>
|
||||
<description>Secret Key (length 64 HEX) used for secure connection authentication used with devices v3 (if not
|
||||
known, enter email and password for Cloud to retrieve it).</description>
|
||||
</parameter>
|
||||
<parameter name="pollingTime" type="decimal" required="true" min="30" unit="s">
|
||||
<context>pollingTime</context>
|
||||
<label>Polling time</label>
|
||||
<description>Polling time in seconds. Minimum time is 30 seconds, default 60 seconds.</description>
|
||||
<default>60</default>
|
||||
</parameter>
|
||||
<parameter name="timeout" type="decimal" required="true" min="2" max="10" unit="s">
|
||||
<context>timeout</context>
|
||||
<label>Timeout</label>
|
||||
<description>Connecting timeout. Minimum time is 2 second, maximum 10 seconds (4 seconds default).</description>
|
||||
<default>4</default>
|
||||
</parameter>
|
||||
<parameter name="promptTone" type="boolean" required="true">
|
||||
<context>promptTone</context>
|
||||
<label>Prompt tone</label>
|
||||
<description>After sending a command device will play "ding" tone when command is received and executed.</description>
|
||||
<default>false</default>
|
||||
</parameter>
|
||||
<parameter name="version" type="decimal" required="true">
|
||||
<context>version</context>
|
||||
<label>AC Version</label>
|
||||
<description>Version 3 requires Token, Key and Cloud provider. Version 2 doesn't.</description>
|
||||
<default>0</default>
|
||||
</parameter>
|
||||
</config-description>
|
||||
|
||||
</thing-type>
|
||||
|
||||
<channel-type id="power">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Power</label>
|
||||
<description>Turn the AC on and off.</description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="target-temperature">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Target temperature</label>
|
||||
<description>Target temperature.</description>
|
||||
<category>Temperature</category>
|
||||
<state readOnly="false" pattern="%d %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="operational-mode">
|
||||
<item-type>String</item-type>
|
||||
<label>Operational mode</label>
|
||||
<description>Operational mode: AUTO, COOL, DRY, HEAT.</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="AUTO">AUTO</option>
|
||||
<option value="COOL">COOL</option>
|
||||
<option value="DRY">DRY</option>
|
||||
<option value="HEAT">HEAT</option>
|
||||
<option value="FAN_ONLY">FAN ONLY</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="fan-speed">
|
||||
<item-type>String</item-type>
|
||||
<label>Fan speed</label>
|
||||
<description>Fan speed: SILENT, LOW, MEDIUM, HIGH, FULL, AUTO.</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="SILENT">SILENT</option>
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="FULL">FULL</option>
|
||||
<option value="AUTO">AUTO</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="swing-mode">
|
||||
<item-type>String</item-type>
|
||||
<label>Swing mode</label>
|
||||
<description>Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Some V3 versions do not support</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="OFF">OFF</option>
|
||||
<option value="VERTICAL">VERTICAL</option>
|
||||
<option value="HORIZONTAL">HORIZONTAL</option>
|
||||
<option value="BOTH">BOTH</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="eco-mode">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Eco mode</label>
|
||||
<description>Eco mode, Cool only, Temp: min. 24C, Fan: AUTO. </description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="turbo-mode">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Turbo mode</label>
|
||||
<description>Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. Only works in COOL and HEAT
|
||||
mode.</description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="indoor-temperature">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Indoor temperature</label>
|
||||
<description>Indoor temperature measured by the internal unit. Not frequent when unit is off</description>
|
||||
<category>Temperature</category>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="outdoor-temperature">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Outdoor temperature</label>
|
||||
<description>Outdoor temperature from the external unit. Not frequent when unit is off</description>
|
||||
<category>Temperature</category>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="sleep-function">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Sleep function</label>
|
||||
<description>Sleep function ("Moon with a star" icon on IR Remote Controller).</description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="temperature-unit" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Temperature unit on LED Display</label>
|
||||
<description>On = Farenheit on Indoor AC unit LED display, Off = Celsius.</description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="screen-display" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Screen display</label>
|
||||
<description>Status of LEDs on the device. Not all models work on LAN (only IR). No confirmation
|
||||
possible either.</description>
|
||||
<category>Switch</category>
|
||||
</channel-type>
|
||||
<channel-type id="appliance-error" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Appliance error</label>
|
||||
<description>Appliance error (Read Only).</description>
|
||||
<category>Switch</category>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="on-timer" advanced="true">
|
||||
<item-type>String</item-type>
|
||||
<label>ON Timer</label>
|
||||
<description>ON Timer (HH:MM) to set.</description>
|
||||
</channel-type>
|
||||
<channel-type id="off-timer" advanced="true">
|
||||
<item-type>String</item-type>
|
||||
<label>OFF Timer</label>
|
||||
<description>OFF Timer (HH:MM) to set.</description>
|
||||
</channel-type>
|
||||
<channel-type id="auxiliary-heat" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Auxiliary heat</label>
|
||||
<description>Auxiliary heat (Read Only).</description>
|
||||
<category>Switch</category>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="humidity" advanced="true">
|
||||
<item-type>Number</item-type>
|
||||
<label>Humidity</label>
|
||||
<description>Humidity measured in the room by the indoor unit.</description>
|
||||
<category>Humidity</category>
|
||||
<state readOnly="true" pattern="%d%%"/>
|
||||
</channel-type>
|
||||
<channel-type id="alternate-target-temperature" advanced="true">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Alternate Target Temperature</label>
|
||||
<description>Alternate Target Temperature (Read Only).</description>
|
||||
<category>Temperature</category>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
</thing:thing-descriptions>
|
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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.mideaac.internal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mideaac.internal.security.TokenKey;
|
||||
|
||||
/**
|
||||
* Testing of the {@link MideaACConfigurationTest} Configuration
|
||||
*
|
||||
* @author Robert Eckhoff - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MideaACConfigurationTest {
|
||||
|
||||
MideaACConfiguration config = new MideaACConfiguration();
|
||||
|
||||
/**
|
||||
* Test for valid step 1 Configs
|
||||
*/
|
||||
@Test
|
||||
public void testValidConfigs() {
|
||||
config.ipAddress = "192.168.0.1";
|
||||
config.ipPort = 6444;
|
||||
config.deviceId = "1234567890";
|
||||
config.version = 3;
|
||||
assertTrue(config.isValid());
|
||||
assertFalse(config.isDiscoveryNeeded());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for non-valid step 1 configs
|
||||
*/
|
||||
@Test
|
||||
public void testnonValidConfigs() {
|
||||
config.ipAddress = "192.168.0.1";
|
||||
config.ipPort = 0;
|
||||
config.deviceId = "1234567890";
|
||||
config.version = 3;
|
||||
assertFalse(config.isValid());
|
||||
assertTrue(config.isDiscoveryNeeded());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for valid Security Configs
|
||||
*/
|
||||
@Test
|
||||
public void testValidSecurityConfigs() {
|
||||
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
|
||||
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
|
||||
config.cloud = "NetHome Plus";
|
||||
assertTrue(config.isV3ConfigValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for Invalid Security Configs
|
||||
*/
|
||||
@Test
|
||||
public void testInvalidSecurityConfigs() {
|
||||
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
|
||||
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
|
||||
config.cloud = "";
|
||||
assertFalse(config.isV3ConfigValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for if key and token are obtainable from cloud
|
||||
*/
|
||||
@Test
|
||||
public void testIfTokenAndKeyCanBeObtainedFromCloud() {
|
||||
config.email = "someemail.com";
|
||||
config.password = "somestrongpassword";
|
||||
config.cloud = "NetHome Plus";
|
||||
assertTrue(config.isTokenKeyObtainable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for if key and token cannot be obtaines from cloud
|
||||
*/
|
||||
@Test
|
||||
public void testIfTokenAndKeyCanNotBeObtainedFromCloud() {
|
||||
config.email = "";
|
||||
config.password = "somestrongpassword";
|
||||
config.cloud = "NetHome Plus";
|
||||
assertFalse(config.isTokenKeyObtainable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for bad IP v.4 address
|
||||
*/
|
||||
@Test
|
||||
public void testBadIpConfigs() {
|
||||
config.ipAddress = "192.1680.1";
|
||||
config.ipPort = 6444;
|
||||
config.deviceId = "1234567890";
|
||||
config.version = 3;
|
||||
assertTrue(config.isValid());
|
||||
assertTrue(config.isDiscoveryNeeded());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to return cloud provider
|
||||
*/
|
||||
@Test
|
||||
public void testCloudProvider() {
|
||||
config.cloud = "NetHome Plus";
|
||||
assertEquals(config.cloud, "NetHome Plus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to return token and key pair
|
||||
*/
|
||||
@Test
|
||||
public void testTokenKey() {
|
||||
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
|
||||
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
|
||||
TokenKey tokenKey = new TokenKey(config.token, config.key);
|
||||
String tokenTest = tokenKey.token();
|
||||
String keyTest = tokenKey.key();
|
||||
assertEquals(config.token, tokenTest);
|
||||
assertEquals(config.key, keyTest);
|
||||
}
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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.mideaac.internal.discovery;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mideaac.internal.Utils;
|
||||
|
||||
/**
|
||||
* The {@link MideaACDiscoveryServiceTest} tests the discovery byte arrays
|
||||
* (reply string already decrypted - See SecurityTest)
|
||||
* to extract the correct device information
|
||||
*
|
||||
* @author Robert Eckhoff - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MideaACDiscoveryServiceTest {
|
||||
|
||||
byte[] data = HexFormat.of().parseHex(
|
||||
"837000C8200F00005A5A0111B8007A80000000006B0925121D071814C0110800008A0000000000000000018000000000AF55C8897BEA338348DA7FC0B3EF1F1C889CD57C06462D83069558B66AF14A2D66353F52BAECA68AEB4C3948517F276F72D8A3AD4652EFA55466D58975AEB8D948842E20FBDCA6339558C848ECE09211F62B1D8BB9E5C25DBA7BF8E0CC4C77944BDFB3E16E33D88768CC4C3D0658937D0BB19369BF0317B24D3A4DE9E6A13106AFFBBE80328AEA7426CD6BA2AD8439F72B4EE2436CC634040CB976A92A53BCD5");
|
||||
byte[] reply = HexFormat.of().parseHex(
|
||||
"F600A8C02C19000030303030303050303030303030305131423838433239353634334243303030300B6E65745F61635F343342430000870002000000000000000000AC00ACAC00000000B88C295643BC150023082122000300000000000000000000000000000000000000000000000000000000000000000000");
|
||||
String mSmartId = "", mSmartVersion = "", mSmartip = "", mSmartPort = "", mSmartSN = "", mSmartSSID = "",
|
||||
mSmartType = "";
|
||||
|
||||
/**
|
||||
* Test Version
|
||||
*/
|
||||
@Test
|
||||
public void testVersion() {
|
||||
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
|
||||
mSmartVersion = "3";
|
||||
} else {
|
||||
mSmartVersion = "2";
|
||||
}
|
||||
assertEquals("3", mSmartVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Id
|
||||
*/
|
||||
@Test
|
||||
public void testId() {
|
||||
if (Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A")) {
|
||||
data = Arrays.copyOfRange(data, 8, data.length - 16);
|
||||
}
|
||||
byte[] id = Utils.reverse(Arrays.copyOfRange(data, 20, 26));
|
||||
BigInteger bigId = new BigInteger(1, id);
|
||||
mSmartId = bigId.toString(10);
|
||||
assertEquals("151732605161920", mSmartId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test IP address of device
|
||||
*/
|
||||
@Test
|
||||
public void testIPAddress() {
|
||||
mSmartip = Byte.toUnsignedInt(reply[3]) + "." + Byte.toUnsignedInt(reply[2]) + "."
|
||||
+ Byte.toUnsignedInt(reply[1]) + "." + Byte.toUnsignedInt(reply[0]);
|
||||
assertEquals("192.168.0.246", mSmartip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Device Port
|
||||
*/
|
||||
@Test
|
||||
public void testPort() {
|
||||
BigInteger portId = new BigInteger(Utils.reverse(Arrays.copyOfRange(reply, 4, 8)));
|
||||
mSmartPort = portId.toString();
|
||||
assertEquals("6444", mSmartPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test serial Number
|
||||
*/
|
||||
@Test
|
||||
public void testSN() {
|
||||
mSmartSN = new String(reply, 8, 40 - 8, StandardCharsets.UTF_8);
|
||||
assertEquals("000000P0000000Q1B88C295643BC0000", mSmartSN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SSID - SN converted
|
||||
*/
|
||||
@Test
|
||||
public void testSSID() {
|
||||
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
|
||||
assertEquals("net_ac_43BC", mSmartSSID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Type
|
||||
*/
|
||||
@Test
|
||||
public void testType() {
|
||||
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
|
||||
mSmartType = mSmartSSID.split("_")[1];
|
||||
assertEquals("ac", mSmartType);
|
||||
}
|
||||
}
|
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
|
||||
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
|
||||
|
||||
/**
|
||||
* The {@link CommandSetTest} compares example SET commands with the
|
||||
* expected results.
|
||||
*
|
||||
* @author Bob Eckhoff - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CommandSetTest {
|
||||
|
||||
/**
|
||||
* Power State Test
|
||||
*/
|
||||
@Test
|
||||
public void setPowerStateTest() {
|
||||
boolean status = true;
|
||||
boolean status1 = true;
|
||||
CommandSet commandSet = new CommandSet();
|
||||
commandSet.setPowerState(status);
|
||||
assertEquals(status1, commandSet.getPowerState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Target temperature tests
|
||||
*/
|
||||
@Test
|
||||
public void testsetTargetTemperature() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
// Device is limited to 0.5 degree C increments. Check rounding too
|
||||
|
||||
// Test case 1
|
||||
float targetTemperature1 = 25.4f;
|
||||
commandSet.setTargetTemperature(targetTemperature1);
|
||||
assertEquals(25.5f, commandSet.getTargetTemperature());
|
||||
|
||||
// Test case 2
|
||||
float targetTemperature2 = 17.8f;
|
||||
commandSet.setTargetTemperature(targetTemperature2);
|
||||
assertEquals(18.0f, commandSet.getTargetTemperature());
|
||||
|
||||
// Test case 3
|
||||
float targetTemperature3 = 21.26f;
|
||||
commandSet.setTargetTemperature(targetTemperature3);
|
||||
assertEquals(21.5f, commandSet.getTargetTemperature());
|
||||
|
||||
// Test case 4
|
||||
float degreefahr = 72.0f;
|
||||
float targetTemperature4 = ((degreefahr + 40.0f) * (5.0f / 9.0f)) - 40.0f;
|
||||
commandSet.setTargetTemperature(targetTemperature4);
|
||||
assertEquals(22.0f, commandSet.getTargetTemperature());
|
||||
|
||||
// Test case 5
|
||||
float degreefahr2 = 66.0f;
|
||||
float targetTemperature5 = ((degreefahr2 + 40.0f) * (5.0f / 9.0f)) - 40.0f;
|
||||
commandSet.setTargetTemperature(targetTemperature5);
|
||||
assertEquals(19.0f, commandSet.getTargetTemperature());
|
||||
}
|
||||
|
||||
/**
|
||||
* Swing Mode test
|
||||
*/
|
||||
@Test
|
||||
public void testHandleSwingMode() {
|
||||
SwingMode mode = SwingMode.VERTICAL3;
|
||||
int mode1 = 60;
|
||||
CommandSet commandSet = new CommandSet();
|
||||
commandSet.setSwingMode(mode);
|
||||
assertEquals(mode1, commandSet.getSwingMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Speed test
|
||||
*/
|
||||
@Test
|
||||
public void testHandleFanSpeedCommand() {
|
||||
FanSpeed speed = FanSpeed.AUTO3;
|
||||
int speed1 = 102;
|
||||
CommandSet commandSet = new CommandSet();
|
||||
commandSet.setFanSpeed(speed);
|
||||
assertEquals(speed1, commandSet.getFanSpeed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Operational mode test
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOperationalMode() {
|
||||
OperationalMode mode = OperationalMode.COOL;
|
||||
int mode1 = 64;
|
||||
CommandSet commandSet = new CommandSet();
|
||||
commandSet.setOperationalMode(mode);
|
||||
assertEquals(mode1, commandSet.getOperationalMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* On timer test
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOnTimer() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = true;
|
||||
int hours = 3;
|
||||
int minutes = 59;
|
||||
int bits = (int) Math.floor(minutes / 15);
|
||||
int time = 143;
|
||||
int remainder = (15 - (int) (minutes - bits * 15));
|
||||
commandSet.setOnTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOnTimer());
|
||||
assertEquals(remainder, commandSet.getOnTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* On timer test3
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOnTimer2() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = false;
|
||||
int hours = 3;
|
||||
int minutes = 60;
|
||||
int time = 127;
|
||||
int remainder = 0;
|
||||
commandSet.setOnTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOnTimer());
|
||||
assertEquals(remainder, commandSet.getOnTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* On timer test3
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOnTimer3() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = true;
|
||||
int hours = 0;
|
||||
int minutes = 14;
|
||||
int time = 128;
|
||||
int remainder = (15 - minutes);
|
||||
commandSet.setOnTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOnTimer());
|
||||
assertEquals(remainder, commandSet.getOnTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* Off timer test
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOffTimer() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = true;
|
||||
int hours = 3;
|
||||
int minutes = 59;
|
||||
int bits = (int) Math.floor(minutes / 15);
|
||||
int time = 143;
|
||||
int remainder = (15 - (int) (minutes - bits * 15));
|
||||
commandSet.setOffTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOffTimer());
|
||||
assertEquals(remainder, commandSet.getOffTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* Off timer test2
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOffTimer2() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = false;
|
||||
int hours = 3;
|
||||
int minutes = 60;
|
||||
int time = 127;
|
||||
int remainder = 0;
|
||||
commandSet.setOffTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOffTimer());
|
||||
assertEquals(remainder, commandSet.getOffTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* Off timer test3
|
||||
*/
|
||||
@Test
|
||||
public void testHandleOffTimer3() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
boolean on = true;
|
||||
int hours = 0;
|
||||
int minutes = 14;
|
||||
int time = 128;
|
||||
int remainder = (15 - minutes);
|
||||
commandSet.setOffTimer(on, hours, minutes);
|
||||
assertEquals(time, commandSet.getOffTimer());
|
||||
assertEquals(remainder, commandSet.getOffTimer2());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test screen display change command
|
||||
*/
|
||||
@Test
|
||||
public void testSetScreenDisplayOff() {
|
||||
CommandSet commandSet = new CommandSet();
|
||||
commandSet.setScreenDisplay(true);
|
||||
|
||||
// Check the modified bytes
|
||||
assertEquals((byte) 0x20, commandSet.data[0x01]);
|
||||
assertEquals((byte) 0x03, commandSet.data[0x09]);
|
||||
assertEquals((byte) 0x41, commandSet.data[0x0a]);
|
||||
assertEquals((byte) 0x02, commandSet.data[0x0b] & 0x02); // Check if bit 1 is set
|
||||
assertEquals((byte) 0x00, commandSet.data[0x0b] & 0x80); // Check if bit 7 is cleared
|
||||
assertEquals((byte) 0x00, commandSet.data[0x0c]);
|
||||
assertEquals((byte) 0xff, commandSet.data[0x0d]);
|
||||
assertEquals((byte) 0x02, commandSet.data[0x0e]);
|
||||
assertEquals((byte) 0x00, commandSet.data[0x0f]);
|
||||
assertEquals((byte) 0x02, commandSet.data[0x10]);
|
||||
assertEquals((byte) 0x00, commandSet.data[0x11]);
|
||||
assertEquals((byte) 0x00, commandSet.data[0x12]);
|
||||
assertEquals((byte) 0x00, commandSet.data[0x13]);
|
||||
assertEquals((byte) 0x00, commandSet.data[0x14]);
|
||||
|
||||
// Check the length of the data array
|
||||
assertEquals(31, commandSet.data.length);
|
||||
}
|
||||
}
|
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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.mideaac.internal.handler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.HexFormat;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The {@link ResponseTest} extracts the AC device response and
|
||||
* compares them to the expected result.
|
||||
*
|
||||
* @author Bob Eckhoff - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ResponseTest {
|
||||
@org.jupnp.registry.event.Before
|
||||
|
||||
byte[] data = HexFormat.of().parseHex("C00042668387123C00000460FF0C7000000000320000F9ECDB");
|
||||
private int version = 3;
|
||||
String responseType = "query";
|
||||
byte bodyType = (byte) 0xC0;
|
||||
Response response = new Response(data, version, responseType, bodyType);
|
||||
|
||||
/**
|
||||
* Power State Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetPowerState() {
|
||||
boolean actualPowerState = response.getPowerState();
|
||||
assertEquals(false, actualPowerState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt Tone Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetPromptTone() {
|
||||
assertEquals(false, response.getPromptTone());
|
||||
}
|
||||
|
||||
/**
|
||||
* Appliance Error Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetApplianceError() {
|
||||
assertEquals(false, response.getApplianceError());
|
||||
}
|
||||
|
||||
/**
|
||||
* Target Temperature Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetTargetTemperature() {
|
||||
assertEquals(18, response.getTargetTemperature());
|
||||
}
|
||||
|
||||
/**
|
||||
* Operational Mode Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetOperationalMode() {
|
||||
CommandBase.OperationalMode mode = response.getOperationalMode();
|
||||
assertEquals(CommandBase.OperationalMode.COOL, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan Speed Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetFanSpeed() {
|
||||
CommandBase.FanSpeed fanSpeed = response.getFanSpeed();
|
||||
assertEquals(CommandBase.FanSpeed.AUTO3, fanSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* On timer Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetOnTimer() {
|
||||
Timer status = response.getOnTimer();
|
||||
String expectedString = "enabled: true, hours: 0, minutes: 59";
|
||||
assertEquals(expectedString, status.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Off timer Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetOffTimer() {
|
||||
Timer status = response.getOffTimer();
|
||||
String expectedString = "enabled: true, hours: 1, minutes: 58";
|
||||
assertEquals(expectedString, status.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Swing mode Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetSwingMode() {
|
||||
CommandBase.SwingMode swing = response.getSwingMode();
|
||||
assertEquals(CommandBase.SwingMode.VERTICAL3, swing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auxiliary Heat Status Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetAuxHeat() {
|
||||
assertEquals(false, response.getAuxHeat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Eco Mode Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetEcoMode() {
|
||||
assertEquals(false, response.getEcoMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep Function Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetSleepFunction() {
|
||||
assertEquals(false, response.getSleepFunction());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turbo Mode Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetTurboMode() {
|
||||
assertEquals(false, response.getTurboMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fahrenheit Display Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetFahrenheit() {
|
||||
assertEquals(true, response.getFahrenheit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Indoor Temperature Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetIndoorTemperature() {
|
||||
assertEquals(23, response.getIndoorTemperature());
|
||||
}
|
||||
|
||||
/**
|
||||
* Outdoor Temperature Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetOutdoorTemperature() {
|
||||
assertEquals(0, response.getOutdoorTemperature());
|
||||
}
|
||||
|
||||
/**
|
||||
* LED Display Test
|
||||
*/
|
||||
@Test
|
||||
public void testDisplayOn() {
|
||||
assertEquals(false, response.getDisplayOn());
|
||||
}
|
||||
|
||||
/**
|
||||
* Humidity Test
|
||||
*/
|
||||
@Test
|
||||
public void testGetHumidity() {
|
||||
assertEquals(50, response.getHumidity());
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternate Target temperature Test
|
||||
*/
|
||||
@Test
|
||||
public void testAlternateTargetTemperature() {
|
||||
assertEquals(24, response.getAlternateTargetTemperature());
|
||||
}
|
||||
}
|
@ -256,6 +256,7 @@
|
||||
<module>org.openhab.binding.meteostick</module>
|
||||
<module>org.openhab.binding.metofficedatahub</module>
|
||||
<module>org.openhab.binding.mffan</module>
|
||||
<module>org.openhab.binding.mideaac</module>
|
||||
<module>org.openhab.binding.miele</module>
|
||||
<module>org.openhab.binding.mielecloud</module>
|
||||
<module>org.openhab.binding.mihome</module>
|
||||
|
Loading…
Reference in New Issue
Block a user