Midea AC after partial PR review

Mideaac binding after partial PR review.  Main remaining issue is the connection manager which currently needs to be embedded in the MideaACHandler to leverage the OH base thing handler.
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
This commit is contained in:
Bob Eckhoff 2024-10-29 17:02:02 -04:00
parent 2f7b727d14
commit 925fc2860e
33 changed files with 6195 additions and 0 deletions

View File

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

View 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

View File

@ -0,0 +1,121 @@
# 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. | 3 |
## 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 |
| dropped-commands | Number | Quality of WiFi connections - For debugging only. | 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)

View 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>4.3.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.mideaac</artifactId>
<name>openHAB Add-ons :: Bundles :: MideaAC Binding</name>
</project>

View File

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

View File

@ -0,0 +1,93 @@
/**
* Copyright (c) 2010-2024 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 PROPERTY_VERSION = "version";
public static final String PROPERTY_SN = "sn";
public static final String PROPERTY_SSID = "ssid";
public static final String PROPERTY_TYPE = "type";
}

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) 2010-2024 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 {
public String ipAddress = "";
public String ipPort = "6444";
public String deviceId = "";
public String email = "";
public String password = "";
public String cloud = "";
public String token = "";
public String key = "";
public int pollingTime = 60;
public int timeout = 4;
public boolean promptTone;
public String version = "";
/**
* Check during initialization that the params are valid
*
* @return true(valid), false (not valid)
*/
public boolean isValid() {
return !("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort.isBlank() || ipAddress.isBlank());
}
/**
* Check during initialization if discovery is needed
*
* @return true(discovery needed), false (not needed)
*/
public boolean isDiscoveryNeeded() {
return ("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort.isBlank() || ipAddress.isBlank()
|| !Utils.validateIP(ipAddress));
}
}

View File

@ -0,0 +1,72 @@
/**
* Copyright (c) 2010-2024 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 UnitProvider unitProvider;
private final HttpClientFactory httpClientFactory;
private final CloudsDTO clouds;
@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.unitProvider = unitProvider;
this.httpClientFactory = httpClientFactory;
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;
}
}

View File

@ -0,0 +1,250 @@
/**
* Copyright (c) 2010-2024 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;
}
}

View File

@ -0,0 +1,82 @@
/**
* Copyright (c) 2010-2024 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();
}
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2024 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);
}

View File

@ -0,0 +1,353 @@
/**
* Copyright (c) 2010-2024 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
*/
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.debug("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.debug("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, mSmartVersion = "", mSmartip = "", mSmartPort = "", mSmartSN = "", mSmartSSID = "",
mSmartType = "";
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.trace("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.debug("Encrypt data: '{}'", Utils.bytesToHex(encryptData));
byte[] reply = security.aesDecrypt(encryptData);
logger.debug("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(PROPERTY_VERSION, version);
properties.put(PROPERTY_SN, sn);
properties.put(PROPERTY_SSID, ssid);
properties.put(PROPERTY_TYPE, type);
return properties;
}
}

View File

@ -0,0 +1,357 @@
/**
* Copyright (c) 2010-2024 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");
}
}

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2010-2024 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", "", "", "");
}
}

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2010-2024 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);
}
}

View File

@ -0,0 +1,314 @@
/**
* Copyright (c) 2010-2024 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;
}
}

View File

@ -0,0 +1,399 @@
/**
* Copyright (c) 2010-2024 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;
}
}

View File

@ -0,0 +1,117 @@
/**
* Copyright (c) 2010-2024 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;
/**
* 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 MideaACHandler mideaACHandler;
/**
* The Packet class parameters
*
* @param command command from Command Base
* @param deviceId the device ID
* @param mideaACHandler the MideaACHandler class
*/
public Packet(CommandBase command, String deviceId, MideaACHandler mideaACHandler) {
this.command = command;
this.mideaACHandler = mideaACHandler;
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 = mideaACHandler.getSecurity().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 = mideaACHandler.getSecurity().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;
}
}

View File

@ -0,0 +1,389 @@
/**
* Copyright (c) 2010-2024 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("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("Eco Mode: {}", getEcoMode());
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);
}
}

View File

@ -0,0 +1,121 @@
/**
* Copyright (c) 2010-2024 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;
}
}
}

View File

@ -0,0 +1,78 @@
/**
* Copyright (c) 2010-2024 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;
}
}

View File

@ -0,0 +1,59 @@
/**
* Copyright (c) 2010-2024 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;
}
}

View File

@ -0,0 +1,627 @@
/**
* Copyright (c) 2010-2024 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);
}
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2024 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) {
}

View File

@ -0,0 +1,11 @@
<?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>
</addon:addon>

View File

@ -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 (for V2: 6444).
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. Leave blank to discover
# 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.

View File

@ -0,0 +1,266 @@
<?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"/>
<channel id="dropped-commands" typeId="dropped-commands"/>
</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="text" required="true">
<context>ipPort</context>
<label>IP Port</label>
<description>IP port of the device (for V2: 6444).</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="text" required="true">
<context>version</context>
<label>AC Version</label>
<description>Version 3 requires Token, Key and Cloud provider. Version 2 doesn't. Leave blank to discover</description>
<default>3</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>
<channel-type id="dropped-commands" advanced="true">
<item-type>Number</item-type>
<label>Dropped Command Monitor</label>
<description>Commands dropped due to TCP read() issues.</description>
<category>Number</category>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2010-2024 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 Configs
*/
@Test
public void testValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = "6444";
config.deviceId = "1234567890";
assertTrue(config.isValid());
assertFalse(config.isDiscoveryNeeded());
}
/**
* Test for non-valid configs
*/
@Test
public void testnonValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = "";
config.deviceId = "1234567890";
assertFalse(config.isValid());
assertTrue(config.isDiscoveryNeeded());
}
/**
* Test for bad IP configs
*/
@Test
public void testBadIpConfigs() {
config.ipAddress = "192.1680.1";
config.ipPort = "6444";
config.deviceId = "1234567890";
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);
}
}

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2010-2024 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 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);
}
}

View File

@ -0,0 +1,241 @@
/**
* Copyright (c) 2010-2024 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);
}
}

View File

@ -0,0 +1,197 @@
/**
* Copyright (c) 2010-2024 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());
}
}

View File

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