Compare commits

...

14 Commits

Author SHA1 Message Date
Robert Eckhoff
811ef527b1
Merge 7350e5020f into adacdebb9f 2025-01-08 21:37:08 +00:00
Bob Eckhoff
7350e5020f Change to new Headers
Change to new Headers
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-08 16:37:06 -05:00
Robert Michalak
adacdebb9f
[digiplex] Handle erroneous responses and restart the bridge (#18035)
Signed-off-by: Robert Michalak <rbrt.michalak@gmail.com>
2025-01-08 22:21:07 +01:00
Bob Eckhoff
071c1e7391 Revert "Changing dates was a bad idea"
This reverts commit 44bb90ff99.

Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
2c3b4c90b7 Changing dates was a bad idea
Changing dates early was a bad idea

Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
0993ebc13c Change to OH5.0 snapshot
Changed version and changed dates (in advance-hopefully ok)
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
736551b613 Align V2 response with V3 reponse
Changed V2 response to align with V3 process after extra decoding.
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
18d67a08c5 Make retries more robust
Makes retries more robust. Three times for connection and one retry on the command.
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
23ec6aeb7e Spotless changes
spotless
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:32 -05:00
Bob Eckhoff
32c2665811 New PR candidate
Java doc and possible mdns discovery.

Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:31 -05:00
Bob Eckhoff
2a73de9d06 Apply spotless changes
forgot to run spotless on the last update

Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:31 -05:00
Bob Eckhoff
99ed3d5000 Changes to get separate Connection Manager working
After initial changes, tested various scenarios and made changes so they are working like before.

Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:31 -05:00
Bob Eckhoff
a2159bed2a Working version of split connection manager
Working (sort of) version of split connection manager. Problem with the connection manager being null when the binding is stop/start.  Need reset to clear with clean-cache too.
Signed-off-by: Bob Eckhoff <katmandodo@yahoo.com>
2025-01-03 10:28:31 -05:00
Bob Eckhoff
925fc2860e 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>
2025-01-03 10:28:31 -05:00
46 changed files with 6696 additions and 15 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

@ -51,7 +51,6 @@ public class DigiplexBindingConstants {
public static final String BRIDGE_MESSAGES_SENT = "statistics#messages_sent";
public static final String BRIDGE_RESPONSES_RECEIVED = "statistics#responses_received";
public static final String BRIDGE_EVENTS_RECEIVED = "statistics#events_received";
public static final String BRIDGE_TLM_TROUBLE = "troubles#tlm_trouble";
public static final String BRIDGE_AC_FAILURE = "troubles#ac_failure";
public static final String BRIDGE_BATTERY_FAILURE = "troubles#battery_failure";

View File

@ -52,6 +52,9 @@ public interface DigiplexMessageHandler {
default void handleUnknownResponse(UnknownResponse response) {
}
default void handleErroneousResponse(ErroneousResponse response) {
}
// Events
default void handleZoneEvent(ZoneEvent event) {
}

View File

@ -13,6 +13,7 @@
package org.openhab.binding.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.digiplex.internal.communication.events.AreaEvent;
import org.openhab.binding.digiplex.internal.communication.events.AreaEventType;
import org.openhab.binding.digiplex.internal.communication.events.GenericEvent;
@ -29,21 +30,19 @@ import org.openhab.binding.digiplex.internal.communication.events.ZoneStatusEven
* Resolves serial messages to appropriate classes
*
* @author Robert Michalak - Initial contribution
*
*/
@NonNullByDefault
public class DigiplexResponseResolver {
private static final String OK = "&ok";
// TODO: handle failures
private static final String FAIL = "&fail";
public static DigiplexResponse resolveResponse(String message) {
if (message.length() < 4) { // sanity check: try to filter out malformed responses
return new UnknownResponse(message);
return new ErroneousResponse(message);
}
int zoneNo, areaNo;
Integer zoneNo, areaNo;
String commandType = message.substring(0, 2);
switch (commandType) {
case "CO": // communication status
@ -53,24 +52,36 @@ public class DigiplexResponseResolver {
return CommunicationStatus.OK;
}
case "ZL": // zone label
zoneNo = Integer.valueOf(message.substring(2, 5));
zoneNo = getZoneOrArea(message);
if (zoneNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return ZoneLabelResponse.failure(zoneNo);
} else {
return ZoneLabelResponse.success(zoneNo, message.substring(5).trim());
}
case "AL": // area label
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaLabelResponse.failure(areaNo);
} else {
return AreaLabelResponse.success(areaNo, message.substring(5).trim());
}
case "RZ": // zone status
zoneNo = Integer.valueOf(message.substring(2, 5));
zoneNo = getZoneOrArea(message);
if (zoneNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return ZoneStatusResponse.failure(zoneNo);
} else {
if (message.length() < 10) {
return new ErroneousResponse(message);
}
return ZoneStatusResponse.success(zoneNo, // zone number
ZoneStatus.fromMessage(message.charAt(5)), // status
toBoolean(message.charAt(6)), // alarm
@ -79,10 +90,16 @@ public class DigiplexResponseResolver {
toBoolean(message.charAt(9))); // battery low
}
case "RA": // area status
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaStatusResponse.failure(areaNo);
} else {
if (message.length() < 12) {
return new ErroneousResponse(message);
}
return AreaStatusResponse.success(areaNo, // zone number
AreaStatus.fromMessage(message.charAt(5)), // status
toBoolean(message.charAt(6)), // zone in memory
@ -95,7 +112,10 @@ public class DigiplexResponseResolver {
case "AA": // area arm
case "AQ": // area quick arm
case "AD": // area disarm
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaArmDisarmResponse.failure(areaNo, ArmDisarmType.fromMessage(commandType));
} else {
@ -105,21 +125,41 @@ public class DigiplexResponseResolver {
case "PG": // PGM events
default:
if (message.startsWith("G")) {
if (message.length() >= 12) {
return resolveSystemEvent(message);
} else {
return new ErroneousResponse(message);
}
} else {
return new UnknownResponse(message);
}
}
}
private static @Nullable Integer getZoneOrArea(String message) {
if (message.length() < 5) {
return null;
}
try {
return Integer.valueOf(message.substring(2, 5));
} catch (NumberFormatException e) {
return null;
}
}
private static boolean toBoolean(char value) {
return value != 'O';
}
private static DigiplexResponse resolveSystemEvent(String message) {
int eventGroup = Integer.parseInt(message.substring(1, 4));
int eventNumber = Integer.parseInt(message.substring(5, 8));
int areaNumber = Integer.parseInt(message.substring(9, 12));
int eventGroup, eventNumber, areaNumber;
try {
eventGroup = Integer.parseInt(message.substring(1, 4));
eventNumber = Integer.parseInt(message.substring(5, 8));
areaNumber = Integer.parseInt(message.substring(9, 12));
} catch (NumberFormatException e) {
return new ErroneousResponse(message);
}
switch (eventGroup) {
case 0:
return new ZoneStatusEvent(eventNumber, ZoneStatus.CLOSED, areaNumber);

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Erroneous message from PRT3.
*
* Message that is invalid, which happens sometimes due to communication errors.
*
* @author Robert Michalak - Initial contribution
*
*/
@NonNullByDefault
public class ErroneousResponse implements DigiplexResponse {
public final String message;
public ErroneousResponse(String message) {
this.message = message;
}
@Override
public void accept(DigiplexMessageHandler visitor) {
visitor.handleErroneousResponse(this);
}
}

View File

@ -15,7 +15,9 @@ package org.openhab.binding.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Unknown message from PRT3
* Unknown message from PRT3.
*
* Message that is otherwise valid, but not handled in this binding.
*
* @author Robert Michalak - Initial contribution
*

View File

@ -38,6 +38,7 @@ import org.openhab.binding.digiplex.internal.communication.DigiplexMessageHandle
import org.openhab.binding.digiplex.internal.communication.DigiplexRequest;
import org.openhab.binding.digiplex.internal.communication.DigiplexResponse;
import org.openhab.binding.digiplex.internal.communication.DigiplexResponseResolver;
import org.openhab.binding.digiplex.internal.communication.ErroneousResponse;
import org.openhab.binding.digiplex.internal.communication.events.AbstractEvent;
import org.openhab.binding.digiplex.internal.communication.events.TroubleEvent;
import org.openhab.binding.digiplex.internal.communication.events.TroubleStatus;
@ -295,6 +296,12 @@ public class DigiplexBridgeHandler extends BaseBridgeHandler implements SerialPo
updateState(channel, state);
}
}
@Override
public void handleErroneousResponse(ErroneousResponse response) {
logger.debug("Erroneous response: {}", response.message);
handleCommunicationError();
}
}
private class DigiplexReceiverThread extends Thread {

View File

@ -0,0 +1,221 @@
/**
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.digiplex.internal.communication;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.openhab.binding.digiplex.internal.communication.events.GenericEvent;
/**
* Tests for {@link DigiplexResponseResolver}
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class DigiplexResponseResolverTest {
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsErroneousResponseWhenMessageIsMalformed")
void resolveResponseReturnsErroneousResponseWhenMessageIsMalformed(String message) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ErroneousResponse.class)));
if (actual instanceof ErroneousResponse erroneousResponse) {
assertThat(erroneousResponse.message, is(equalTo(message)));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsErroneousResponseWhenMessageIsMalformed() {
return Stream.of( //
Arguments.of("CO&"), Arguments.of("ZL&fail"), Arguments.of("ZL12"), Arguments.of("AL&fail"),
Arguments.of("AL12"), Arguments.of("RZZZ3COOOO&fail"), Arguments.of("RZ123C"),
Arguments.of("RZ123COOO"), Arguments.of("RA&fail"), Arguments.of("RA123DOOXOO"),
Arguments.of("AA&fail"), Arguments.of("GGGGGGGGGGGG"), Arguments.of("G1234567890"));
}
@Test
void resolveResponseReturnsCommunicationStatusSuccessWhenWellformed() {
String message = "CO&ok";
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(CommunicationStatus.class)));
if (actual instanceof CommunicationStatus communicationStatus) {
assertThat(communicationStatus.success, is(true));
}
}
@Test
void resolveResponseReturnsCommunicationStatusFailureWhenMessageContainsFail() {
String message = "CO&fail";
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(CommunicationStatus.class)));
if (actual instanceof CommunicationStatus communicationStatus) {
assertThat(communicationStatus.success, is(false));
}
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsZoneLabelResponse")
void resolveResponseReturnsZoneLabelResponse(String message, boolean expectedSuccess, int expectedZoneNo,
String expectedName) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ZoneLabelResponse.class)));
if (actual instanceof ZoneLabelResponse zoneLabelResponse) {
assertThat(zoneLabelResponse.success, is(expectedSuccess));
assertThat(zoneLabelResponse.zoneNo, is(expectedZoneNo));
assertThat(zoneLabelResponse.zoneName, is(expectedName));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsZoneLabelResponse() {
return Stream.of( //
Arguments.of("ZL123", true, 123, ""), Arguments.of("ZL123test ", true, 123, "test"),
Arguments.of("ZL123&fail", false, 123, null), Arguments.of("ZL123test&fail", false, 123, null));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaLabelResponse")
void resolveResponseReturnsAreaLabelResponse(String message, boolean expectedSuccess, int expectedAreaNo,
String expectedName) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaLabelResponse.class)));
if (actual instanceof AreaLabelResponse areaLabelResponse) {
assertThat(areaLabelResponse.success, is(expectedSuccess));
assertThat(areaLabelResponse.areaNo, is(expectedAreaNo));
assertThat(areaLabelResponse.areaName, is(expectedName));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaLabelResponse() {
return Stream.of( //
Arguments.of("AL123", true, 123, ""), Arguments.of("AL123test ", true, 123, "test"),
Arguments.of("AL123&fail", false, 123, null), Arguments.of("AL123test&fail", false, 123, null));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsZoneStatusResponse")
void resolveResponseReturnsZoneStatusResponse(String message, boolean expectedSuccess, int expectedZoneNo,
ZoneStatus expectedZoneStatus, boolean expectedAlarm, boolean expectedFireAlarm,
boolean expectedSupervisionLost, boolean expectedLowBattery) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ZoneStatusResponse.class)));
if (actual instanceof ZoneStatusResponse zoneStatusResponse) {
assertThat(zoneStatusResponse.success, is(expectedSuccess));
assertThat(zoneStatusResponse.zoneNo, is(expectedZoneNo));
assertThat(zoneStatusResponse.status, is(expectedZoneStatus));
assertThat(zoneStatusResponse.alarm, is(expectedAlarm));
assertThat(zoneStatusResponse.fireAlarm, is(expectedFireAlarm));
assertThat(zoneStatusResponse.supervisionLost, is(expectedSupervisionLost));
assertThat(zoneStatusResponse.lowBattery, is(expectedLowBattery));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsZoneStatusResponse() {
return Stream.of( //
Arguments.of("RZ123COOOO", true, 123, ZoneStatus.CLOSED, false, false, false, false),
Arguments.of("RZ123OOOOO", true, 123, ZoneStatus.OPEN, false, false, false, false),
Arguments.of("RZ123TOOOO", true, 123, ZoneStatus.TAMPERED, false, false, false, false),
Arguments.of("RZ123FOOOO", true, 123, ZoneStatus.FIRE_LOOP_TROUBLE, false, false, false, false),
Arguments.of("RZ123uOOOO", true, 123, ZoneStatus.UNKNOWN, false, false, false, false),
Arguments.of("RZ123cOOOO", true, 123, ZoneStatus.UNKNOWN, false, false, false, false),
Arguments.of("RZ123cXOOO", true, 123, ZoneStatus.UNKNOWN, true, false, false, false),
Arguments.of("RZ123cOXOO", true, 123, ZoneStatus.UNKNOWN, false, true, false, false),
Arguments.of("RZ123cOOXO", true, 123, ZoneStatus.UNKNOWN, false, false, true, false),
Arguments.of("RZ123cOOOX", true, 123, ZoneStatus.UNKNOWN, false, false, false, true),
Arguments.of("RZ123&fail", false, 123, null, false, false, false, false),
Arguments.of("RZ123COOOO&fail", false, 123, null, false, false, false, false));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaStatusResponse")
void resolveResponseReturnsAreaStatusResponse(String message, boolean expectedSuccess, int expectedAreaNo,
AreaStatus expectedAreaStatus, boolean expectedZoneInMemory, boolean expectedTrouble, boolean expectedReady,
boolean expectedInProgramming, boolean expectedAlarm, boolean expectedStrobe) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaStatusResponse.class)));
if (actual instanceof AreaStatusResponse areaStatusResponse) {
assertThat(areaStatusResponse.success, is(expectedSuccess));
assertThat(areaStatusResponse.areaNo, is(expectedAreaNo));
assertThat(areaStatusResponse.status, is(expectedAreaStatus));
assertThat(areaStatusResponse.zoneInMemory, is(expectedZoneInMemory));
assertThat(areaStatusResponse.trouble, is(expectedTrouble));
assertThat(areaStatusResponse.ready, is(expectedReady));
assertThat(areaStatusResponse.inProgramming, is(expectedInProgramming));
assertThat(areaStatusResponse.alarm, is(expectedAlarm));
assertThat(areaStatusResponse.strobe, is(expectedStrobe));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaStatusResponse() {
return Stream.of( //
Arguments.of("RA123DOOXOOO", true, 123, AreaStatus.DISARMED, false, false, false, false, false, false),
Arguments.of("RA123AOOXOOO", true, 123, AreaStatus.ARMED, false, false, false, false, false, false),
Arguments.of("RA123FOOXOOO", true, 123, AreaStatus.ARMED_FORCE, false, false, false, false, false,
false),
Arguments.of("RA123SOOXOOO", true, 123, AreaStatus.ARMED_STAY, false, false, false, false, false,
false),
Arguments.of("RA123IOOXOOO", true, 123, AreaStatus.ARMED_INSTANT, false, false, false, false, false,
false),
Arguments.of("RA123uOOXOOO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, false),
Arguments.of("RA123dOOXOOO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, false),
Arguments.of("RA123dXOXOOO", true, 123, AreaStatus.UNKNOWN, true, false, false, false, false, false),
Arguments.of("RA123dOXxOOO", true, 123, AreaStatus.UNKNOWN, false, true, false, false, false, false),
Arguments.of("RA123dOOOOOO", true, 123, AreaStatus.UNKNOWN, false, false, true, false, false, false),
Arguments.of("RA123dOOXXOO", true, 123, AreaStatus.UNKNOWN, false, false, false, true, false, false),
Arguments.of("RA123dOOXOXO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, true, false),
Arguments.of("RA123dOOXOOX", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, true),
Arguments.of("RA123&fail", false, 123, null, false, false, false, false, false, false),
Arguments.of("RA123DOOXOOO&fail", false, 123, null, false, false, false, false, false, false));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaArmDisarmResponse")
void resolveResponseReturnsAreaArmDisarmResponse(String message, boolean expectedSuccess, int expectedAreaNo,
ArmDisarmType expectedType) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaArmDisarmResponse.class)));
if (actual instanceof AreaArmDisarmResponse armDisarmResponse) {
assertThat(armDisarmResponse.success, is(expectedSuccess));
assertThat(armDisarmResponse.areaNo, is(expectedAreaNo));
assertThat(armDisarmResponse.type, is(expectedType));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaArmDisarmResponse() {
return Stream.of( //
Arguments.of("AA123", true, 123, ArmDisarmType.ARM),
Arguments.of("AQ123", true, 123, ArmDisarmType.QUICK_ARM),
Arguments.of("AD123", true, 123, ArmDisarmType.DISARM),
Arguments.of("AA123&fail", false, 123, ArmDisarmType.ARM),
Arguments.of("AQ123&fail", false, 123, ArmDisarmType.QUICK_ARM),
Arguments.of("AD123&fail", false, 123, ArmDisarmType.DISARM));
}
@Test
void resolveResponseReturnsGenericEventWhenWellformed() {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse("G123 456 789");
assertThat(actual, is(instanceOf(GenericEvent.class)));
if (actual instanceof GenericEvent genericEvent) {
assertThat(genericEvent.getEventGroup(), is(123));
assertThat(genericEvent.getEventNumber(), is(456));
assertThat(genericEvent.getAreaNo(), is(789));
}
}
}

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,120 @@
# Midea AC Binding
This binding integrates Air Conditioners that use the Midea protocol. Midea is an OEM for many brands.
An AC device is likely supported if it uses one of the following Android apps or it's iOS equivalent.
| Application | Comment | Options |
|--:-------------------------------------------|--:------------------------------------|--------------|
| Midea Air (com.midea.aircondition.obm) | Full Support of key and token updates | Midea Air |
| NetHome Plus (com.midea.aircondition) | Full Support of key and token updates | NetHome Plus |
| SmartHome/MSmartHome (com.midea.ai.overseas) | Full Support of key and token updates | MSmartHome |
Note: The Air Conditioner must already be set-up on the WiFi network and have a fixed IP Address with one of the three apps listed above for full discovery and key and token updates.
## Supported Things
This binding supports one Thing type `ac`.
## Discovery
Once the Air Conditioner is on the network (WiFi active) the other required parameters can be discovered automatically.
An IP broadcast message is sent and every responding unit gets added to the Inbox.
As an alternative use the python application msmart-ng from <https://github.com/mill1000/midea-msmart> with the msmart-ng discover ipAddress option.
## Binding Configuration
No binding configuration is required.
## Thing Configuration
| Parameter | Required ? | Comment | Default |
|--:----------|--:----------|--:----------------------------------------------------------------|---------|
| ipAddress | Yes | IP Address of the device. | |
| ipPort | Yes | IP port of the device | 6444 |
| deviceId | Yes | ID of the device. Leave 0 to do ID discovery (length 6 bytes). | 0 |
| cloud | Yes for V.3 | Cloud Provider name for email and password | |
| email | No | Email for cloud account chosen in Cloud Provider. | |
| password | No | Password for cloud account chosen in Cloud Provider. | |
| token | Yes for V.3 | Secret Token (length 128 HEX) | |
| key | Yes for V.3 | Secret Key (length 64 HEX) | |
| pollingTime | Yes | Polling time in seconds. Minimum time is 30 seconds. | 60 |
| timeout | Yes | Connecting timeout. Minimum time is 2 second, maximum 10 seconds. | 4 |
| promptTone | Yes | "Ding" tone when command is received and executed. | False |
| version | Yes | Version 3 has token, key and cloud requirements. | 0 |
## Channels
Following channels are available:
| Channel | Type | Description | Read only | Advanced |
|--:---------------------------|--:-----------------|--:-----------------------------------------------------------------------------------------------------|--:--------|--:-------|
| power | Switch | Turn the AC on and off. | | |
| target-temperature | Number:Temperature | Target temperature. | | |
| operational-mode | String | Operational mode: OFF (turns off), AUTO, COOL, DRY, HEAT, FAN ONLY | | |
| fan-speed | String | Fan speed: OFF (turns off), SILENT, LOW, MEDIUM, HIGH, AUTO. Not all modes supported by all units. | | |
| swing-mode | String | Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Not all modes supported by all units. | | |
| eco-mode | Switch | Eco mode - Cool only (Temperature is set to 24 C (75 F) and fan on AUTO) | | |
| turbo-mode | Switch | Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. COOL and HEAT mode only. | | |
| sleep-function | Switch | Sleep function ("Moon with a star" icon on IR Remote Controller). | | |
| indoor-temperature | Number:Temperature | Indoor temperature measured in the room, where internal unit is installed. | Yes | |
| outdoor-temperature | Number:Temperature | Outdoor temperature by external unit. Some units do not report reading when off. | Yes | |
| temperature-unit | Switch | Sets the LED display on the evaporator to Fahrenheit (true) or Celsius (false). | | Yes |
| on-timer | String | Sets the future time to turn on the AC. | | Yes |
| off-timer | String | Sets the future time to turn off the AC. | | Yes |
| screen-display | Switch | If device supports across LAN, turns off the LED display. | | Yes |
| humidity | Number | If device supports, the indoor humidity. | Yes | Yes |
| appliance-error | Switch | If device supports, appliance error | Yes | Yes |
| auxiliary-heat | Switch | If device supports, auxiliary heat | Yes | Yes |
| alternate-target-temperature | Number:Temperature | Alternate Target Temperature - not currently used | Yes | Yes |
## Examples
### `demo.things` Example
```java
Thing mideaac:ac:mideaac "myAC" @ "Room" [ ipAddress="192.168.1.200", ipPort="6444", deviceId="deviceId", cloud="your cloud (e.g NetHome Plus)", email="yourclouduser@email.com", password="yourcloudpassword", token="token", key ="key", pollingTime = 60, timeout=4, promptTone="false", version="3"]
```
Option to use the built-in binding discovery of ipPort, deviceId, token and key.
```java
Thing mideaac:ac:mideaac "myAC" @ "Room" [ ipAddress="192.168.1.200", ipPort="", deviceId="", cloud="your cloud (e.g NetHome Plus)", email="yourclouduser@email.com", password="yourcloudpassword", token="", key ="", pollingTime = 60, timeout=4, promptTone="false", version="3"]
```
### `demo.items` Example
```java
Switch power "Power" { channel="mideaac:ac:mideaac:power" }
Number:Temperature target_temperature "Target Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:target-temperature" }
String operational_mode "Operational Mode" { channel="mideaac:ac:mideaac:operational-mode" }
String fan_speed "Fan Speed" { channel="mideaac:ac:mideaac:fan-speed" }
String swing_mode "Swing Mode" { channel="mideaac:ac:mideaac:swing-mode" }
Number:Temperature indoor_temperature "Indoor Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:indoor-temperature" }
Number:Temperature outdoor_temperature "Current Temperature [%.1f °F]" { channel="mideaac:ac:mideaac:outdoor-temperature" }
Switch eco_mode "Eco Mode" { channel="mideaac:ac:mideaac:eco-mode" }
Switch turbo_mode "Turbo Mode" { channel="mideaac:ac:mideaac:turbo-mode" }
Switch sleep_function "Sleep function" { channel="mideaac:ac:mideaac:sleep-function" }
Switch temperature_unit "Fahrenheit or Celsius" { channel="mideaac:ac:mideaac:temperature-unit" }
```
### `demo.sitemap` Example
```java
sitemap midea label="Split AC MBR"{
Frame label="AC Unit" {
Text item=outdoor_temperature label="Outdoor Temperature [%.1f °F]"
Text item=indoor_temperature label="Indoor Temperature [%.1f °F]"
Setpoint item=target_temperature label="Target Temperature [%.1f °F]" minValue=63.0 maxValue=78 step=1.0
Switch item=power label="Midea AC Power"
Switch item=temperature_unit label= "Temp Unit" mappings=[ON="Fahrenheit", OFF="Celsius"]
Selection item=fan_speed label="Midea AC Fan Speed"
Selection item=operational_mode label="Midea AC Mode"
Selection item=swing_mode label="Midea AC Louver Swing Mode"
}
}
```
## Debugging and Tracing
Switch the log level to TRACE or DEBUG on the UI Settings Page (Add-on Settings)

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>5.0.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-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal;
import java.util.Collections;
import java.util.Set;
import javax.measure.Unit;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link MideaACBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - OH naming conventions
*/
@NonNullByDefault
public class MideaACBindingConstants {
private static final String BINDING_ID = "mideaac";
/**
* Thing Type
*/
public static final ThingTypeUID THING_TYPE_MIDEAAC = new ThingTypeUID(BINDING_ID, "ac");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_MIDEAAC);
/**
* List of all channel IDS
*/
public static final String CHANNEL_POWER = "power";
public static final String CHANNEL_APPLIANCE_ERROR = "appliance-error";
public static final String CHANNEL_TARGET_TEMPERATURE = "target-temperature";
public static final String CHANNEL_OPERATIONAL_MODE = "operational-mode";
public static final String CHANNEL_FAN_SPEED = "fan-speed";
public static final String CHANNEL_ON_TIMER = "on-timer";
public static final String CHANNEL_OFF_TIMER = "off-timer";
public static final String CHANNEL_SWING_MODE = "swing-mode";
public static final String CHANNEL_AUXILIARY_HEAT = "auxiliary-heat";
public static final String CHANNEL_ECO_MODE = "eco-mode";
public static final String CHANNEL_TEMPERATURE_UNIT = "temperature-unit";
public static final String CHANNEL_SLEEP_FUNCTION = "sleep-function";
public static final String CHANNEL_TURBO_MODE = "turbo-mode";
public static final String CHANNEL_INDOOR_TEMPERATURE = "indoor-temperature";
public static final String CHANNEL_OUTDOOR_TEMPERATURE = "outdoor-temperature";
public static final String CHANNEL_HUMIDITY = "humidity";
public static final String CHANNEL_ALTERNATE_TARGET_TEMPERATURE = "alternate-target-temperature";
public static final String CHANNEL_SCREEN_DISPLAY = "screen-display";
public static final String DROPPED_COMMANDS = "dropped-commands";
public static final Unit<Temperature> API_TEMPERATURE_UNIT = SIUnits.CELSIUS;
/**
* Commands sent to/from AC wall unit are ASCII
*/
public static final String CHARSET = "US-ASCII";
/**
* List of all AC thing properties
*/
public static final String CONFIG_IP_ADDRESS = "ipAddress";
public static final String CONFIG_IP_PORT = "ipPort";
public static final String CONFIG_DEVICEID = "deviceId";
public static final String CONFIG_CLOUD = "cloud";
public static final String CONFIG_EMAIL = "email";
public static final String CONFIG_PASSWORD = "password";
public static final String CONFIG_TOKEN = "token";
public static final String CONFIG_KEY = "key";
public static final String CONFIG_POLLING_TIME = "pollingTime";
public static final String CONFIG_CONNECTING_TIMEOUT = "timeout";
public static final String CONFIG_PROMPT_TONE = "promptTone";
public static final String CONFIG_VERSION = "version";
public static final String PROPERTY_SN = "sn";
public static final String PROPERTY_SSID = "ssid";
public static final String PROPERTY_TYPE = "type";
}

View File

@ -0,0 +1,124 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link MideaACConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - OH addons changes
*/
@NonNullByDefault
public class MideaACConfiguration {
/**
* IP Address
*/
public String ipAddress = "";
/**
* IP Port
*/
public int ipPort = 6444;
/**
* Device ID
*/
public String deviceId = "0";
/**
* Cloud Account email
*/
public String email = "";
/**
* Cloud Account Password
*/
public String password = "";
/**
* Cloud Provider
*/
public String cloud = "";
/**
* Token
*/
public String token = "";
/**
* Key
*/
public String key = "";
/**
* Poll Frequency
*/
public int pollingTime = 60;
/**
* Socket Timeout
*/
public int timeout = 4;
/**
* Prompt tone from indoor unit with a Set Command
*/
public boolean promptTone = false;
/**
* AC Version
*/
public int version = 0;
/**
* Check during initialization that the params are valid
*
* @return true(valid), false (not valid)
*/
public boolean isValid() {
return !("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank()
|| version <= 1);
}
/**
* Check during initialization if discovery is needed
*
* @return true(discovery needed), false (not needed)
*/
public boolean isDiscoveryNeeded() {
return ("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank()
|| !Utils.validateIP(ipAddress) || version <= 1);
}
/**
* Check during initialization if key and token can be obtained
* from the cloud.
*
* @return true (yes they can), false (they cannot)
*/
public boolean isTokenKeyObtainable() {
return (!email.isBlank() && !password.isBlank() && !"".equals(cloud));
}
/**
* Check during initialization if cloud, key and token are true for v3
*
* @return true (Valid, all items are present) false (key, token and/or provider missing)
*/
public boolean isV3ConfigValid() {
return (!key.isBlank() && !token.isBlank() && !"".equals(cloud));
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal;
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mideaac.internal.dto.CloudsDTO;
import org.openhab.binding.mideaac.internal.handler.MideaACHandler;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link MideaACHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Jacek Dobrowolski - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.mideaac", service = ThingHandlerFactory.class)
public class MideaACHandlerFactory extends BaseThingHandlerFactory {
private final HttpClientFactory httpClientFactory;
private final CloudsDTO clouds;
private final UnitProvider unitProvider;
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
/**
* The MideaACHandlerFactory class parameters
*
* @param unitProvider OH unitProvider
* @param httpClientFactory OH httpClientFactory
*/
@Activate
public MideaACHandlerFactory(@Reference UnitProvider unitProvider, @Reference HttpClientFactory httpClientFactory) {
this.httpClientFactory = httpClientFactory;
this.unitProvider = unitProvider;
clouds = new CloudsDTO();
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
return new MideaACHandler(thing, unitProvider, httpClientFactory.getCommonHttpClient(), clouds);
}
return null;
}
}

View File

@ -0,0 +1,250 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Random;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jose4j.base64url.Base64;
import com.google.gson.JsonObject;
/**
* The {@link Utils} class defines common byte and String array methods
* which are used across the whole binding.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - JavaDoc
*/
@NonNullByDefault
public class Utils {
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
private static final char[] HEX_ARRAY_LOWERCASE = "0123456789abcdef".toCharArray();
static byte[] empty = new byte[0];
/**
* Converts byte array to upper case hex string
*
* @param bytes bytes to convert
* @return string of hex chars
*/
public static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars);
}
/**
* Converts byte array to binary string
*
* @param bytes bytes to convert
* @return string of hex chars
*/
public static String bytesToBinary(byte[] bytes) {
String s1 = "";
for (int j = 0; j < bytes.length; j++) {
s1 = s1.concat(Integer.toBinaryString(bytes[j] & 255 | 256).substring(1));
s1 = s1.concat(" ");
}
return s1;
}
/**
* Converts byte array to lower case hex string
*
* @param bytes bytes to convert
* @return string of hex chars
*/
public static String bytesToHexLowercase(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY_LOWERCASE[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY_LOWERCASE[v & 0x0F];
}
return new String(hexChars);
}
/**
* Validates the IP address format
*
* @param ip string of IP Address
* @return IP pattern OK
*/
public static boolean validateIP(final String ip) {
String pattern = "^((0|1\\d?\\d?|2[0-4]?\\d?|25[0-5]?|[3-9]\\d?)\\.){3}(0|1\\d?\\d?|2[0-4]?\\d?|25[0-5]?|[3-9]\\d?)$";
return ip.matches(pattern);
}
/**
* Converts hex string to a byte array
*
* @param s string to convert to byte array
* @return byte array
*/
public static byte[] hexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16));
}
return data;
}
/**
* Adds two byte arrays together
*
* @param a input byte array 1
* @param b input byte array 2
* @return byte array
*/
public static byte[] concatenateArrays(byte[] a, byte[] b) {
byte[] c = new byte[a.length + b.length];
System.arraycopy(a, 0, c, 0, a.length);
System.arraycopy(b, 0, c, a.length, b.length);
return c;
}
/**
* Arrange byte order
*
* @param i input
* @return @return byte array
*/
public static byte[] toBytes(short i) {
ByteBuffer b = ByteBuffer.allocate(2);
b.order(ByteOrder.BIG_ENDIAN); // optional, the initial order of a byte buffer is always BIG_ENDIAN.
b.putShort(i);
return b.array();
}
/**
* Combine byte arrays
*
* @param array1 input array
* @param array2 input array
* @return byte array
*/
public static byte[] strxor(byte[] array1, byte[] array2) {
byte[] result = new byte[array1.length];
int i = 0;
for (byte b : array1) {
result[i] = (byte) (b ^ array2[i++]);
}
return result;
}
/**
* Create String of the v3 Token
*
* @param nbytes number of bytes
* @return String
*/
public static String tokenHex(int nbytes) {
Random r = new Random();
StringBuffer sb = new StringBuffer();
for (int n = 0; n < nbytes; n++) {
sb.append(Integer.toHexString(r.nextInt()));
}
return sb.toString().substring(0, nbytes);
}
/**
* Create URL safe token
*
* @param nbytes number of bytes
* @return encoded string
*/
public static String tokenUrlsafe(int nbytes) {
Random r = new Random();
byte[] bytes = new byte[nbytes];
r.nextBytes(bytes);
return Base64.encode(bytes);
}
/**
* Extracts 6 bits and reorders them based on signed or unsigned
*
* @param i input
* @param order byte order
* @return reordered array
*/
public static byte[] toIntTo6ByteArray(long i, ByteOrder order) {
final ByteBuffer bb = ByteBuffer.allocate(8);
bb.order(order);
bb.putLong(i);
if (order == ByteOrder.BIG_ENDIAN) {
return Arrays.copyOfRange(bb.array(), 2, 8);
}
if (order == ByteOrder.LITTLE_ENDIAN) {
return Arrays.copyOfRange(bb.array(), 0, 6);
}
return empty;
}
/**
* String Builder
*
* @param json JSON object
* @return string
*/
public static String getQueryString(JsonObject json) {
StringBuilder sb = new StringBuilder();
Iterator<String> keys = json.keySet().stream().sorted().iterator();
while (keys.hasNext()) {
@Nullable
String key = keys.next();
sb.append(key);
sb.append("=");
sb.append(json.get(key).getAsString());
if (keys.hasNext()) {
sb.append("&"); // To allow for another argument.
}
}
return sb.toString();
}
/**
* Used to reverse (or unreverse) the deviceId
*
* @param array input array
* @return reversed array
*/
public static byte[] reverse(byte[] array) {
int left = 0;
int right = array.length - 1;
while (left < right) {
byte temp = array[left];
array[left] = array[right];
array[right] = temp;
left++;
right--;
}
return array;
}
}

View File

@ -0,0 +1,438 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.connection;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
import org.openhab.binding.mideaac.internal.handler.CommandSet;
import org.openhab.binding.mideaac.internal.handler.Response;
import org.openhab.binding.mideaac.internal.handler.Timer;
import org.openhab.binding.mideaac.internal.handler.Timer.TimeParser;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link CommandHelper} is a static class that is able to translate {@link Command} to {@link CommandSet}
*
* @author Leo Siepel - Initial contribution
*/
@NonNullByDefault
public class CommandHelper {
private static Logger logger = LoggerFactory.getLogger(CommandHelper.class);
private static final StringType OPERATIONAL_MODE_OFF = new StringType("OFF");
private static final StringType OPERATIONAL_MODE_AUTO = new StringType("AUTO");
private static final StringType OPERATIONAL_MODE_COOL = new StringType("COOL");
private static final StringType OPERATIONAL_MODE_DRY = new StringType("DRY");
private static final StringType OPERATIONAL_MODE_HEAT = new StringType("HEAT");
private static final StringType OPERATIONAL_MODE_FAN_ONLY = new StringType("FAN_ONLY");
private static final StringType FAN_SPEED_OFF = new StringType("OFF");
private static final StringType FAN_SPEED_SILENT = new StringType("SILENT");
private static final StringType FAN_SPEED_LOW = new StringType("LOW");
private static final StringType FAN_SPEED_MEDIUM = new StringType("MEDIUM");
private static final StringType FAN_SPEED_HIGH = new StringType("HIGH");
private static final StringType FAN_SPEED_FULL = new StringType("FULL");
private static final StringType FAN_SPEED_AUTO = new StringType("AUTO");
private static final StringType SWING_MODE_OFF = new StringType("OFF");
private static final StringType SWING_MODE_VERTICAL = new StringType("VERTICAL");
private static final StringType SWING_MODE_HORIZONTAL = new StringType("HORIZONTAL");
private static final StringType SWING_MODE_BOTH = new StringType("BOTH");
/**
* Device Power ON OFF
*
* @param command On or Off
*/
public static CommandSet handlePower(Command command, Response lastResponse) throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command.equals(OnOffType.OFF)) {
commandSet.setPowerState(false);
} else if (command.equals(OnOffType.ON)) {
commandSet.setPowerState(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown power command: {}", command));
}
return commandSet;
}
/**
* Supported AC - Heat Pump modes
*
* @param command Operational Mode Cool, Heat, etc.
*/
public static CommandSet handleOperationalMode(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command instanceof StringType) {
if (command.equals(OPERATIONAL_MODE_OFF)) {
commandSet.setPowerState(false);
} else if (command.equals(OPERATIONAL_MODE_AUTO)) {
commandSet.setOperationalMode(OperationalMode.AUTO);
} else if (command.equals(OPERATIONAL_MODE_COOL)) {
commandSet.setOperationalMode(OperationalMode.COOL);
} else if (command.equals(OPERATIONAL_MODE_DRY)) {
commandSet.setOperationalMode(OperationalMode.DRY);
} else if (command.equals(OPERATIONAL_MODE_HEAT)) {
commandSet.setOperationalMode(OperationalMode.HEAT);
} else if (command.equals(OPERATIONAL_MODE_FAN_ONLY)) {
commandSet.setOperationalMode(OperationalMode.FAN_ONLY);
} else {
throw new UnsupportedOperationException(String.format("Unknown operational mode command: {}", command));
}
}
return commandSet;
}
private static float limitTargetTemperatureToRange(float temperatureInCelsius) {
if (temperatureInCelsius < 17.0f) {
return 17.0f;
}
if (temperatureInCelsius > 30.0f) {
return 30.0f;
}
return temperatureInCelsius;
}
/**
* Device only uses Celsius in 0.5 degree increments
* Fahrenheit is rounded to fit (example
* setting to 64 F is 18 C but will result in 64.4 F display in OH)
* The evaporator only displays 2 digits, so will show 64.
*
* @param command Target Temperature
*/
public static CommandSet handleTargetTemperature(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command instanceof DecimalType decimalCommand) {
logger.debug("Handle Target Temperature as DecimalType in degrees C");
commandSet.setTargetTemperature(limitTargetTemperatureToRange(decimalCommand.floatValue()));
} else if (command instanceof QuantityType<?> quantityCommand) {
if (quantityCommand.getUnit().equals(ImperialUnits.FAHRENHEIT)) {
quantityCommand = Objects.requireNonNull(quantityCommand.toUnit(SIUnits.CELSIUS));
}
commandSet.setTargetTemperature(limitTargetTemperatureToRange(quantityCommand.floatValue()));
} else {
throw new UnsupportedOperationException(String.format("Unknown target temperature command: {}", command));
}
return commandSet;
}
/**
* Fan Speeds vary by V2 or V3 and device. This command also turns the power ON
*
* @param command Fan Speed Auto, Low, High, etc.
*/
public static CommandSet handleFanSpeed(Command command, Response lastResponse, int version)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command instanceof StringType) {
commandSet.setPowerState(true);
if (command.equals(FAN_SPEED_OFF)) {
commandSet.setPowerState(false);
} else if (command.equals(FAN_SPEED_SILENT)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.SILENT2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.SILENT3);
}
} else if (command.equals(FAN_SPEED_LOW)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.LOW2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.LOW3);
}
} else if (command.equals(FAN_SPEED_MEDIUM)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.MEDIUM2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.MEDIUM3);
}
} else if (command.equals(FAN_SPEED_HIGH)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.HIGH2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.HIGH3);
}
} else if (command.equals(FAN_SPEED_FULL)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.FULL2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.FULL3);
}
} else if (command.equals(FAN_SPEED_AUTO)) {
if (version == 2) {
commandSet.setFanSpeed(FanSpeed.AUTO2);
} else if (version == 3) {
commandSet.setFanSpeed(FanSpeed.AUTO3);
}
} else {
throw new UnsupportedOperationException(String.format("Unknown fan speed command: {}", command));
}
}
return commandSet;
}
/**
* Must be set in Cool mode. Fan will switch to Auto
* and temp will be 24 C or 75 F on unit (75.2 F in OH)
*
* @param command Eco Mode
*/
public static CommandSet handleEcoMode(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command.equals(OnOffType.OFF)) {
commandSet.setEcoMode(false);
} else if (command.equals(OnOffType.ON)) {
commandSet.setEcoMode(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown eco mode command: {}", command));
}
return commandSet;
}
/**
* Modes supported depends on the device
* Power is turned on when swing mode is changed
*
* @param command Swing Mode
*/
public static CommandSet handleSwingMode(Command command, Response lastResponse, int version)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
commandSet.setPowerState(true);
if (command instanceof StringType) {
if (command.equals(SWING_MODE_OFF)) {
if (version == 2) {
commandSet.setSwingMode(SwingMode.OFF2);
} else if (version == 3) {
commandSet.setSwingMode(SwingMode.OFF3);
}
} else if (command.equals(SWING_MODE_VERTICAL)) {
if (version == 2) {
commandSet.setSwingMode(SwingMode.VERTICAL2);
} else if (version == 3) {
commandSet.setSwingMode(SwingMode.VERTICAL3);
}
} else if (command.equals(SWING_MODE_HORIZONTAL)) {
if (version == 2) {
commandSet.setSwingMode(SwingMode.HORIZONTAL2);
} else if (version == 3) {
commandSet.setSwingMode(SwingMode.HORIZONTAL3);
}
} else if (command.equals(SWING_MODE_BOTH)) {
if (version == 2) {
commandSet.setSwingMode(SwingMode.BOTH2);
} else if (version == 3) {
commandSet.setSwingMode(SwingMode.BOTH3);
}
} else {
throw new UnsupportedOperationException(String.format("Unknown swing mode command: {}", command));
}
}
return commandSet;
}
/**
* Turbo mode is only with Heat or Cool to quickly change
* Room temperature. Power is turned on.
*
* @param command Turbo mode - Fast cooling or Heating
*/
public static CommandSet handleTurboMode(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
commandSet.setPowerState(true);
if (command.equals(OnOffType.OFF)) {
commandSet.setTurboMode(false);
} else if (command.equals(OnOffType.ON)) {
commandSet.setTurboMode(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown turbo mode command: {}", command));
}
return commandSet;
}
/**
* May not be supported via LAN in all models - IR only
*
* @param command Screen Display Toggle to ON or Off - One command
*/
public static CommandSet handleScreenDisplay(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command.equals(OnOffType.OFF)) {
commandSet.setScreenDisplay(true);
} else if (command.equals(OnOffType.ON)) {
commandSet.setScreenDisplay(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown screen display command: {}", command));
}
return commandSet;
}
/**
* This is only for the AC LED device display units, calcs always in Celsius
*
* @param command Temp unit on the indoor evaporator
*/
public static CommandSet handleTempUnit(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
if (command.equals(OnOffType.OFF)) {
commandSet.setFahrenheit(false);
} else if (command.equals(OnOffType.ON)) {
commandSet.setFahrenheit(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown temperature unit command: {}", command));
}
return commandSet;
}
/**
* Power turned on with Sleep Mode Change
* Sleep mode increases temp slightly in first 2 hours of sleep
*
* @param command Sleep function
*/
public static CommandSet handleSleepFunction(Command command, Response lastResponse)
throws UnsupportedOperationException {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
commandSet.setPowerState(true);
if (command.equals(OnOffType.OFF)) {
commandSet.setSleepMode(false);
} else if (command.equals(OnOffType.ON)) {
commandSet.setSleepMode(true);
} else {
throw new UnsupportedOperationException(String.format("Unknown sleep mode command: {}", command));
}
return commandSet;
}
/**
* Sets the time (from now) that the device will turn on at it's current settings
*
* @param command Sets On Timer
*/
public static CommandSet handleOnTimer(Command command, Response lastResponse) {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
int hours = 0;
int minutes = 0;
Timer timer = new Timer(true, hours, minutes);
TimeParser timeParser = timer.new TimeParser();
if (command instanceof StringType) {
String timeString = ((StringType) command).toString();
if (!timeString.matches("\\d{2}:\\d{2}")) {
logger.debug("Invalid time format. Expected HH:MM.");
commandSet.setOnTimer(false, hours, minutes);
} else {
int[] timeParts = timeParser.parseTime(timeString);
boolean on = true;
hours = timeParts[0];
minutes = timeParts[1];
// Validate minutes and hours
if (minutes < 0 || minutes > 59 || hours > 24 || hours < 0) {
logger.debug("Invalid hours (24 max) and or minutes (59 max)");
hours = 0;
minutes = 0;
}
if (hours == 0 && minutes == 0) {
commandSet.setOnTimer(false, hours, minutes);
} else {
commandSet.setOnTimer(on, hours, minutes);
}
}
} else {
logger.debug("Command must be of type StringType: {}", command);
commandSet.setOnTimer(false, hours, minutes);
}
return commandSet;
}
/**
* Sets the time (from now) that the device will turn off
*
* @param command Sets Off Timer
*/
public static CommandSet handleOffTimer(Command command, Response lastResponse) {
CommandSet commandSet = CommandSet.fromResponse(lastResponse);
int hours = 0;
int minutes = 0;
Timer timer = new Timer(true, hours, minutes);
TimeParser timeParser = timer.new TimeParser();
if (command instanceof StringType) {
String timeString = ((StringType) command).toString();
if (!timeString.matches("\\d{2}:\\d{2}")) {
logger.debug("Invalid time format. Expected HH:MM.");
commandSet.setOffTimer(false, hours, minutes);
} else {
int[] timeParts = timeParser.parseTime(timeString);
boolean on = true;
hours = timeParts[0];
minutes = timeParts[1];
// Validate minutes and hours
if (minutes < 0 || minutes > 59 || hours > 24 || hours < 0) {
logger.debug("Invalid hours (24 max) and or minutes (59 max)");
hours = 0;
minutes = 0;
}
if (hours == 0 && minutes == 0) {
commandSet.setOffTimer(false, hours, minutes);
} else {
commandSet.setOffTimer(on, hours, minutes);
}
}
} else {
logger.debug("Command must be of type StringType: {}", command);
commandSet.setOffTimer(false, hours, minutes);
}
return commandSet;
}
}

View File

@ -0,0 +1,540 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.connection;
import java.io.ByteArrayInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.Arrays;
import java.util.HexFormat;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.connection.exception.MideaAuthenticationException;
import org.openhab.binding.mideaac.internal.connection.exception.MideaConnectionException;
import org.openhab.binding.mideaac.internal.connection.exception.MideaException;
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
import org.openhab.binding.mideaac.internal.handler.Callback;
import org.openhab.binding.mideaac.internal.handler.CommandBase;
import org.openhab.binding.mideaac.internal.handler.CommandSet;
import org.openhab.binding.mideaac.internal.handler.Packet;
import org.openhab.binding.mideaac.internal.handler.Response;
import org.openhab.binding.mideaac.internal.security.Decryption8370Result;
import org.openhab.binding.mideaac.internal.security.Security;
import org.openhab.binding.mideaac.internal.security.Security.MsgType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ConnectionManager} class is responsible for managing the state of the TCP connection to the
* indoor AC unit evaporator.
*
* @author Jacek Dobrowolski - Initial Contribution
* @author Bob Eckhoff - Revised logic to reconnect with security before each poll or command
*
* This gets around the issue that any command needs to be within 30 seconds of the authorization
* in testing this only adds 50 ms, but allows polls at longer intervals
*/
@NonNullByDefault
public class ConnectionManager {
private Logger logger = LoggerFactory.getLogger(ConnectionManager.class);
private final String ipAddress;
private final int ipPort;
private final int timeout;
private String key;
private String token;
private final String cloud;
private final String deviceId;
private Response lastResponse;
private CloudProviderDTO cloudProvider;
private Security security;
private final int version;
private final boolean promptTone;
private boolean deviceIsConnected;
private int droppedCommands = 0;
/**
* True allows command retry if null response
*/
private boolean retry = true;
public ConnectionManager(String ipAddress, int ipPort, int timeout, String key, String token, String cloud,
String email, String password, String deviceId, int version, boolean promptTone) {
this.deviceIsConnected = false;
this.ipAddress = ipAddress;
this.ipPort = ipPort;
this.timeout = timeout;
this.key = key;
this.token = token;
this.cloud = cloud;
this.deviceId = deviceId;
this.version = version;
this.promptTone = promptTone;
this.lastResponse = new Response(HexFormat.of().parseHex("C00042667F7F003C0000046066000000000000000000F9ECDB"),
version, "query", (byte) 0xc0);
this.cloudProvider = CloudProviderDTO.getCloudProvider(cloud);
this.security = new Security(cloudProvider);
}
private Socket socket = new Socket();
private InputStream inputStream = new ByteArrayInputStream(new byte[0]);
private DataOutputStream writer = new DataOutputStream(System.out);
/**
* Gets last response
*
* @return byte array of last response
*/
public Response getLastResponse() {
return this.lastResponse;
}
/**
* Validate if String is blank
*
* @param str string to be evaluated
* @return boolean true or false
*/
public static boolean isBlank(String str) {
return str.trim().isEmpty();
}
/**
* After checking if the key and token need to be updated (Default = 0 Never)
* The socket is established with the writer and inputStream (for reading responses)
* The device is considered connected. V2 devices will proceed to send the poll or the
* set command. V3 devices will proceed to authenticate
*/
public synchronized void connect() throws MideaConnectionException, MideaAuthenticationException {
logger.trace("Connecting to {}:{}", ipAddress, ipPort);
int maxTries = 3;
int retryCount = 0;
// Open socket
// Retry addresses most common wifi connection problems- wait 5 seconds and try again
while (retryCount < maxTries) {
try {
socket = new Socket();
socket.setSoTimeout(timeout * 1000);
socket.connect(new InetSocketAddress(ipAddress, ipPort), timeout * 1000);
break;
} catch (IOException e) {
retryCount++;
if (retryCount < maxTries) {
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
logger.debug("An interupted error (socket retry) has occured {}", ex.getMessage());
}
logger.debug("Socket retry count {}, IOException connecting to {}: {}", retryCount, ipAddress,
e.getMessage());
}
}
}
if (retryCount == maxTries) {
deviceIsConnected = false;
logger.info("Failed to connect after {} tries. Try again with next scheduled poll", maxTries);
throw new MideaConnectionException("Failed to connect after maximum tries");
}
// Create streams
try {
writer = new DataOutputStream(socket.getOutputStream());
inputStream = socket.getInputStream();
} catch (IOException e) {
logger.debug("IOException getting streams for {}: {}", ipAddress, e.getMessage(), e);
deviceIsConnected = false;
throw new MideaConnectionException(e);
}
if (version == 3) {
logger.debug("Device at IP: {} requires authentication, going to authenticate", ipAddress);
try {
authenticate();
} catch (MideaAuthenticationException | MideaConnectionException e) {
deviceIsConnected = false;
throw e;
}
}
if (!deviceIsConnected) {
logger.info("Connected to IP {}", ipAddress);
}
logger.debug("Connected to IP {}", ipAddress);
deviceIsConnected = true;
}
/**
* For V3 devices only. This method checks for the Cloud Provider
* key and token (and goes offline if any are missing). It will retrieve the
* missing key and/or token if the account email and password are provided.
*
* @throws MideaAuthenticationException
* @throws MideaConnectionException
*/
public void authenticate() throws MideaConnectionException, MideaAuthenticationException {
logger.trace("Key: {}", key);
logger.trace("Token: {}", token);
logger.trace("Cloud {}", cloud);
if (!isBlank(token) && !isBlank(key) && !"".equals(cloud)) {
logger.debug("Device at IP: {} authenticating", ipAddress);
doV3Handshake();
} else {
throw new MideaAuthenticationException("Token, Key and / or cloud provider missing");
}
}
/**
* Sends the Handshake Request to the V3 device. Generally quick response
* Without the 1000 ms sleep delay there are problems in sending the Poll/Command
* Suspect that the socket write and read streams need a moment to clear
* as they will be reused in the SendCommand method
*/
private void doV3Handshake() throws MideaConnectionException, MideaAuthenticationException {
byte[] request = security.encode8370(Utils.hexStringToByteArray(token), MsgType.MSGTYPE_HANDSHAKE_REQUEST);
try {
logger.trace("Device at IP: {} writing handshake_request: {}", ipAddress, Utils.bytesToHex(request));
write(request);
byte[] response = read();
if (response != null && response.length > 0) {
logger.trace("Device at IP: {} response for handshake_request length:{}", ipAddress, response.length);
if (response.length == 72) {
boolean success = security.tcpKey(Arrays.copyOfRange(response, 8, 72),
Utils.hexStringToByteArray(key));
if (success) {
logger.debug("Authentication successful");
// Altering the sleep caused or can cause write errors problems. Use caution.
// At 500 ms the first write usually fails. Works, but no backup
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
logger.debug("An interupted error (success) has occured {}", e.getMessage());
}
} else {
throw new MideaAuthenticationException("Invalid Key. Correct Key in configuration.");
}
} else if (Arrays.equals(new String("ERROR").getBytes(), response)) {
throw new MideaAuthenticationException("Authentication failed!");
} else {
logger.warn("Authentication reponse unexpected data length ({} instead of 72)!", response.length);
throw new MideaAuthenticationException("Unexpected authentication response length");
}
}
} catch (IOException e) {
throw new MideaConnectionException(e);
}
}
/**
* Sends the routine polling command from the DoPoll
* in the MideaACHandler
*
* @param callback
* @throws MideaConnectionException
* @throws MideaAuthenticationException
* @throws MideaException
*/
public void getStatus(Callback callback)
throws MideaConnectionException, MideaAuthenticationException, MideaException {
CommandBase requestStatusCommand = new CommandBase();
sendCommand(requestStatusCommand, callback);
}
private void ensureConnected() throws MideaConnectionException, MideaAuthenticationException {
disconnect();
connect();
}
/**
* Pulls the packet byte array together. There is a check to
* make sure to make sure the input stream is empty before sending
* the new command and another check if input stream is empty after 1.5 seconds.
* Normal device response in 0.75 - 1 second range
* If still empty, send the bytes again. If there are bytes, the read method is called.
* If the socket times out with no response the command is dropped. There will be another poll
* in the time set by the user (30 seconds min). A Set command will need to be resent.
*
* @param command either the set or polling command
* @throws MideaAuthenticationException
* @throws MideaConnectionException
*/
public synchronized void sendCommand(CommandBase command, @Nullable Callback callback)
throws MideaConnectionException, MideaAuthenticationException {
ensureConnected();
if (command instanceof CommandSet) {
((CommandSet) command).setPromptTone(promptTone);
}
Packet packet = new Packet(command, deviceId, security);
packet.compose();
try {
byte[] bytes = packet.getBytes();
logger.debug("Writing to {} bytes.length: {}", ipAddress, bytes.length);
if (version == 3) {
bytes = security.encode8370(bytes, MsgType.MSGTYPE_ENCRYPTED_REQUEST);
}
// Ensure input stream is empty before writing packet
if (inputStream.available() == 0) {
logger.debug("Input stream empty sending write {}", command);
write(bytes);
}
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
logger.debug("An interupted error (retrycommand2) has occured {}", e.getMessage());
Thread.currentThread().interrupt();
// Note, but continue anyway for second write.
}
if (inputStream.available() == 0) {
logger.debug("Input stream empty sending second write {}", command);
write(bytes);
}
// Socket timeout (UI parameter) 2 seconds minimum up to 10 seconds.
byte[] responseBytes = read();
if (responseBytes != null) {
retry = true;
if (version == 3) {
Decryption8370Result result = security.decode8370(responseBytes);
for (byte[] response : result.getResponses()) {
logger.debug("Response length: {} IP address: {} ", response.length, ipAddress);
if (response.length > 40 + 16) {
byte[] data = security.aesDecrypt(Arrays.copyOfRange(response, 40, response.length - 16));
logger.trace("Bytes in HEX, decoded and with header: length: {}, data: {}", data.length,
Utils.bytesToHex(data));
byte bodyType2 = data[0xa];
// data[3]: Device Type - 0xAC = AC
// https://github.com/georgezhao2010/midea_ac_lan/blob/06fc4b582a012bbbfd6bd5942c92034270eca0eb/custom_components/midea_ac_lan/midea_devices.py#L96
// data[9]: MessageType - set, query, notify1, notify2, exception, querySN, exception2,
// querySubtype
// https://github.com/georgezhao2010/midea_ac_lan/blob/30d0ff5ff14f150da10b883e97b2f280767aa89a/custom_components/midea_ac_lan/midea/core/message.py#L22-L29
String responseType = "";
switch (data[0x9]) {
case 0x02:
responseType = "set";
break;
case 0x03:
responseType = "query";
break;
case 0x04:
responseType = "notify1";
break;
case 0x05:
responseType = "notify2";
break;
case 0x06:
responseType = "exception";
break;
case 0x07:
responseType = "querySN";
break;
case 0x0A:
responseType = "exception2";
break;
case 0x09: // Helyesen: 0xA0
responseType = "querySubtype";
break;
default:
logger.debug("Invalid response type: {}", data[0x9]);
}
logger.trace("Response Type: {} and bodyType: {}", responseType, bodyType2);
// The response data from the appliance includes a packet header which we don't want
data = Arrays.copyOfRange(data, 10, data.length);
byte bodyType = data[0x0];
logger.trace("Response Type expected: {} and bodyType: {}", responseType, bodyType);
logger.trace("Bytes in HEX, decoded and stripped without header: length: {}, data: {}",
data.length, Utils.bytesToHex(data));
logger.debug("Bytes in BINARY, decoded and stripped without header: length: {}, data: {}",
data.length, Utils.bytesToBinary(data));
if (data.length < 21) {
logger.warn("Response data is {} long, minimum is 21!", data.length);
return;
}
if (bodyType != -64) {
if (bodyType == 30) {
logger.warn("Error response 0x1E received {} from IP Address:{}", bodyType,
ipAddress);
return;
}
logger.warn("Unexpected response bodyType {}", bodyType);
return;
}
lastResponse = new Response(data, version, responseType, bodyType);
try {
logger.trace("Data length is {}, version is {}, IP address is {}", data.length, version,
ipAddress);
if (callback != null) {
callback.updateChannels(lastResponse);
}
} catch (Exception ex) {
logger.warn("Processing response exception: {}", ex.getMessage());
}
}
}
} else {
if (responseBytes.length > 40 + 16) {
byte[] data = security
.aesDecrypt(Arrays.copyOfRange(responseBytes, 40, responseBytes.length - 16));
logger.trace("V2 Bytes decoded with header: length: {}, data: {}", data.length,
Utils.bytesToHex(data));
// The response data from the appliance includes a packet header which we don't want
data = Arrays.copyOfRange(data, 10, data.length);
byte bodyType = data[0x0];
logger.trace("V2 Bytes decoded and stripped without header: length: {}, data: {}", data.length,
Utils.bytesToHex(data));
if (data.length < 21) {
logger.warn("Response data is {} long, minimum is 21!", data.length);
return;
}
if (bodyType != -64) {
if (bodyType == 30) {
logger.warn("Error response 0x1E received {} from IP Address:{}", bodyType, ipAddress);
return;
}
logger.warn("Unexpected response bodyType {}", bodyType);
return;
}
lastResponse = new Response(data, version, "", bodyType);
try {
logger.trace("Data length is {}, version is {}, Ip Address is {}", data.length, version,
ipAddress);
if (callback != null) {
callback.updateChannels(lastResponse);
}
} catch (Exception ex) {
logger.warn("Processing response exception: {}", ex.getMessage());
}
}
}
return;
} else {
if (retry) {
logger.debug("Resending Command {}", command);
retry = false;
sendCommand(command, callback);
} else {
droppedCommands = droppedCommands + 1;
logger.info("Problem with reading response, skipping {} skipped count since startup {}", command,
droppedCommands);
retry = true;
return;
}
}
} catch (SocketException e) {
droppedCommands = droppedCommands + 1;
logger.debug("Socket exception on IP: {}, skipping command {} skipped count since startup {}", ipAddress,
command, droppedCommands);
throw new MideaConnectionException(e);
} catch (IOException e) {
droppedCommands = droppedCommands + 1;
logger.debug("IO exception on IP: {}, skipping command {} skipped count since startup {}", ipAddress,
command, droppedCommands);
throw new MideaConnectionException(e);
}
}
/**
* Closes all elements of the connection before starting a new one
* Makes sure writer, inputStream and socket are closed before each command is started
*/
public synchronized void disconnect() {
logger.debug("Disconnecting from device at {}", ipAddress);
InputStream inputStream = this.inputStream;
DataOutputStream writer = this.writer;
Socket socket = this.socket;
try {
writer.close();
inputStream.close();
socket.close();
} catch (IOException e) {
logger.warn("IOException closing connection to device at {}: {}", ipAddress, e.getMessage(), e);
}
socket = null;
inputStream = null;
writer = null;
}
/**
* Reads the inputStream byte array
*
* @return byte array or null
*/
public synchronized byte @Nullable [] read() {
byte[] bytes = new byte[512];
InputStream inputStream = this.inputStream;
try {
int len = inputStream.read(bytes);
if (len > 0) {
logger.debug("Response received length: {} from device at IP: {}", len, ipAddress);
bytes = Arrays.copyOfRange(bytes, 0, len);
return bytes;
}
} catch (IOException e) {
String message = e.getMessage();
logger.debug(" Byte read exception {}", message);
}
return null;
}
/**
* Writes the packet that will be sent to the device
*
* @param buffer socket writer
* @throws IOException writer could be null
*/
public synchronized void write(byte[] buffer) throws IOException {
DataOutputStream writer = this.writer;
try {
writer.write(buffer, 0, buffer.length);
} catch (IOException e) {
String message = e.getMessage();
logger.debug("Write error {}", message);
}
}
/**
* Disconnects from the AC device
*
* @param force
*/
public void dispose(boolean force) {
disconnect();
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.connection.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link MideaAuthenticationException} represents a binding specific {@link Exception}.
*
* @author Leo Siepel - Initial contribution
*/
@NonNullByDefault
public class MideaAuthenticationException extends Exception {
private static final long serialVersionUID = 1L;
public MideaAuthenticationException(String message) {
super(message);
}
public MideaAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
public MideaAuthenticationException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.connection.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link MideaConnectionException} represents a binding specific {@link Exception}.
*
* @author Leo Siepel - Initial contribution
*/
@NonNullByDefault
public class MideaConnectionException extends Exception {
private static final long serialVersionUID = 1L;
public MideaConnectionException(String message) {
super(message);
}
public MideaConnectionException(String message, Throwable cause) {
super(message, cause);
}
public MideaConnectionException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.connection.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link MideaException} represents a binding specific {@link Exception}.
*
* @author Leo Siepel - Initial contribution
*/
@NonNullByDefault
public class MideaException extends Exception {
private static final long serialVersionUID = 1L;
public MideaException(String message) {
super(message);
}
public MideaException(String message, Throwable cause) {
super(message, cause);
}
public MideaException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.discovery;
import java.io.Closeable;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Connection} Manages the discovery connection to a Midea AC.
*
* @author Jacek Dobrowolski - Initial contribution
*/
@NonNullByDefault
public class Connection implements Closeable {
/**
* UDP port1 to send command.
*/
public static final int MIDEAAC_SEND_PORT1 = 6445;
/**
* UDP port2 to send command.
*/
public static final int MIDEAAC_SEND_PORT2 = 20086;
/**
* UDP port devices send discover replies back.
*/
public static final int MIDEAAC_RECEIVE_PORT = 6440;
private final InetAddress iNetAddress;
private final DatagramSocket socket;
/**
* Initializes a connection to the given IP address.
*
* @param ipAddress IP address of the connection
* @throws UnknownHostException if ipAddress could not be resolved.
* @throws SocketException if no Datagram socket connection could be made.
*/
public Connection(String ipAddress) throws SocketException, UnknownHostException {
iNetAddress = InetAddress.getByName(ipAddress);
socket = new DatagramSocket();
}
/**
* Sends the 9 bytes command to the Midea AC device.
*
* @param command the 9 bytes command
* @throws IOException Connection to the LED failed
*/
public void sendCommand(byte[] command) throws IOException {
{
DatagramPacket sendPkt = new DatagramPacket(command, command.length, iNetAddress, MIDEAAC_SEND_PORT1);
socket.send(sendPkt);
}
{
DatagramPacket sendPkt = new DatagramPacket(command, command.length, iNetAddress, MIDEAAC_SEND_PORT2);
socket.send(sendPkt);
}
}
@Override
public void close() {
socket.close();
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.discovery;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.discovery.DiscoveryResult;
/**
* Discovery {@link DiscoveryHandler}
*
* @author Jacek Dobrowolski - Initial contribution
*/
@NonNullByDefault
public interface DiscoveryHandler {
/**
* Discovery result
*
* @param discoveryResult AC device
*/
public void discovered(DiscoveryResult discoveryResult);
}

View File

@ -0,0 +1,354 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.discovery;
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.*;
import java.io.IOException;
import java.math.BigInteger;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
import org.openhab.binding.mideaac.internal.handler.CommandBase;
import org.openhab.binding.mideaac.internal.security.Security;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link MideaACDiscoveryService} service for Midea AC.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - OH naming conventions
*/
@NonNullByDefault
@Component(service = DiscoveryService.class, configurationPid = "discovery.mideaac")
public class MideaACDiscoveryService extends AbstractDiscoveryService {
private static int discoveryTimeoutSeconds = 5;
private final int receiveJobTimeout = 20000;
private final int udpPacketTimeout = receiveJobTimeout - 50;
private final String mideaacNamePrefix = "MideaAC";
private final Logger logger = LoggerFactory.getLogger(MideaACDiscoveryService.class);
///// Network
private byte[] buffer = new byte[512];
@Nullable
private DatagramSocket discoverSocket;
@Nullable
DiscoveryHandler discoveryHandler;
private Security security;
/**
* Discovery Service Uses the default decryption for all devices
*/
public MideaACDiscoveryService() {
super(SUPPORTED_THING_TYPES_UIDS, discoveryTimeoutSeconds, false);
this.security = new Security(CloudProviderDTO.getCloudProvider(""));
}
@Override
protected void startScan() {
logger.debug("Start scan for Midea AC devices.");
discoverThings();
}
@Override
protected void stopScan() {
logger.debug("Stop scan for Midea AC devices.");
closeDiscoverSocket();
super.stopScan();
}
/**
* Performs the actual discovery of Midea AC devices (things).
*/
private void discoverThings() {
try {
final DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
// No need to call close first, because the caller of this method already has done it.
startDiscoverSocket();
// Runs until the socket call gets a time out and throws an exception. When a time out is triggered it means
// no data was present and nothing new to discover.
while (true) {
// Set packet length in case a previous call reduced the size.
receivePacket.setLength(buffer.length);
DatagramSocket discoverSocket = this.discoverSocket;
if (discoverSocket == null) {
break;
} else {
discoverSocket.receive(receivePacket);
}
logger.debug("Midea AC device discovery returned package with length {}", receivePacket.getLength());
if (receivePacket.getLength() > 0) {
thingDiscovered(receivePacket);
}
}
} catch (SocketTimeoutException e) {
logger.debug("Discovering poller timeout...");
} catch (IOException e) {
logger.debug("Error during discovery: {}", e.getMessage());
} finally {
closeDiscoverSocket();
removeOlderResults(getTimestampOfLastScan());
}
}
/**
* Performs the actual discovery of a specific Midea AC device (thing)
*
* @param ipAddress IP Address
* @param discoveryHandler Discovery Handler
*/
public void discoverThing(String ipAddress, DiscoveryHandler discoveryHandler) {
try {
final DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
// No need to call close first, because the caller of this method already has done it.
startDiscoverSocket(ipAddress, discoveryHandler);
// Runs until the socket call gets a time out and throws an exception. When a time out is triggered it means
// no data was present and nothing new to discover.
while (true) {
// Set packet length in case a previous call reduced the size.
receivePacket.setLength(buffer.length);
DatagramSocket discoverSocket = this.discoverSocket;
if (discoverSocket == null) {
break;
} else {
discoverSocket.receive(receivePacket);
}
logger.debug("Midea AC device discovery returned package with length {}", receivePacket.getLength());
if (receivePacket.getLength() > 0) {
thingDiscovered(receivePacket);
}
}
} catch (SocketTimeoutException e) {
logger.trace("Discovering poller timeout...");
} catch (IOException e) {
logger.debug("Error during discovery: {}", e.getMessage());
} finally {
closeDiscoverSocket();
}
}
/**
* Opens a {@link DatagramSocket} and sends a packet for discovery of Midea AC devices.
*
* @throws SocketException
* @throws IOException
*/
private void startDiscoverSocket() throws SocketException, IOException {
startDiscoverSocket("255.255.255.255", null);
}
/**
* Start the discovery Socket
*
* @param ipAddress broadcast IP Address
* @param discoveryHandler Discovery handler
* @throws SocketException Socket Exception
* @throws IOException IO Exception
*/
public void startDiscoverSocket(String ipAddress, @Nullable DiscoveryHandler discoveryHandler)
throws SocketException, IOException {
logger.trace("Discovering: {}", ipAddress);
this.discoveryHandler = discoveryHandler;
discoverSocket = new DatagramSocket(new InetSocketAddress(Connection.MIDEAAC_RECEIVE_PORT));
DatagramSocket discoverSocket = this.discoverSocket;
if (discoverSocket != null) {
discoverSocket.setBroadcast(true);
discoverSocket.setSoTimeout(udpPacketTimeout);
final InetAddress broadcast = InetAddress.getByName(ipAddress);
{
final DatagramPacket discoverPacket = new DatagramPacket(CommandBase.discover(),
CommandBase.discover().length, broadcast, Connection.MIDEAAC_SEND_PORT1);
discoverSocket.send(discoverPacket);
logger.trace("Broadcast discovery package sent to port: {}", Connection.MIDEAAC_SEND_PORT1);
}
{
final DatagramPacket discoverPacket = new DatagramPacket(CommandBase.discover(),
CommandBase.discover().length, broadcast, Connection.MIDEAAC_SEND_PORT2);
discoverSocket.send(discoverPacket);
logger.trace("Broadcast discovery package sent to port: {}", Connection.MIDEAAC_SEND_PORT2);
}
}
}
/**
* Closes the discovery socket and cleans the value. No need for synchronization as this method is called from a
* synchronized context.
*/
private void closeDiscoverSocket() {
DatagramSocket discoverSocket = this.discoverSocket;
if (discoverSocket != null) {
discoverSocket.close();
this.discoverSocket = null;
}
}
/**
* Register a device (thing) with the discovered properties.
*
* @param packet containing data of detected device
*/
private void thingDiscovered(DatagramPacket packet) {
DiscoveryResult dr = discoveryPacketReceived(packet);
if (dr != null) {
DiscoveryHandler discoveryHandler = this.discoveryHandler;
if (discoveryHandler != null) {
discoveryHandler.discovered(dr);
} else {
thingDiscovered(dr);
}
}
}
/**
* Parses the packet to extract the device properties
*
* @param packet returned paket from device
* @return extracted device properties
*/
@Nullable
public DiscoveryResult discoveryPacketReceived(DatagramPacket packet) {
final String ipAddress = packet.getAddress().getHostAddress();
byte[] data = Arrays.copyOfRange(packet.getData(), 0, packet.getLength());
logger.trace("Midea AC discover data ({}) from {}: '{}'", data.length, ipAddress, Utils.bytesToHex(data));
if (data.length >= 104 && (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")
|| Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A"))) {
logger.trace("Device supported");
String mSmartId, mSmartip = "", mSmartSN = "", mSmartSSID = "", mSmartType = "", mSmartPort = "",
mSmartVersion = "";
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")) {
mSmartVersion = "2";
}
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
mSmartVersion = "3";
}
if (Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A")) {
data = Arrays.copyOfRange(data, 8, data.length - 16);
}
logger.debug("Version: {}", mSmartVersion);
byte[] id = Arrays.copyOfRange(data, 20, 26);
logger.trace("Id Bytes: {}", Utils.bytesToHex(id));
byte[] idReverse = Utils.reverse(id);
BigInteger bigId = new BigInteger(1, idReverse);
mSmartId = bigId.toString(10);
logger.debug("Id: '{}'", mSmartId);
byte[] encryptData = Arrays.copyOfRange(data, 40, data.length - 16);
logger.trace("Encrypt data: '{}'", Utils.bytesToHex(encryptData));
byte[] reply = security.aesDecrypt(encryptData);
logger.trace("Length: {}, Reply: '{}'", reply.length, Utils.bytesToHex(reply));
mSmartip = Byte.toUnsignedInt(reply[3]) + "." + Byte.toUnsignedInt(reply[2]) + "."
+ Byte.toUnsignedInt(reply[1]) + "." + Byte.toUnsignedInt(reply[0]);
logger.debug("IP: '{}'", mSmartip);
byte[] portIdBytes = Utils.reverse(Arrays.copyOfRange(reply, 4, 8));
BigInteger portId = new BigInteger(1, portIdBytes);
mSmartPort = portId.toString(10);
logger.debug("Port: '{}'", mSmartPort);
mSmartSN = new String(reply, 8, 40 - 8, StandardCharsets.UTF_8);
logger.debug("SN: '{}'", mSmartSN);
logger.trace("SSID length: '{}'", Byte.toUnsignedInt(reply[40]));
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
logger.debug("SSID: '{}'", mSmartSSID);
mSmartType = mSmartSSID.split("_")[1];
logger.debug("Type: '{}'", mSmartType);
String thingName = createThingName(packet.getAddress().getAddress(), mSmartId);
ThingUID thingUID = new ThingUID(THING_TYPE_MIDEAAC, thingName.toLowerCase());
return DiscoveryResultBuilder.create(thingUID).withLabel(thingName)
.withRepresentationProperty(CONFIG_IP_ADDRESS).withThingType(THING_TYPE_MIDEAAC)
.withProperties(collectProperties(ipAddress, mSmartVersion, mSmartId, mSmartPort, mSmartSN,
mSmartSSID, mSmartType))
.build();
} else if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 6)).equals("3C3F786D6C20")) {
logger.debug("Midea AC v1 device was detected, supported, but not implemented yet.");
return null;
} else {
logger.debug(
"Midea AC device was detected, but the retrieved data is incomplete or not supported. Device not registered");
return null;
}
}
/**
* Creates a OH name for the Midea AC device.
*
* @return the name for the device
*/
private String createThingName(final byte[] byteIP, String id) {
return mideaacNamePrefix + "-" + Byte.toUnsignedInt(byteIP[3]) + "-" + id;
}
/**
* Collects properties into a map.
*
* @param ipAddress IP address of the thing
* @param version Version 2 or 3
* @param id ID of the device
* @param port Port of the device
* @param sn Serial number of the device
* @param ssid Serial id converted with StandardCharsets.UTF_8
* @param type Type of device (ac)
* @return Map with properties
*/
private Map<String, Object> collectProperties(String ipAddress, String version, String id, String port, String sn,
String ssid, String type) {
Map<String, Object> properties = new TreeMap<>();
properties.put(CONFIG_IP_ADDRESS, ipAddress);
properties.put(CONFIG_IP_PORT, port);
properties.put(CONFIG_DEVICEID, id);
properties.put(CONFIG_VERSION, version);
properties.put(PROPERTY_SN, sn);
properties.put(PROPERTY_SSID, ssid);
properties.put(PROPERTY_TYPE, type);
return properties;
}
}

View File

@ -0,0 +1,357 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.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-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link CloudProviderDTO} class contains the information
* to allow encryption and decryption for the supported Cloud Providers
*
* @param name Cloud provider
* @param appkey application key
* @param appid application id
* @param apiurl application url
* @param signkey sign key for AES
* @param proxied proxy - MSmarthome only
* @param iotkey iot key - MSmarthome only
* @param hmackey hmac key - MSmarthome only
*
* @author Jacek Dobrowolski - Initial Contribution
* @author Bob Eckhoff - JavaDoc and conversion to record
*/
@NonNullByDefault
public record CloudProviderDTO(String name, String appkey, String appid, String apiurl, String signkey, String proxied,
String iotkey, String hmackey) {
/**
* Cloud provider information for record
* All providers use the same signkey for AES encryption and Decryption.
* V2 Devices do not require a Cloud Provider entry as they only use AES
*
* @param name Cloud provider
* @return Cloud provider information (appkey, appid, apiurl,signkey, proxied, iotkey, hmackey)
*/
public static CloudProviderDTO getCloudProvider(String name) {
switch (name) {
case "NetHome Plus":
return new CloudProviderDTO("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
"https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
case "Midea Air":
return new CloudProviderDTO("Midea Air", "ff0cf6f5f0c3471de36341cab3f7a9af", "1117",
"https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
case "MSmartHome":
return new CloudProviderDTO("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "1010",
"https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S",
"meicloud", "PROD_VnoClJI9aikS8dyy", "v5");
}
return new CloudProviderDTO("", "", "", "", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
}
}

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.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,30 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Response} performs the byte data stream decoding
*
* @author Leo Siepel - Initial contribution
*/
@NonNullByDefault
public interface Callback {
/**
* Updates channels with the response
*
* @param response Byte response from the device used to update channels
*/
void updateChannels(Response response);
}

View File

@ -0,0 +1,314 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import java.time.LocalDateTime;
import java.util.Arrays;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.security.Crc8;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link CommandBase} has the discover command and the routine poll command
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - Add Java Docs, minor fixes
*/
@NonNullByDefault
public class CommandBase {
private final Logger logger = LoggerFactory.getLogger(CommandBase.class);
private static final byte[] DISCOVER_COMMAND = new byte[] { (byte) 0x5a, (byte) 0x5a, (byte) 0x01, (byte) 0x11,
(byte) 0x48, (byte) 0x00, (byte) 0x92, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x7f, (byte) 0x75, (byte) 0xbd, (byte) 0x6b,
(byte) 0x3e, (byte) 0x4f, (byte) 0x8b, (byte) 0x76, (byte) 0x2e, (byte) 0x84, (byte) 0x9c, (byte) 0x6e,
(byte) 0x57, (byte) 0x8d, (byte) 0x65, (byte) 0x90, (byte) 0x03, (byte) 0x6e, (byte) 0x9d, (byte) 0x43,
(byte) 0x42, (byte) 0xa5, (byte) 0x0f, (byte) 0x1f, (byte) 0x56, (byte) 0x9e, (byte) 0xb8, (byte) 0xec,
(byte) 0x91, (byte) 0x8e, (byte) 0x92, (byte) 0xe5 };
protected byte[] data;
/**
* Operational Modes
*/
public enum OperationalMode {
AUTO(1),
COOL(2),
DRY(3),
HEAT(4),
FAN_ONLY(5),
UNKNOWN(0);
private final int value;
private OperationalMode(int value) {
this.value = value;
}
/**
* Gets Operational Mode value
*
* @return value
*/
public int getId() {
return value;
}
/**
* Provides Operational Mode Common name
*
* @param id integer from byte response
* @return type
*/
public static OperationalMode fromId(int id) {
for (OperationalMode type : values()) {
if (type.getId() == id) {
return type;
}
}
return UNKNOWN;
}
}
/**
* Converts byte value to the Swing Mode label by version
* Two versions of V3, Supported Swing or Non-Supported (4)
* V2 set without leading 3, but reports with it (1)
*/
public enum SwingMode {
OFF3(0x30, 3),
OFF4(0x00, 3),
VERTICAL3(0x3C, 3),
VERTICAL4(0xC, 3),
HORIZONTAL3(0x33, 3),
HORIZONTAL4(0x3, 3),
BOTH3(0x3F, 3),
BOTH4(0xF, 3),
OFF2(0, 2),
VERTICAL2(0xC, 2),
VERTICAL1(0x3C, 2),
HORIZONTAL2(0x3, 2),
HORIZONTAL1(0x33, 2),
BOTH2(0xF, 2),
BOTH1(0x3F, 2),
UNKNOWN(0xFF, 0);
private final int value;
private final int version;
private SwingMode(int value, int version) {
this.value = value;
this.version = version;
}
/**
* Gets Swing Mode value
*
* @return value
*/
public int getId() {
return value;
}
/**
* Gets device version for swing mode
*
* @return version
*/
public int getVersion() {
return version;
}
/**
* Gets Swing mode in common language horiontal, vertical, off, etc.
*
* @param id integer from byte response
* @param version device version
* @return type
*/
public static SwingMode fromId(int id, int version) {
for (SwingMode type : values()) {
if (type.getId() == id && type.getVersion() == version) {
return type;
}
}
return UNKNOWN;
}
@Override
public String toString() {
// Drops the trailing 1 (V2 report) 2, 3 or 4 (nonsupported V3) from the swing mode
return super.toString().replace("1", "").replace("2", "").replace("3", "").replace("4", "");
}
}
/**
* Converts byte value to the Fan Speed label by version.
* Some devices do not support all speeds
*/
public enum FanSpeed {
AUTO2(102, 2),
FULL2(100, 2),
HIGH2(80, 2),
MEDIUM2(50, 2),
LOW2(30, 2),
SILENT2(20, 2),
UNKNOWN2(0, 2),
AUTO3(102, 3),
FULL3(0, 3),
HIGH3(80, 3),
MEDIUM3(60, 3),
LOW3(40, 3),
SILENT3(30, 3),
UNKNOWN3(0, 3),
UNKNOWN(0, 0);
private final int value;
private final int version;
private FanSpeed(int value, int version) {
this.value = value;
this.version = version;
}
/**
* Gets Fan Speed value
*
* @return value
*/
public int getId() {
return value;
}
/**
* Gets device version for Fan Speed
*
* @return version
*/
public int getVersion() {
return version;
}
/**
* Returns Fan Speed high, medium, low, etc
*
* @param id integer from byte response
* @param version version
* @return type
*/
public static FanSpeed fromId(int id, int version) {
for (FanSpeed type : values()) {
if (type.getId() == id && type.getVersion() == version) {
return type;
}
}
return UNKNOWN;
}
@Override
public String toString() {
// Drops the trailing 2 or 3 from the fan speed
return super.toString().replace("2", "").replace("3", "");
}
}
/**
* Returns the command to discover devices.
* Command is defined above
*
* @return discover command
*/
public static byte[] discover() {
return DISCOVER_COMMAND;
}
/**
* Byte Array structure for Base commands
*/
public CommandBase() {
data = new byte[] { (byte) 0xaa,
// request is 0x20; setting is 0x23
(byte) 0x20,
// device type
(byte) 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// request is 0x03; setting is 0x02
(byte) 0x03,
// Byte0 - Data request/response type: 0x41 - check status; 0x40 - Set up
(byte) 0x41,
// Byte1
(byte) 0x81,
// Byte2 - operational_mode
0x00,
// Byte3
(byte) 0xff,
// Byte4
0x03,
// Byte5
(byte) 0xff,
// Byte6
0x00,
// Byte7 - Room Temperature Request: 0x02 - indoor_temperature, 0x03 - outdoor_temperature
// when set, this is swing_mode
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Message ID
0x00 };
LocalDateTime now = LocalDateTime.now();
data[data.length - 1] = (byte) now.getSecond();
data[0x02] = (byte) 0xAC;
}
/**
* Pulls the elements of the Base command together
*/
public void compose() {
logger.trace("Base Bytes before crypt {}", Utils.bytesToHex(data));
byte crc8 = (byte) Crc8.calculate(Arrays.copyOfRange(data, 10, data.length));
byte[] newData1 = new byte[data.length + 1];
System.arraycopy(data, 0, newData1, 0, data.length);
newData1[data.length] = crc8;
data = newData1;
byte chksum = checksum(Arrays.copyOfRange(data, 1, data.length));
byte[] newData2 = new byte[data.length + 1];
System.arraycopy(data, 0, newData2, 0, data.length);
newData2[data.length] = chksum;
data = newData2;
}
/**
* Gets byte array
*
* @return data array
*/
public byte[] getBytes() {
return data;
}
private static byte checksum(byte[] bytes) {
int sum = 0;
for (byte value : bytes) {
sum = (byte) (sum + value);
}
sum = (byte) ((255 - (sum % 256)) + 1);
return (byte) sum;
}
}

View File

@ -0,0 +1,399 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.handler.Timer.TimerData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This {@link CommandSet} class handles the allowed changes originating from
* the items linked to the Midea AC channels. Not all devices
* support all commands. The general process is to clear the
* bit(s) the set them to the commanded value.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - Add Java Docs, minor fixes
*/
@NonNullByDefault
public class CommandSet extends CommandBase {
private final Logger logger = LoggerFactory.getLogger(CommandSet.class);
/**
* Byte array structure for Command set
*/
public CommandSet() {
data[0x01] = (byte) 0x23;
data[0x09] = (byte) 0x02;
// Set up Mode
data[0x0a] = (byte) 0x40;
byte[] extra = { 0x00, 0x00, 0x00 };
byte[] newData = new byte[data.length + 3];
System.arraycopy(data, 0, newData, 0, data.length);
newData[data.length] = extra[0];
newData[data.length + 1] = extra[1];
newData[data.length + 2] = extra[2];
data = newData;
}
/**
* These provide continuity so a new command on another channel
* doesn't delete the current states of the other channels
*
* @param response response from last poll or set command
* @return commandSet
*/
public static CommandSet fromResponse(Response response) {
CommandSet commandSet = new CommandSet();
commandSet.setPowerState(response.getPowerState());
commandSet.setTargetTemperature(response.getTargetTemperature());
commandSet.setOperationalMode(response.getOperationalMode());
commandSet.setFanSpeed(response.getFanSpeed());
commandSet.setFahrenheit(response.getFahrenheit());
commandSet.setTurboMode(response.getTurboMode());
commandSet.setSwingMode(response.getSwingMode());
commandSet.setEcoMode(response.getEcoMode());
commandSet.setSleepMode(response.getSleepFunction());
commandSet.setOnTimer(response.getOnTimerData());
commandSet.setOffTimer(response.getOffTimerData());
return commandSet;
}
/**
* Causes indoor evaporator to beep when Set command received
*
* @param feedbackEnabled will indoor unit beep
*/
public void setPromptTone(boolean feedbackEnabled) {
if (!feedbackEnabled) {
data[0x0b] &= ~(byte) 0x40; // Clear
} else {
data[0x0b] |= (byte) 0x40; // Set
}
}
/**
* Turns device On or Off
*
* @param state on or off
*/
public void setPowerState(boolean state) {
if (!state) {
data[0x0b] &= ~0x01;
} else {
data[0x0b] |= 0x01;
}
}
/**
* For Testing assertion get result
*
* @return true or false
*/
public boolean getPowerState() {
return (data[0x0b] & 0x1) > 0;
}
/**
* Cool, Heat, Fan Only, etc. See Command Base class
*
* @param mode cool, heat, etc.
*/
public void setOperationalMode(OperationalMode mode) {
data[0x0c] &= ~(byte) 0xe0;
data[0x0c] |= ((byte) mode.getId() << 5) & (byte) 0xe0;
}
/**
* For Testing assertion get result
*
* @return operational mode
*/
public int getOperationalMode() {
return data[0x0c] &= (byte) 0xe0;
}
/**
* Clear, then set the temperature bits, including the 0.5 bit
* This is all degrees C
*
* @param temperature target temperature
*/
public void setTargetTemperature(float temperature) {
data[0x0c] &= ~0x0f;
data[0x0c] |= (int) (Math.round(temperature * 2) / 2) & 0xf;
setTemperatureDot5((Math.round(temperature * 2)) % 2 != 0);
}
/**
* For Testing assertion get Setpoint results
*
* @return target temperature as a number
*/
public float getTargetTemperature() {
return (data[0x0c] & 0xf) + 16.0f + (((data[0x0c] & 0x10) > 0) ? 0.5f : 0.0f);
}
/**
* Low, Medium, High, Auto etc. See Command Base class
*
* @param speed Set fan speed
*/
public void setFanSpeed(FanSpeed speed) {
data[0x0d] = (byte) (speed.getId());
}
/**
* For Testing assertion get Fan Speed results
*
* @return fan speed as a number
*/
public int getFanSpeed() {
return data[0x0d];
}
/**
* In cool mode sets Fan to Auto and temp to 24 C
*
* @param ecoModeEnabled true or false
*/
public void setEcoMode(boolean ecoModeEnabled) {
if (!ecoModeEnabled) {
data[0x13] &= ~0x80;
} else {
data[0x13] |= 0x80;
}
}
/**
* If unit supports, set the vertical and/or horzontal louver
*
* @param mode sets swing mode
*/
public void setSwingMode(SwingMode mode) {
data[0x11] &= ~0x3f; // Clear the mode bits
data[0x11] |= mode.getId() & 0x3f;
}
/**
* For Testing assertion get Swing result
*
* @return swing mode
*/
public int getSwingMode() {
return data[0x11];
}
/**
* Activates the sleep function. Setpoint Temp increases in first
* two hours of sleep by 1 degree in Cool mode
*
* @param sleepModeEnabled true or false
*/
public void setSleepMode(boolean sleepModeEnabled) {
if (sleepModeEnabled) {
data[0x14] |= 0x01;
} else {
data[0x14] &= (~0x01);
}
}
/**
* Sets the Turbo mode for maximum cooling or heat
*
* @param turboModeEnabled true or false
*/
public void setTurboMode(boolean turboModeEnabled) {
if (turboModeEnabled) {
data[0x14] |= 0x02;
} else {
data[0x14] &= (~0x02);
}
}
/**
* Set the Indoor Unit display to Fahrenheit from Celsius
*
* @param fahrenheitEnabled true or false
*/
public void setFahrenheit(boolean fahrenheitEnabled) {
if (fahrenheitEnabled) {
data[0x14] |= 0x04;
} else {
data[0x14] &= (~0x04);
}
}
/**
* Toggles the LED display.
* This uses the request format, so needed modification, but need to keep
* current beep and operating state.
*
* @param screenDisplayToggle true (On) or false (off)
*/
public void setScreenDisplay(boolean screenDisplayToggle) {
modifyBytesForDisplayOff();
removeExtraBytes();
logger.trace(" Set Bytes before crypt {}", Utils.bytesToHex(data));
}
private void modifyBytesForDisplayOff() {
data[0x01] = (byte) 0x20;
data[0x09] = (byte) 0x03;
data[0x0a] = (byte) 0x41;
data[0x0b] |= 0x02; // Set
data[0x0b] &= ~(byte) 0x80; // Clear
data[0x0c] = (byte) 0x00;
data[0x0d] = (byte) 0xff;
data[0x0e] = (byte) 0x02;
data[0x0f] = (byte) 0x00;
data[0x10] = (byte) 0x02;
data[0x11] = (byte) 0x00;
data[0x12] = (byte) 0x00;
data[0x13] = (byte) 0x00;
data[0x14] = (byte) 0x00;
}
private void removeExtraBytes() {
byte[] newData = new byte[data.length - 3];
System.arraycopy(data, 0, newData, 0, newData.length);
data = newData;
}
/**
* Add 0.5C to the temperature value. If needed
* Target_temperature setter calls this method
*/
private void setTemperatureDot5(boolean temperatureDot5Enabled) {
if (temperatureDot5Enabled) {
data[0x0c] |= 0x10;
} else {
data[0x0c] &= (~0x10);
}
}
/**
* Set the ON timer for AC device start.
*
* @param timerData status (On or Off), hours, minutes
*/
public void setOnTimer(TimerData timerData) {
setOnTimer(timerData.status, timerData.hours, timerData.minutes);
}
/**
* Calculates remaining time until On
*
* @param on is timer on
* @param hours hours remaining
* @param minutes minutes remaining
*/
public void setOnTimer(boolean on, int hours, int minutes) {
// Process minutes (1 bit = 15 minutes)
int bits = (int) Math.floor(minutes / 15);
int subtract = 0;
if (bits != 0) {
subtract = (15 - (int) (minutes - bits * 15));
}
if (bits == 0 && minutes != 0) {
subtract = (15 - minutes);
}
data[0x0e] &= ~(byte) 0xff; // Clear
data[0x10] &= ~(byte) 0xf0;
if (on) {
data[0x0e] |= 0x80;
data[0x0e] |= (hours << 2) & 0x7c;
data[0x0e] |= bits & 0x03;
data[0x10] |= (subtract << 4) & 0xf0;
} else {
data[0x0e] = 0x7f;
}
}
/**
* For Testing assertion get On Timer result
*
* @return timer data base
*/
public int getOnTimer() {
return (data[0x0e] & 0xff);
}
/**
* For Testing assertion get On Timer result (subtraction amount)
*
* @return timer data subtraction
*/
public int getOnTimer2() {
return ((data[0x10] & (byte) 0xf0) >> 4) & 0x0f;
}
/**
* Set the timer for AC device stop.
*
* @param timerData status (On or Off), hours, minutes
*/
public void setOffTimer(TimerData timerData) {
setOffTimer(timerData.status, timerData.hours, timerData.minutes);
}
/**
* Calculates remaining time until Off
*
* @param on is timer on
* @param hours hours remaining
* @param minutes minutes remaining
*/
public void setOffTimer(boolean on, int hours, int minutes) {
int bits = (int) Math.floor(minutes / 15);
int subtract = 0;
if (bits != 0) {
subtract = (15 - (int) (minutes - bits * 15));
}
if (bits == 0 && minutes != 0) {
subtract = (15 - minutes);
}
data[0x0f] &= ~(byte) 0xff; // Clear
data[0x10] &= ~(byte) 0x0f;
if (on) {
data[0x0f] |= 0x80;
data[0x0f] |= (hours << 2) & 0x7c;
data[0x0f] |= bits & 0x03;
data[0x10] |= subtract & 0x0f;
} else {
data[0x0f] = 0x7f;
}
}
/**
* For Testing assertion get Off Timer result
*
* @return hours and minutes
*/
public int getOffTimer() {
return (data[0x0f] & 0xff);
}
/**
* For Testing assertion get Off Timer result (subtraction)
*
* @return minutes to subtract
*/
public int getOffTimer2() {
return ((data[0x10] & (byte) 0x0f)) & 0x0f;
}
}

View File

@ -0,0 +1,398 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import static org.openhab.binding.mideaac.internal.MideaACBindingConstants.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.mideaac.internal.MideaACConfiguration;
import org.openhab.binding.mideaac.internal.connection.CommandHelper;
import org.openhab.binding.mideaac.internal.connection.ConnectionManager;
import org.openhab.binding.mideaac.internal.connection.exception.MideaAuthenticationException;
import org.openhab.binding.mideaac.internal.connection.exception.MideaConnectionException;
import org.openhab.binding.mideaac.internal.connection.exception.MideaException;
import org.openhab.binding.mideaac.internal.discovery.DiscoveryHandler;
import org.openhab.binding.mideaac.internal.discovery.MideaACDiscoveryService;
import org.openhab.binding.mideaac.internal.dto.CloudDTO;
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
import org.openhab.binding.mideaac.internal.dto.CloudsDTO;
import org.openhab.binding.mideaac.internal.security.TokenKey;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link MideaACHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Justan Oldman - Last Response added
* @author Bob Eckhoff - Longer Polls and OH developer guidelines
* @author Leo Siepel - Refactored class, improved seperation of concerns
*/
@NonNullByDefault
public class MideaACHandler extends BaseThingHandler implements DiscoveryHandler {
private final Logger logger = LoggerFactory.getLogger(MideaACHandler.class);
private final CloudsDTO clouds;
private final boolean imperialUnits;
private boolean isPollRunning = false;
private final HttpClient httpClient;
private MideaACConfiguration config = new MideaACConfiguration();
private Map<String, String> properties = new HashMap<>();
// Default parameters are the same as in the MideaACConfiguration class
private ConnectionManager connectionManager = new ConnectionManager("", 6444, 4, "", "", "", "", "", "", 0, false);
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private @Nullable ScheduledFuture<?> scheduledTask = null;
private Callback callbackLambda = (response) -> {
this.updateChannels(response);
};
/**
* Initial creation of the Midea AC Handler
*
* @param thing Thing
* @param unitProvider OH core unit provider
* @param httpClient http Client
* @param clouds CloudsDTO
*/
public MideaACHandler(Thing thing, UnitProvider unitProvider, HttpClient httpClient, CloudsDTO clouds) {
super(thing);
this.thing = thing;
this.imperialUnits = unitProvider.getMeasurementSystem() instanceof ImperialUnits;
this.httpClient = httpClient;
this.clouds = clouds;
}
/**
* Returns Cloud Provider
*
* @return clouds
*/
public CloudsDTO getClouds() {
return clouds;
}
/**
* This method handles the AC Channels that can be set (non-read only)
* The command set is formed using the previous command to only
* change the item requested and leave the others the same.
* The command set which is then sent to the device via the connectionManager.
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Handling channelUID {} with command {}", channelUID.getId(), command.toString());
ConnectionManager connectionManager = this.connectionManager;
if (command instanceof RefreshType) {
try {
connectionManager.getStatus(callbackLambda);
} catch (MideaAuthenticationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (MideaConnectionException | MideaException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
return;
}
try {
Response lastresponse = connectionManager.getLastResponse();
if (channelUID.getId().equals(CHANNEL_POWER)) {
connectionManager.sendCommand(CommandHelper.handlePower(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_OPERATIONAL_MODE)) {
connectionManager.sendCommand(CommandHelper.handleOperationalMode(command, lastresponse),
callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_TARGET_TEMPERATURE)) {
connectionManager.sendCommand(CommandHelper.handleTargetTemperature(command, lastresponse),
callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_FAN_SPEED)) {
connectionManager.sendCommand(CommandHelper.handleFanSpeed(command, lastresponse, config.version),
callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_ECO_MODE)) {
connectionManager.sendCommand(CommandHelper.handleEcoMode(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_TURBO_MODE)) {
connectionManager.sendCommand(CommandHelper.handleTurboMode(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_SWING_MODE)) {
connectionManager.sendCommand(CommandHelper.handleSwingMode(command, lastresponse, config.version),
callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_SCREEN_DISPLAY)) {
connectionManager.sendCommand(CommandHelper.handleScreenDisplay(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_TEMPERATURE_UNIT)) {
connectionManager.sendCommand(CommandHelper.handleTempUnit(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_SLEEP_FUNCTION)) {
connectionManager.sendCommand(CommandHelper.handleSleepFunction(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_ON_TIMER)) {
connectionManager.sendCommand(CommandHelper.handleOnTimer(command, lastresponse), callbackLambda);
} else if (channelUID.getId().equals(CHANNEL_OFF_TIMER)) {
connectionManager.sendCommand(CommandHelper.handleOffTimer(command, lastresponse), callbackLambda);
}
} catch (MideaConnectionException | MideaAuthenticationException e) {
logger.warn("Unable to proces command: {}", e.getMessage());
}
}
/**
* Initialize is called on first pass or when a device parameter is changed
* The basic check is if the information from Discovery (or the user update)
* is valid. Because V2 devices do not require a cloud provider (or token/key)
* The first check is for the IP, port and deviceID. The second part
* checks the security configuration if required (V3 device).
*/
@Override
public void initialize() {
if (isPollRunning) {
stopScheduler();
}
config = getConfigAs(MideaACConfiguration.class);
if (!config.isValid()) {
logger.warn("Configuration invalid for {}", thing.getUID());
if (config.isDiscoveryNeeded()) {
logger.warn("Discovery needed, discovering....{}", thing.getUID());
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING,
"Configuration missing, discovery needed. Discovering...");
MideaACDiscoveryService discoveryService = new MideaACDiscoveryService();
try {
discoveryService.discoverThing(config.ipAddress, this);
return;
} catch (Exception e) {
logger.error("Discovery failure for {}: {}", thing.getUID(), e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Discovery failure. Check configuration.");
return;
}
} else {
logger.debug("MideaACHandler config of {} is invalid. Check configuration", thing.getUID());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Invalid MideaAC config. Check configuration.");
return;
}
} else {
logger.debug("Non-security Configuration valid for {}", thing.getUID());
}
if (config.version == 3 && !config.isV3ConfigValid()) {
if (config.isTokenKeyObtainable()) {
logger.info("Retrieving Token and/or Key from cloud");
CloudProviderDTO cloudProvider = CloudProviderDTO.getCloudProvider(config.cloud);
getTokenKeyCloud(cloudProvider);
return;
} else {
logger.warn("Configuration invalid for {} and no account info to retrieve from cloud", thing.getUID());
return;
}
} else {
logger.debug("Security Configuration (V3 Device) valid for {}", thing.getUID());
}
updateStatus(ThingStatus.UNKNOWN);
connectionManager = new org.openhab.binding.mideaac.internal.connection.ConnectionManager(config.ipAddress,
config.ipPort, config.timeout, config.key, config.token, config.cloud, config.email, config.password,
config.deviceId, config.version, config.promptTone);
startScheduler(2, config.pollingTime, TimeUnit.SECONDS);
}
/**
* Starts the Scheduler for the Polling
*
* @param initialDelay Seconds before first Poll
* @param delay Seconds between Polls
* @param unit Seconds
*/
private void startScheduler(long initialDelay, long delay, TimeUnit unit) {
if (scheduledTask == null) {
isPollRunning = true;
scheduledTask = scheduler.scheduleWithFixedDelay(this::pollJob, initialDelay, delay, unit);
logger.debug("Scheduled task started");
} else {
logger.debug("Scheduler already running");
}
}
private void pollJob() {
ConnectionManager connectionManager = this.connectionManager;
try {
connectionManager.getStatus(callbackLambda);
updateStatus(ThingStatus.ONLINE);
} catch (MideaAuthenticationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (MideaConnectionException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
} catch (MideaException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
private void updateChannel(String channelName, State state) {
if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
return;
}
Channel channel = thing.getChannel(channelName);
if (channel != null && isLinked(channel.getUID())) {
updateState(channel.getUID(), state);
}
}
private void updateChannels(Response response) {
updateChannel(CHANNEL_POWER, OnOffType.from(response.getPowerState()));
updateChannel(CHANNEL_APPLIANCE_ERROR, OnOffType.from(response.getApplianceError()));
updateChannel(CHANNEL_OPERATIONAL_MODE, new StringType(response.getOperationalMode().toString()));
updateChannel(CHANNEL_FAN_SPEED, new StringType(response.getFanSpeed().toString()));
updateChannel(CHANNEL_ON_TIMER, new StringType(response.getOnTimer().toChannel()));
updateChannel(CHANNEL_OFF_TIMER, new StringType(response.getOffTimer().toChannel()));
updateChannel(CHANNEL_SWING_MODE, new StringType(response.getSwingMode().toString()));
updateChannel(CHANNEL_AUXILIARY_HEAT, OnOffType.from(response.getAuxHeat()));
updateChannel(CHANNEL_ECO_MODE, OnOffType.from(response.getEcoMode()));
updateChannel(CHANNEL_TEMPERATURE_UNIT, OnOffType.from(response.getFahrenheit()));
updateChannel(CHANNEL_SLEEP_FUNCTION, OnOffType.from(response.getSleepFunction()));
updateChannel(CHANNEL_TURBO_MODE, OnOffType.from(response.getTurboMode()));
updateChannel(CHANNEL_SCREEN_DISPLAY, OnOffType.from(response.getDisplayOn()));
updateChannel(CHANNEL_HUMIDITY, new DecimalType(response.getHumidity()));
QuantityType<Temperature> targetTemperature = new QuantityType<Temperature>(response.getTargetTemperature(),
SIUnits.CELSIUS);
QuantityType<Temperature> alternateTemperature = new QuantityType<Temperature>(
response.getAlternateTargetTemperature(), SIUnits.CELSIUS);
QuantityType<Temperature> outdoorTemperature = new QuantityType<Temperature>(response.getOutdoorTemperature(),
SIUnits.CELSIUS);
QuantityType<Temperature> indoorTemperature = new QuantityType<Temperature>(response.getIndoorTemperature(),
SIUnits.CELSIUS);
if (imperialUnits) {
targetTemperature = Objects.requireNonNull(targetTemperature.toUnit(ImperialUnits.FAHRENHEIT));
alternateTemperature = Objects.requireNonNull(alternateTemperature.toUnit(ImperialUnits.FAHRENHEIT));
indoorTemperature = Objects.requireNonNull(indoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
outdoorTemperature = Objects.requireNonNull(outdoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
}
updateChannel(CHANNEL_TARGET_TEMPERATURE, targetTemperature);
updateChannel(CHANNEL_ALTERNATE_TARGET_TEMPERATURE, alternateTemperature);
updateChannel(CHANNEL_INDOOR_TEMPERATURE, indoorTemperature);
updateChannel(CHANNEL_OUTDOOR_TEMPERATURE, outdoorTemperature);
}
@Override
public void discovered(DiscoveryResult discoveryResult) {
logger.debug("Discovered {}", thing.getUID());
Map<String, Object> discoveryProps = discoveryResult.getProperties();
Configuration configuration = editConfiguration();
Object propertyDeviceId = Objects.requireNonNull(discoveryProps.get(CONFIG_DEVICEID));
configuration.put(CONFIG_DEVICEID, propertyDeviceId.toString());
Object propertyIpPort = Objects.requireNonNull(discoveryProps.get(CONFIG_IP_PORT));
configuration.put(CONFIG_IP_PORT, propertyIpPort.toString());
Object propertyVersion = Objects.requireNonNull(discoveryProps.get(CONFIG_VERSION));
BigDecimal bigDecimalVersion = new BigDecimal((String) propertyVersion);
logger.trace("Property Version in Handler {}", bigDecimalVersion.intValue());
configuration.put(CONFIG_VERSION, bigDecimalVersion.intValue());
updateConfiguration(configuration);
properties = editProperties();
Object propertySN = Objects.requireNonNull(discoveryProps.get(PROPERTY_SN));
properties.put(PROPERTY_SN, propertySN.toString());
Object propertySSID = Objects.requireNonNull(discoveryProps.get(PROPERTY_SSID));
properties.put(PROPERTY_SSID, propertySSID.toString());
Object propertyType = Objects.requireNonNull(discoveryProps.get(PROPERTY_TYPE));
properties.put(PROPERTY_TYPE, propertyType.toString());
updateProperties(properties);
initialize();
}
/**
* Gets the token and key from the Cloud
*
* @param cloudProvider Cloud Provider account
*/
public void getTokenKeyCloud(CloudProviderDTO cloudProvider) {
CloudDTO cloud = getClouds().get(config.email, config.password, cloudProvider);
if (cloud != null) {
cloud.setHttpClient(httpClient);
if (cloud.login()) {
TokenKey tk = cloud.getToken(config.deviceId);
Configuration configuration = editConfiguration();
configuration.put(CONFIG_TOKEN, tk.token());
configuration.put(CONFIG_KEY, tk.key());
updateConfiguration(configuration);
logger.trace("Token: {}", tk.token());
logger.trace("Key: {}", tk.key());
logger.info("Token and Key obtained from cloud, saving, back to initialize");
initialize();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format(
"Can't retrieve Token and Key from Cloud; email, password and/or cloud parameter error"));
logger.warn("Can't retrieve Token and Key from Cloud; email, password and/or cloud parameter error");
}
}
}
private void stopScheduler() {
ScheduledFuture<?> localScheduledTask = this.scheduledTask;
if (localScheduledTask != null && !localScheduledTask.isCancelled()) {
localScheduledTask.cancel(true);
logger.debug("Scheduled task cancelled.");
isPollRunning = false;
scheduledTask = null;
}
}
@Override
public void dispose() {
stopScheduler();
connectionManager.dispose(true);
}
}

View File

@ -0,0 +1,118 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import java.math.BigInteger;
import java.time.LocalDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.security.Security;
/**
* The {@link Packet} class for Midea AC creates the
* byte array that is sent to the device
*
* @author Jacek Dobrowolski - Initial contribution
*/
@NonNullByDefault
public class Packet {
private CommandBase command;
private byte[] packet;
private Security security;
/**
* The Packet class parameters
*
* @param command command from Command Base
* @param deviceId the device ID
* @param security the Security class
*/
public Packet(CommandBase command, String deviceId, Security security) {
this.command = command;
this.security = security;
packet = new byte[] {
// 2 bytes - StaticHeader
(byte) 0x5a, (byte) 0x5a,
// 2 bytes - mMessageType
(byte) 0x01, (byte) 0x11,
// 2 bytes - PacketLength
(byte) 0x00, (byte) 0x00,
// 2 bytes
(byte) 0x20, (byte) 0x00,
// 4 bytes - MessageId
0x00, 0x00, 0x00, 0x00,
// 8 bytes - Date&Time
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 6 bytes - mDeviceID
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 14 bytes
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
LocalDateTime now = LocalDateTime.now();
byte[] datetimeBytes = { (byte) (now.getYear() / 100), (byte) (now.getYear() % 100), (byte) now.getMonthValue(),
(byte) now.getDayOfMonth(), (byte) now.getHour(), (byte) now.getMinute(), (byte) now.getSecond(),
(byte) System.currentTimeMillis() };
System.arraycopy(datetimeBytes, 0, packet, 12, 8);
byte[] idBytes = new BigInteger(deviceId).toByteArray();
byte[] idBytesRev = Utils.reverse(idBytes);
System.arraycopy(idBytesRev, 0, packet, 20, 6);
}
/**
* Final composure of the byte array with the encrypted command
*/
public void compose() {
command.compose();
// Append the command data(48 bytes) to the packet
byte[] cmdEncrypted = security.aesEncrypt(command.getBytes());
// Ensure 48 bytes
if (cmdEncrypted.length < 48) {
byte[] paddedCmdEncrypted = new byte[48];
System.arraycopy(cmdEncrypted, 0, paddedCmdEncrypted, 0, cmdEncrypted.length);
cmdEncrypted = paddedCmdEncrypted;
}
byte[] newPacket = new byte[packet.length + cmdEncrypted.length];
System.arraycopy(packet, 0, newPacket, 0, packet.length);
System.arraycopy(cmdEncrypted, 0, newPacket, packet.length, cmdEncrypted.length);
packet = newPacket;
// Override packet length bytes with actual values
byte[] lenBytes = { (byte) (packet.length + 16), 0 };
System.arraycopy(lenBytes, 0, packet, 4, 2);
// calculate checksum data
byte[] checksumData = security.encode32Data(packet);
// Append a basic checksum data(16 bytes) to the packet
byte[] newPacketTwo = new byte[packet.length + checksumData.length];
System.arraycopy(packet, 0, newPacketTwo, 0, packet.length);
System.arraycopy(checksumData, 0, newPacketTwo, packet.length, checksumData.length);
packet = newPacketTwo;
}
/**
* Returns the packet for sending
*
* @return packet for socket writer
*/
public byte[] getBytes() {
return packet;
}
}

View File

@ -0,0 +1,389 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
import org.openhab.binding.mideaac.internal.handler.Timer.TimerData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link Response} performs the byte data stream decoding
* The original reference is
* https://github.com/georgezhao2010/midea_ac_lan/blob/06fc4b582a012bbbfd6bd5942c92034270eca0eb/custom_components/midea_ac_lan/midea/devices/ac/message.py#L418
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - Add Java Docs, minor fixes
*/
@NonNullByDefault
public class Response {
byte[] data;
// set empty to match the return from an empty byte avoid null
float empty = (float) -19.0;
private Logger logger = LoggerFactory.getLogger(Response.class);
private final int version;
String responseType;
byte bodyType;
private int getVersion() {
return version;
}
/**
* Response class Parameters
*
* @param data byte array from device
* @param version version of the device
* @param responseType response type
* @param bodyType Body type
*/
public Response(byte[] data, int version, String responseType, byte bodyType) {
this.data = data;
this.version = version;
this.bodyType = bodyType;
this.responseType = responseType;
if (logger.isDebugEnabled()) {
logger.debug("Power State: {}", getPowerState());
logger.debug("Target Temperature: {}", getTargetTemperature());
logger.debug("Operational Mode: {}", getOperationalMode());
logger.debug("Fan Speed: {}", getFanSpeed());
logger.debug("On Timer: {}", getOnTimer());
logger.debug("Off Timer: {}", getOffTimer());
logger.debug("Swing Mode: {}", getSwingMode());
logger.debug("Sleep Function: {}", getSleepFunction());
logger.debug("Turbo Mode: {}", getTurboMode());
logger.debug("Eco Mode: {}", getEcoMode());
logger.debug("Indoor Temperature: {}", getIndoorTemperature());
logger.debug("Outdoor Temperature: {}", getOutdoorTemperature());
logger.debug("LED Display: {}", getDisplayOn());
}
if (logger.isTraceEnabled()) {
logger.trace("Prompt Tone: {}", getPromptTone());
logger.trace("Appliance Error: {}", getApplianceError());
logger.trace("Auxiliary Heat: {}", getAuxHeat());
logger.trace("Fahrenheit: {}", getFahrenheit());
logger.trace("Humidity: {}", getHumidity());
logger.trace("Alternate Target Temperature {}", getAlternateTargetTemperature());
}
/**
* Trace Log Response and Body Type for V3. V2 set at "" and 0x00
* This was for future development since only 0xC0 is currently used
*/
if (version == 3) {
logger.trace("Response and Body Type: {}, {}", responseType, bodyType);
if ("notify2".equals(responseType) && bodyType == -95) { // 0xA0 = -95
logger.trace("Response Handler: XA0Message");
} else if ("notify1".equals(responseType) && bodyType == -91) { // 0xA1 = -91
logger.trace("Response Handler: XA1Message");
} else if (("notify2".equals(responseType) || "set".equals(responseType) || "query".equals(responseType))
&& (bodyType == 0xB0 || bodyType == 0xB1 || bodyType == 0xB5)) {
logger.trace("Response Handler: XBXMessage");
} else if (("set".equals(responseType) || "query".equals(responseType)) && bodyType == -64) { // 0xC0 = -64
logger.trace("Response Handler: XCOMessage");
} else if ("query".equals(responseType) && bodyType == 0xC1) {
logger.trace("Response Handler: XC1Message");
} else {
logger.trace("Response Handler: _general_");
}
}
}
/**
* Device On or Off
*
* @return power state true or false
*/
public boolean getPowerState() {
return (data[0x01] & 0x1) > 0;
}
/**
* Read only
*
* @return prompt tone true or false
*/
public boolean getPromptTone() {
return (data[0x01] & 0x40) > 0;
}
/**
* Read only
*
* @return appliance error true or false
*/
public boolean getApplianceError() {
return (data[0x01] & 0x80) > 0;
}
/**
* Setpoint for Heat Pump
*
* @return current setpoint in degrees C
*/
public float getTargetTemperature() {
return (data[0x02] & 0xf) + 16.0f + (((data[0x02] & 0x10) > 0) ? 0.5f : 0.0f);
}
/**
* Cool, Heat, Fan Only, etc. See Command Base class
*
* @return Cool, Heat, Fan Only, etc.
*/
public OperationalMode getOperationalMode() {
return OperationalMode.fromId((data[0x02] & 0xe0) >> 5);
}
/**
* Low, Medium, High, Auto etc. See Command Base class
*
* @return Low, Medium, High, Auto etc.
*/
public FanSpeed getFanSpeed() {
return FanSpeed.fromId(data[0x03] & 0x7f, getVersion());
}
/**
* Creates String representation of the On timer to the channel
*
* @return String of HH:MM
*/
public Timer getOnTimer() {
return new Timer((data[0x04] & 0x80) > 0, ((data[0x04] & (byte) 0x7c) >> 2),
((data[0x04] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf0) >> 4) & 0x0f)));
}
/**
* This is used to carry the current On Timer (last response) through
* subsequent Set commands, so it is not overwritten.
*
* @return status plus String of HH:MM
*/
public TimerData getOnTimerData() {
int hours = 0;
int minutes = 0;
Timer timer = new Timer(true, hours, minutes);
boolean status = (data[0x04] & 0x80) > 0;
hours = ((data[0x04] & (byte) 0x7c) >> 2);
minutes = ((data[0x04] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf0) >> 4) & 0x0f));
return timer.new TimerData(status, hours, minutes);
}
/**
* Creates String representation of the Off timer to the channel
*
* @return String of HH:MM
*/
public Timer getOffTimer() {
return new Timer((data[0x05] & 0x80) > 0, ((data[0x05] & (byte) 0x7c) >> 2),
((data[0x05] & 0x3) * 15 + 15 - (data[0x06] & (byte) 0xf)));
}
/**
* This is used to carry the Off timer (last response) through
* subsequent Set commands, so it is not overwritten.
*
* @return status plus String of HH:MM
*/
public TimerData getOffTimerData() {
int hours = 0;
int minutes = 0;
Timer timer = new Timer(true, hours, minutes);
boolean status = (data[0x05] & 0x80) > 0;
hours = ((data[0x05] & (byte) 0x7c) >> 2);
minutes = (data[0x05] & 0x3) * 15 + 15 - (((data[0x06] & (byte) 0xf) & 0x0f));
return timer.new TimerData(status, hours, minutes);
}
/**
* Status of the vertical and/or horzontal louver
*
* @return Vertical, Horizontal, Off, Both
*/
public SwingMode getSwingMode() {
return SwingMode.fromId(data[0x07] & 0x3f, getVersion());
}
/**
* Read only - heat mode only
*
* @return auxiliary heat active
*/
public boolean getAuxHeat() {
return (data[0x09] & (byte) 0x08) != 0;
}
/**
* Ecomode status - Fan to Auto and temp to 24 C
*
* @return Eco mode on (true) or (false)
*/
public boolean getEcoMode() {
return (data[0x09] & (byte) 0x10) != 0;
}
/**
* Sleep function status. Setpoint Temp increases in first
* two hours of sleep by 1 degree in Cool mode
*
* @return Sleep mode on (true) or (false)
*/
public boolean getSleepFunction() {
return (data[0x0a] & (byte) 0x01) != 0;
}
/**
* Turbo mode status for maximum cooling or heat
*
* @return Turbo mode on (true) or (false)
*/
public boolean getTurboMode() {
return (data[0x0a] & (byte) 0x02) != 0;
}
/**
* If true display on indoor unit is degrees F, else C
*
* @return Fahrenheit on (true) or Celsius
*/
public boolean getFahrenheit() {
return (data[0x0a] & (byte) 0x04) != 0;
}
/**
* There is some variation in how this is handled by different
* AC models. This covers at least 2 versions found.
*
* @return Indoor temperature
*/
public Float getIndoorTemperature() {
double indoorTempInteger;
double indoorTempDecimal;
if (data[0] == (byte) 0xc0) {
if (((Byte.toUnsignedInt(data[11]) - 50) / 2.0) < -19) {
return (float) -19;
}
if (((Byte.toUnsignedInt(data[11]) - 50) / 2.0) > 50) {
return (float) 50;
} else {
indoorTempInteger = (float) ((Byte.toUnsignedInt(data[11]) - 50f) / 2.0f);
}
indoorTempDecimal = (float) ((data[15] & 0x0F) * 0.1f);
if (Byte.toUnsignedInt(data[11]) > 49) {
return (float) (indoorTempInteger + indoorTempDecimal);
} else {
return (float) (indoorTempInteger - indoorTempDecimal);
}
}
/**
* Not observed or tested, but left in from original author
* This was for future development since only 0xC0 is currently used
*/
if (data[0] == (byte) 0xa0 || data[0] == (byte) 0xa1) {
if (data[0] == (byte) 0xa0) {
if ((data[1] >> 2) - 4 == 0) {
indoorTempInteger = -1;
} else {
indoorTempInteger = (data[1] >> 2) + 12;
}
if (((data[1] >> 1) & 0x01) == 1) {
indoorTempDecimal = 0.5f;
} else {
indoorTempDecimal = 0;
}
}
if (data[0] == (byte) 0xa1) {
if (((Byte.toUnsignedInt(data[13]) - 50) / 2.0f) < -19) {
return (float) -19;
}
if (((Byte.toUnsignedInt(data[13]) - 50) / 2.0f) > 50) {
return (float) 50;
} else {
indoorTempInteger = (float) (Byte.toUnsignedInt(data[13]) - 50f) / 2.0f;
}
indoorTempDecimal = (data[18] & 0x0f) * 0.1f;
if (Byte.toUnsignedInt(data[13]) > 49) {
return (float) (indoorTempInteger + indoorTempDecimal);
} else {
return (float) (indoorTempInteger - indoorTempDecimal);
}
}
}
return empty;
}
/**
* There is some variation in how this is handled by different
* AC models. This covers at least 2 versions. Some models
* do not report outside temp when the AC is off. Returns 0.0 in that case.
*
* @return Outdoor temperature
*/
public Float getOutdoorTemperature() {
if (data[12] != (byte) 0xff) {
double tempInteger = (float) (Byte.toUnsignedInt(data[12]) - 50f) / 2.0f;
double tempDecimal = ((data[15] & 0xf0) >> 4) * 0.1f;
if (Byte.toUnsignedInt(data[12]) > 49) {
return (float) (tempInteger + tempDecimal);
} else {
return (float) (tempInteger - tempDecimal);
}
}
return 0.0f;
}
/**
* Returns the Alternative Target Temperature (not used)
*
* @return Alternate target Temperature
*/
public Float getAlternateTargetTemperature() {
if ((data[13] & 0x1f) != 0) {
return (data[13] & 0x1f) + 12.0f + (((data[0x02] & 0x10) > 0) ? 0.5f : 0.0f);
} else {
return 0.0f;
}
}
/**
* Returns status of Device LEDs
*
* @return LEDs on (true) or (false)
*/
public boolean getDisplayOn() {
return (data[14] & (byte) 0x70) != (byte) 0x70;
}
/**
* Not observed with units being tested
* From reference Document
*
* @return humidity
*/
public int getHumidity() {
return (data[19] & (byte) 0x7f);
}
}

View File

@ -0,0 +1,121 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Timer} class returns the On and Off AC Timer values
* to the channels.
*
* @author Jacek Dobrowolski - Initial contribution
* @author Bob Eckhoff - Add TimeParser and TimeData classes
*/
@NonNullByDefault
public class Timer {
private boolean status;
private int hours;
private int minutes;
/**
* Timer class parameters
*
* @param status on or off
* @param hours hours
* @param minutes minutes
*/
public Timer(boolean status, int hours, int minutes) {
this.status = status;
this.hours = hours;
this.minutes = minutes;
}
/**
* Timer format for the trace log
*/
public String toString() {
if (status) {
return String.format("enabled: %s, hours: %d, minutes: %d", status, hours, minutes);
} else {
return String.format("enabled: %s", status);
}
}
/**
* Timer format of the OH channel
*
* @return conforming String
*/
public String toChannel() {
if (status) {
return String.format("%02d:%02d", hours, minutes);
} else {
return "";
}
}
/**
* This splits the On or off timer channels command back to hours and minutes
* so the AC start and stop timers can be set
*/
public class TimeParser {
/**
* Parse Time string into components
*
* @param time conforming string
* @return hours and minutes
*/
public int[] parseTime(String time) {
String[] parts = time.split(":");
int hours = Integer.parseInt(parts[0]);
int minutes = Integer.parseInt(parts[1]);
return new int[] { hours, minutes };
}
}
/**
* This allows the continuity of the current timer settings
* when new commands on other channels are set.
*/
public class TimerData {
/**
* Status if timer is on
*/
public boolean status;
/**
* Current hours
*/
public int hours;
/**
* Current minutes
*/
public int minutes;
/**
* Sets the TimerData from the response
*
* @param status true if timer is on
* @param hours hours left
* @param minutes minutes left
*/
public TimerData(boolean status, int hours, int minutes) {
this.status = status;
this.hours = hours;
this.minutes = minutes;
}
}
}

View File

@ -0,0 +1,78 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.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-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.security;
import java.util.ArrayList;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Decryption8370Result} Protocol. V3 Only
*
* @author Jacek Dobrowolski - Initial Contribution
* @author Bob Eckhoff - JavaDoc
*/
@NonNullByDefault
public class Decryption8370Result {
/**
* Set up for decryption
*
* @return responses
*/
public ArrayList<byte[]> getResponses() {
return responses;
}
/**
* Buffer
*
* @return buffer
*/
public byte[] getBuffer() {
return buffer;
}
ArrayList<byte[]> responses;
byte[] buffer;
/**
* Decryption result
*
* @param responses responses
* @param buffer buffer
*/
public Decryption8370Result(ArrayList<byte[]> responses, byte[] buffer) {
super();
this.responses = responses;
this.buffer = buffer;
}
}

View File

@ -0,0 +1,627 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.security;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.Mac;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mideaac.internal.Utils;
import org.openhab.binding.mideaac.internal.dto.CloudProviderDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonObject;
/**
* The {@link Security} class provides Security coding and decoding.
* The basic aes Protocol is used by both V2 and V3 devices.
*
* @author Jacek Dobrowolski - Initial Contribution
* @author Bob Eckhoff - JavaDoc
*/
@NonNullByDefault
public class Security {
private @Nullable SecretKeySpec encKey = null;
private Logger logger = LoggerFactory.getLogger(Security.class);
private IvParameterSpec iv = new IvParameterSpec(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
CloudProviderDTO cloudProvider;
/**
* Set Cloud Provider
*
* @param cloudProvider Name of Cloud provider
*/
public Security(CloudProviderDTO cloudProvider) {
this.cloudProvider = cloudProvider;
}
/**
* Basic Decryption for all devices using common signkey
*
* @param encryptData encrypted array
* @return decypted array
*/
public byte[] aesDecrypt(byte[] encryptData) {
byte[] plainText = {};
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec key = getEncKey();
try {
cipher.init(Cipher.DECRYPT_MODE, key);
} catch (InvalidKeyException e) {
logger.warn("AES decryption error: InvalidKeyException: {}", e.getMessage());
return new byte[0];
}
try {
plainText = cipher.doFinal(encryptData);
} catch (IllegalBlockSizeException e) {
logger.warn("AES decryption error: IllegalBlockSizeException: {}", e.getMessage());
return new byte[0];
} catch (BadPaddingException e) {
logger.warn("AES decryption error: BadPaddingException: {}", e.getMessage());
return new byte[0];
}
} catch (NoSuchAlgorithmException e) {
logger.warn("AES decryption error: NoSuchAlgorithmException: {}", e.getMessage());
return new byte[0];
} catch (NoSuchPaddingException e) {
logger.warn("AES decryption error: NoSuchPaddingException: {}", e.getMessage());
return new byte[0];
}
return plainText;
}
/**
* Basic Encryption for all devices using common signkey
*
* @param plainText Plain Text
* @return encrpted byte[] array
*/
public byte[] aesEncrypt(byte[] plainText) {
byte[] encryptData = {};
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec key = getEncKey();
try {
cipher.init(Cipher.ENCRYPT_MODE, key);
} catch (InvalidKeyException e) {
logger.warn("AES encryption error: InvalidKeyException: {}", e.getMessage());
}
try {
encryptData = cipher.doFinal(plainText);
} catch (IllegalBlockSizeException e) {
logger.warn("AES encryption error: IllegalBlockSizeException: {}", e.getMessage());
return new byte[0];
} catch (BadPaddingException e) {
logger.warn("AES encryption error: BadPaddingException: {}", e.getMessage());
return new byte[0];
}
} catch (NoSuchAlgorithmException e) {
logger.warn("AES encryption error: NoSuchAlgorithmException: {}", e.getMessage());
return new byte[0];
} catch (NoSuchPaddingException e) {
logger.warn("AES encryption error: NoSuchPaddingException: {}", e.getMessage());
return new byte[0];
}
return encryptData;
}
/**
* Secret key using MD5
*
* @return encKey
* @throws NoSuchAlgorithmException missing algorithm
*/
public @Nullable SecretKeySpec getEncKey() throws NoSuchAlgorithmException {
if (encKey == null) {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII));
byte[] key = md.digest();
SecretKeySpec skeySpec = new SecretKeySpec(key, "AES");
encKey = skeySpec;
}
return encKey;
}
/**
* Encode32 Data
*
* @param raw byte array
* @return byte[]
*/
public byte[] encode32Data(byte[] raw) {
byte[] combine = ByteBuffer
.allocate(raw.length + cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII).length).put(raw)
.put(cloudProvider.signkey().getBytes(StandardCharsets.US_ASCII)).array();
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
md.update(combine);
return md.digest();
} catch (NoSuchAlgorithmException e) {
}
return new byte[0];
}
/**
* Message types
*/
public enum MsgType {
MSGTYPE_HANDSHAKE_REQUEST(0x0),
MSGTYPE_HANDSHAKE_RESPONSE(0x1),
MSGTYPE_ENCRYPTED_RESPONSE(0x3),
MSGTYPE_ENCRYPTED_REQUEST(0x6),
MSGTYPE_TRANSPARENT(0xf);
private final int value;
private MsgType(int value) {
this.value = value;
}
/**
* Message type Id
*
* @return message type
*/
public int getId() {
return value;
}
/**
* Plain language message
*
* @param id id
* @return message type
*/
public static MsgType fromId(int id) {
for (MsgType type : values()) {
if (type.getId() == id) {
return type;
}
}
return MSGTYPE_TRANSPARENT;
}
}
private int requestCount = 0;
private int responseCount = 0;
private byte[] tcpKey = new byte[0];
/**
* Advanced Encryption for V3 devices
*
* @param data input data array
* @param msgtype message type
* @return encoded byte array
*/
public byte[] encode8370(byte[] data, MsgType msgtype) {
ByteBuffer headerBuffer = ByteBuffer.allocate(256);
ByteBuffer dataBuffer = ByteBuffer.allocate(256);
headerBuffer.put(new byte[] { (byte) 0x83, (byte) 0x70 });
int size = data.length;
int padding = 0;
logger.trace("Size: {}", size);
byte[] paddingData = null;
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
if ((size + 2) % 16 != 0) {
padding = 16 - (size + 2 & 0xf);
size += padding + 32;
logger.trace("Padding size: {}, size: {}", padding, size);
paddingData = getRandomBytes(padding);
}
}
headerBuffer.put(Utils.toBytes((short) size));
headerBuffer.put(new byte[] { 0x20, (byte) (padding << 4 | msgtype.value) });
if (requestCount > 0xfff) {
logger.trace("requestCount is too big to convert: {}, changing requestCount to 0", requestCount);
requestCount = 0;
}
dataBuffer.put(Utils.toBytes((short) requestCount));
requestCount += 1;
dataBuffer.put(data);
if (paddingData != null) {
dataBuffer.put(paddingData);
}
headerBuffer.flip();
byte[] finalHeader = new byte[headerBuffer.remaining()];
headerBuffer.get(finalHeader);
dataBuffer.flip();
byte[] finalData = new byte[dataBuffer.remaining()];
dataBuffer.get(finalData);
logger.trace("Header: {}", Utils.bytesToHex(finalHeader));
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
byte[] sign = sha256(Utils.concatenateArrays(finalHeader, finalData));
logger.trace("Sign: {}", Utils.bytesToHex(sign));
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
finalData = Utils.concatenateArrays(aesCbcEncrypt(finalData, tcpKey), sign);
}
byte[] result = Utils.concatenateArrays(finalHeader, finalData);
return result;
}
/**
* Advanced Decryption for V3 devices
*
* @param data input data array
* @return decrypted byte array
* @throws IOException IO exception
*/
public Decryption8370Result decode8370(byte[] data) throws IOException {
if (data.length < 6) {
return new Decryption8370Result(new ArrayList<byte[]>(), data);
}
byte[] header = Arrays.copyOfRange(data, 0, 6);
logger.trace("Header: {}", Utils.bytesToHex(header));
if (header[0] != (byte) 0x83 || header[1] != (byte) 0x70) {
logger.warn("Not an 8370 message");
return new Decryption8370Result(new ArrayList<byte[]>(), data);
}
ByteBuffer dataBuffer = ByteBuffer.wrap(data);
int size = dataBuffer.getShort(2) + 8;
logger.trace("Size: {}", size);
byte[] leftover = null;
if (data.length < size) {
return new Decryption8370Result(new ArrayList<byte[]>(), data);
} else if (data.length > size) {
leftover = Arrays.copyOfRange(data, size, data.length);
data = Arrays.copyOfRange(data, 0, size);
}
int padding = header[5] >> 4;
logger.trace("Padding: {}", padding);
MsgType msgtype = MsgType.fromId(header[5] & 0xf);
logger.trace("MsgType: {}", msgtype.toString());
data = Arrays.copyOfRange(data, 6, data.length);
if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
byte[] sign = Arrays.copyOfRange(data, data.length - 32, data.length);
data = Arrays.copyOfRange(data, 0, data.length - 32);
data = aesCbcDecrypt(data, tcpKey);
byte[] signLocal = sha256(Utils.concatenateArrays(header, data));
logger.trace("Sign: {}", Utils.bytesToHex(sign));
logger.trace("SignLocal: {}", Utils.bytesToHex(signLocal));
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
logger.trace("Data: {}", Utils.bytesToHex(data));
if (!Arrays.equals(sign, signLocal)) {
logger.warn("Sign does not match");
return new Decryption8370Result(new ArrayList<byte[]>(), data);
}
if (padding > 0) {
data = Arrays.copyOfRange(data, 0, data.length - padding);
}
} else {
logger.warn("MsgType: {}", msgtype.toString());
throw new IOException(msgtype.toString() + " response was received");
}
dataBuffer = ByteBuffer.wrap(data);
responseCount = dataBuffer.getShort(0);
logger.trace("responseCount: {}", responseCount);
logger.trace("requestCount: {}", requestCount);
data = Arrays.copyOfRange(data, 2, data.length);
if (leftover != null) {
Decryption8370Result r = decode8370(leftover);
ArrayList<byte[]> responses = r.getResponses();
responses.add(0, data);
return new Decryption8370Result(responses, r.buffer);
}
ArrayList<byte[]> responses = new ArrayList<byte[]>();
responses.add(data);
return new Decryption8370Result(responses, new byte[] {});
}
/**
* Retrieve TCP key
*
* @param response message
* @param key key
* @return tcp key
*/
public boolean tcpKey(byte[] response, byte key[]) {
byte[] payload = Arrays.copyOfRange(response, 0, 32);
byte[] sign = Arrays.copyOfRange(response, 32, 64);
byte[] plain = aesCbcDecrypt(payload, key);
byte[] signLocal = sha256(plain);
logger.trace("Payload: {}", Utils.bytesToHex(payload));
logger.trace("Sign: {}", Utils.bytesToHex(sign));
logger.trace("SignLocal: {}", Utils.bytesToHex(signLocal));
logger.trace("Plain: {}", Utils.bytesToHex(plain));
if (!Arrays.equals(sign, signLocal)) {
logger.warn("Sign does not match");
return false;
}
tcpKey = Utils.strxor(plain, key);
logger.trace("TcpKey: {}", Utils.bytesToHex(tcpKey));
return true;
}
private byte[] aesCbcDecrypt(byte[] encryptData, byte[] decrypt_key) {
byte[] plainText = {};
try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key = new SecretKeySpec(decrypt_key, "AES");
try {
cipher.init(Cipher.DECRYPT_MODE, key, iv);
} catch (InvalidKeyException e) {
logger.warn("AES decryption error: InvalidKeyException: {}", e.getMessage());
return new byte[0];
} catch (InvalidAlgorithmParameterException e) {
logger.warn("AES decryption error: InvalidAlgorithmParameterException: {}", e.getMessage());
return new byte[0];
}
try {
plainText = cipher.doFinal(encryptData);
} catch (IllegalBlockSizeException e) {
logger.warn("AES decryption error: IllegalBlockSizeException: {}", e.getMessage());
return new byte[0];
} catch (BadPaddingException e) {
logger.warn("AES decryption error: BadPaddingException: {}", e.getMessage());
return new byte[0];
}
} catch (NoSuchAlgorithmException e) {
logger.warn("AES decryption error: NoSuchAlgorithmException: {}", e.getMessage());
return new byte[0];
} catch (NoSuchPaddingException e) {
logger.warn("AES decryption error: NoSuchPaddingException: {}", e.getMessage());
return new byte[0];
}
return plainText;
}
private byte[] aesCbcEncrypt(byte[] plainText, byte[] encrypt_key) {
byte[] encryptData = {};
try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key = new SecretKeySpec(encrypt_key, "AES");
try {
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
} catch (InvalidKeyException e) {
logger.warn("AES encryption error: InvalidKeyException: {}", e.getMessage());
} catch (InvalidAlgorithmParameterException e) {
logger.warn("AES encryption error: InvalidAlgorithmParameterException: {}", e.getMessage());
}
try {
encryptData = cipher.doFinal(plainText);
} catch (IllegalBlockSizeException e) {
logger.warn("AES encryption error: IllegalBlockSizeException: {}", e.getMessage());
return new byte[0];
} catch (BadPaddingException e) {
logger.warn("AES encryption error: BadPaddingException: {}", e.getMessage());
return new byte[0];
}
} catch (NoSuchAlgorithmException e) {
logger.warn("AES encryption error: NoSuchAlgorithmException: {}", e.getMessage());
return new byte[0];
} catch (NoSuchPaddingException e) {
logger.warn("AES encryption error: NoSuchPaddingException: {}", e.getMessage());
return new byte[0];
}
return encryptData;
}
private byte[] sha256(byte[] bytes) {
try {
return MessageDigest.getInstance("SHA-256").digest(bytes);
} catch (NoSuchAlgorithmException e) {
logger.warn("SHA256 digest error: NoSuchAlgorithmException: {}", e.getMessage());
return new byte[0];
}
}
private byte[] getRandomBytes(int size) {
byte[] random = new byte[size];
new Random().nextBytes(random);
return random;
}
/**
* Path to cloud provider
*
* @param url url of cloud provider
* @param payload message
* @return lower case hex string
*/
public @Nullable String sign(String url, JsonObject payload) {
logger.trace("url: {}", url);
String path;
try {
path = new URI(url).getPath();
String query = Utils.getQueryString(payload);
String sign = path + query + cloudProvider.appkey();
logger.trace("sign: {}", sign);
return Utils.bytesToHexLowercase(sha256((sign).getBytes(StandardCharsets.US_ASCII)));
} catch (URISyntaxException e) {
logger.warn("Syntax error{}", e.getMessage());
}
return null;
}
/**
* Provides a randown iotKey for Cloud Providers that do not have one
*
* @param data input data array
* @param random random values
* @return sign
*/
public @Nullable String newSign(String data, String random) {
String msg = cloudProvider.iotkey();
if (!data.isEmpty()) {
msg += data;
}
msg += random;
String sign;
try {
sign = hmac(msg, cloudProvider.hmackey(), "HmacSHA256");
} catch (InvalidKeyException e) {
logger.warn("HMAC digest error: InvalidKeyException: {}", e.getMessage());
return null;
} catch (NoSuchAlgorithmException e) {
logger.warn("HMAC digest error: NoSuchAlgorithmException: {}", e.getMessage());
return null;
}
return sign; // .hexdigest();
}
/**
* Converts parameters to lower case string for communication with cloud
*
* @param data data array
* @param key key
* @param algorithm method
* @throws NoSuchAlgorithmException no Algorithm
* @throws InvalidKeyException bad key
* @return lower case string
*/
public String hmac(String data, String key, String algorithm) throws NoSuchAlgorithmException, InvalidKeyException {
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), algorithm);
Mac mac = Mac.getInstance(algorithm);
mac.init(secretKeySpec);
return Utils.bytesToHexLowercase(mac.doFinal(data.getBytes()));
}
/**
* Encrypts password for cloud API using SHA-256
*
* @param loginId Login ID
* @param password Login password
* @return string
*/
public @Nullable String encryptPassword(@Nullable String loginId, String password) {
try {
// Hash the password
MessageDigest m = MessageDigest.getInstance("SHA-256");
m.update(password.getBytes(StandardCharsets.US_ASCII));
// Create the login hash with the loginID + password hash + appKey, then hash it all AGAIN
String loginHash = loginId + Utils.bytesToHexLowercase(m.digest()) + cloudProvider.appkey();
m = MessageDigest.getInstance("SHA-256");
m.update(loginHash.getBytes(StandardCharsets.US_ASCII));
return Utils.bytesToHexLowercase(m.digest());
} catch (NoSuchAlgorithmException e) {
logger.warn("encryptPassword error: NoSuchAlgorithmException: {}", e.getMessage());
}
return null;
}
/**
* Encrypts password for cloud API using MD5
*
* @param loginId Login ID
* @param password Login password
* @return string
*/
public @Nullable String encryptIamPassword(@Nullable String loginId, String password) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(password.getBytes(StandardCharsets.US_ASCII));
MessageDigest mdSecond = MessageDigest.getInstance("MD5");
mdSecond.update(Utils.bytesToHexLowercase(md.digest()).getBytes(StandardCharsets.US_ASCII));
// if self._use_china_server:
// return mdSecond.hexdigest()
String loginHash = loginId + Utils.bytesToHexLowercase(mdSecond.digest()) + cloudProvider.appkey();
return Utils.bytesToHexLowercase(sha256(loginHash.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
logger.warn("encryptIamPasswordt error: NoSuchAlgorithmException: {}", e.getMessage());
}
return null;
}
/**
* Gets UDPID from byte data
*
* @param data data array
* @return string of lower case bytes
*/
public String getUdpId(byte[] data) {
byte[] b = sha256(data);
byte[] b1 = Arrays.copyOfRange(b, 0, 16);
byte[] b2 = Arrays.copyOfRange(b, 16, b.length);
byte[] b3 = new byte[16];
int i = 0;
while (i < b1.length) {
b3[i] = (byte) (b1[i] ^ b2[i]);
i++;
}
return Utils.bytesToHexLowercase(b3);
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.security;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link TokenKey} returns the active Token and Key.
*
* @param token For coding/decoding messages
* @param key For coding/decoding messages
*
* @author Jacek Dobrowolski - Initial Contribution
* @author Bob Eckhoff - JavaDoc and OH addons review
*/
@NonNullByDefault
public record TokenKey(String token, String key) {
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="mideaac" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>MideaAC Binding</name>
<description>This is the binding for MideaAC.</description>
<connection>local</connection>
<discovery-methods>
<discovery-method>
<service-type>mdns</service-type>
<discovery-parameters>
<discovery-parameter>
<name>mdnsServiceType</name>
<value>_mideaair._tcp.local.</value>
</discovery-parameter>
</discovery-parameters>
</discovery-method>
</discovery-methods>
</addon:addon>

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.
thing-type.config.mideaac.ac.key.label = Key
thing-type.config.mideaac.ac.key.description = Secret Key (length 64 HEX) used for secure connection authentication used with devices v3 (if not known, enter email and password for Cloud to retrieve it).
thing-type.config.mideaac.ac.password.label = Password
thing-type.config.mideaac.ac.password.description = Password for cloud account chosen in Cloud Provider.
thing-type.config.mideaac.ac.pollingTime.label = Polling time
thing-type.config.mideaac.ac.pollingTime.description = Polling time in seconds. Minimum time is 30 seconds, default 60 seconds.
thing-type.config.mideaac.ac.promptTone.label = Prompt tone
thing-type.config.mideaac.ac.promptTone.description = After sending a command device will play "ding" tone when command is received and executed.
thing-type.config.mideaac.ac.timeout.label = Timeout
thing-type.config.mideaac.ac.timeout.description = Connecting timeout. Minimum time is 2 second, maximum 10 seconds (4 seconds default).
thing-type.config.mideaac.ac.token.label = Token
thing-type.config.mideaac.ac.token.description = Secret Token (length 128 HEX) used for secure connection authentication used with devices v3 (if not known, enter email and password for Cloud to retrieve it).
thing-type.config.mideaac.ac.version.label = AC Version
thing-type.config.mideaac.ac.version.description = Version 3 requires Token, Key and Cloud provider. Version 2 doesn't.
# channel types
channel-type.mideaac.alternate-target-temperature.label = Alternate Target Temperature
channel-type.mideaac.alternate-target-temperature.description = Alternate Target Temperature (Read Only).
channel-type.mideaac.appliance-error.label = Appliance error
channel-type.mideaac.appliance-error.description = Appliance error (Read Only).
channel-type.mideaac.auxiliary-heat.label = Auxiliary heat
channel-type.mideaac.auxiliary-heat.description = Auxiliary heat (Read Only).
channel-type.mideaac.dropped-commands.label = Dropped Command Monitor
channel-type.mideaac.dropped-commands.description = Commands dropped due to TCP read() issues.
channel-type.mideaac.eco-mode.label = Eco mode
channel-type.mideaac.eco-mode.description = Eco mode, Cool only, Temp: min. 24C, Fan: AUTO.
channel-type.mideaac.fan-speed.label = Fan speed
channel-type.mideaac.fan-speed.description = Fan speed: SILENT, LOW, MEDIUM, HIGH, FULL, AUTO.
channel-type.mideaac.fan-speed.state.option.SILENT = SILENT
channel-type.mideaac.fan-speed.state.option.LOW = LOW
channel-type.mideaac.fan-speed.state.option.MEDIUM = MEDIUM
channel-type.mideaac.fan-speed.state.option.HIGH = HIGH
channel-type.mideaac.fan-speed.state.option.FULL = FULL
channel-type.mideaac.fan-speed.state.option.AUTO = AUTO
channel-type.mideaac.humidity.label = Humidity
channel-type.mideaac.humidity.description = Humidity measured in the room by the indoor unit.
channel-type.mideaac.indoor-temperature.label = Indoor temperature
channel-type.mideaac.indoor-temperature.description = Indoor temperature measured by the internal unit. Not frequent when unit is off
channel-type.mideaac.off-timer.label = OFF Timer
channel-type.mideaac.off-timer.description = OFF Timer (HH:MM) to set.
channel-type.mideaac.on-timer.label = ON Timer
channel-type.mideaac.on-timer.description = ON Timer (HH:MM) to set.
channel-type.mideaac.operational-mode.label = Operational mode
channel-type.mideaac.operational-mode.description = Operational mode: AUTO, COOL, DRY, HEAT.
channel-type.mideaac.operational-mode.state.option.AUTO = AUTO
channel-type.mideaac.operational-mode.state.option.COOL = COOL
channel-type.mideaac.operational-mode.state.option.DRY = DRY
channel-type.mideaac.operational-mode.state.option.HEAT = HEAT
channel-type.mideaac.operational-mode.state.option.FAN_ONLY = FAN ONLY
channel-type.mideaac.outdoor-temperature.label = Outdoor temperature
channel-type.mideaac.outdoor-temperature.description = Outdoor temperature from the external unit. Not frequent when unit is off
channel-type.mideaac.power.label = Power
channel-type.mideaac.power.description = Turn the AC on and off.
channel-type.mideaac.screen-display.label = Screen display
channel-type.mideaac.screen-display.description = Status of LEDs on the device. Not all models work on LAN (only IR). No confirmation possible either.
channel-type.mideaac.sleep-function.label = Sleep function
channel-type.mideaac.sleep-function.description = Sleep function ("Moon with a star" icon on IR Remote Controller).
channel-type.mideaac.swing-mode.label = Swing mode
channel-type.mideaac.swing-mode.description = Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Some V3 versions do not support
channel-type.mideaac.swing-mode.state.option.OFF = OFF
channel-type.mideaac.swing-mode.state.option.VERTICAL = VERTICAL
channel-type.mideaac.swing-mode.state.option.HORIZONTAL = HORIZONTAL
channel-type.mideaac.swing-mode.state.option.BOTH = BOTH
channel-type.mideaac.target-temperature.label = Target temperature
channel-type.mideaac.target-temperature.description = Target temperature.
channel-type.mideaac.temperature-unit.label = Temperature unit on LED Display
channel-type.mideaac.temperature-unit.description = On = Farenheit on Indoor AC unit LED display, Off = Celsius.
channel-type.mideaac.turbo-mode.label = Turbo mode
channel-type.mideaac.turbo-mode.description = Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. Only works in COOL and HEAT mode.

View File

@ -0,0 +1,258 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="mideaac"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Thing Type -->
<thing-type id="ac">
<label>Midea Air Conditioner</label>
<description>Midea Air Conditioner with USB WIFI stick. There are 2 versions: v2 - without encryption, v3 - with
encryption - Token and Key must be provided, it can be automatically obtained from Cloud.
</description>
<channels>
<channel id="power" typeId="power"/>
<channel id="target-temperature" typeId="target-temperature"/>
<channel id="operational-mode" typeId="operational-mode"/>
<channel id="fan-speed" typeId="fan-speed"/>
<channel id="swing-mode" typeId="swing-mode"/>
<channel id="eco-mode" typeId="eco-mode"/>
<channel id="turbo-mode" typeId="turbo-mode"/>
<channel id="indoor-temperature" typeId="indoor-temperature"/>
<channel id="outdoor-temperature" typeId="outdoor-temperature"/>
<channel id="sleep-function" typeId="sleep-function"/>
<channel id="temperature-unit" typeId="temperature-unit"/>
<channel id="on-timer" typeId="on-timer"/>
<channel id="off-timer" typeId="off-timer"/>
<channel id="appliance-error" typeId="appliance-error"/>
<channel id="auxiliary-heat" typeId="auxiliary-heat"/>
<channel id="humidity" typeId="humidity"/>
<channel id="screen-display" typeId="screen-display"/>
<channel id="alternate-target-temperature" typeId="alternate-target-temperature"/>
</channels>
<representation-property>ipAddress</representation-property>
<config-description>
<parameter name="ipAddress" type="text" required="true">
<context>ipAddress</context>
<label>IP Address</label>
<description>IP Address of the device.</description>
</parameter>
<parameter name="ipPort" type="decimal" required="true">
<context>ipPort</context>
<label>IP Port</label>
<description>IP port of the device.</description>
<default>6444</default>
</parameter>
<parameter name="deviceId" type="text" required="true">
<context>deviceId</context>
<label>Device ID</label>
<description>ID of the device. Leave 0 to do ID discovery.</description>
<default>0</default>
</parameter>
<parameter name="cloud" type="text" required="false">
<context>cloud</context>
<label>Cloud Provider</label>
<description>Cloud Provider name for email and password.</description>
<options>
<option value=""></option>
<option value="MSmartHome">MSmartHome</option>
<option value="Midea Air">Midea Air</option>
<option value="NetHome Plus">NetHome Plus</option>
</options>
<limitToOptions>true</limitToOptions>
<default></default>
</parameter>
<parameter name="email" type="text" required="false">
<context>email</context>
<label>Email</label>
<description>Email for cloud account chosen in Cloud Provider.</description>
</parameter>
<parameter name="password" type="text" required="false">
<context>password</context>
<label>Password</label>
<description>Password for cloud account chosen in Cloud Provider.</description>
</parameter>
<parameter name="token" type="text" required="false">
<context>token</context>
<label>Token</label>
<description>Secret Token (length 128 HEX) used for secure connection authentication used with devices v3 (if not
known, enter email and password for Cloud to retrieve it).</description>
</parameter>
<parameter name="key" type="text" required="false">
<context>key</context>
<label>Key</label>
<description>Secret Key (length 64 HEX) used for secure connection authentication used with devices v3 (if not
known, enter email and password for Cloud to retrieve it).</description>
</parameter>
<parameter name="pollingTime" type="decimal" required="true" min="30" unit="s">
<context>pollingTime</context>
<label>Polling time</label>
<description>Polling time in seconds. Minimum time is 30 seconds, default 60 seconds.</description>
<default>60</default>
</parameter>
<parameter name="timeout" type="decimal" required="true" min="2" max="10" unit="s">
<context>timeout</context>
<label>Timeout</label>
<description>Connecting timeout. Minimum time is 2 second, maximum 10 seconds (4 seconds default).</description>
<default>4</default>
</parameter>
<parameter name="promptTone" type="boolean" required="true">
<context>promptTone</context>
<label>Prompt tone</label>
<description>After sending a command device will play "ding" tone when command is received and executed.</description>
<default>false</default>
</parameter>
<parameter name="version" type="decimal" required="true">
<context>version</context>
<label>AC Version</label>
<description>Version 3 requires Token, Key and Cloud provider. Version 2 doesn't.</description>
<default>0</default>
</parameter>
</config-description>
</thing-type>
<channel-type id="power">
<item-type>Switch</item-type>
<label>Power</label>
<description>Turn the AC on and off.</description>
<category>Switch</category>
</channel-type>
<channel-type id="target-temperature">
<item-type>Number:Temperature</item-type>
<label>Target temperature</label>
<description>Target temperature.</description>
<category>Temperature</category>
<state readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="operational-mode">
<item-type>String</item-type>
<label>Operational mode</label>
<description>Operational mode: AUTO, COOL, DRY, HEAT.</description>
<state>
<options>
<option value="AUTO">AUTO</option>
<option value="COOL">COOL</option>
<option value="DRY">DRY</option>
<option value="HEAT">HEAT</option>
<option value="FAN_ONLY">FAN ONLY</option>
</options>
</state>
</channel-type>
<channel-type id="fan-speed">
<item-type>String</item-type>
<label>Fan speed</label>
<description>Fan speed: SILENT, LOW, MEDIUM, HIGH, FULL, AUTO.</description>
<state>
<options>
<option value="SILENT">SILENT</option>
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH</option>
<option value="FULL">FULL</option>
<option value="AUTO">AUTO</option>
</options>
</state>
</channel-type>
<channel-type id="swing-mode">
<item-type>String</item-type>
<label>Swing mode</label>
<description>Swing mode: OFF, VERTICAL, HORIZONTAL, BOTH. Some V3 versions do not support</description>
<state>
<options>
<option value="OFF">OFF</option>
<option value="VERTICAL">VERTICAL</option>
<option value="HORIZONTAL">HORIZONTAL</option>
<option value="BOTH">BOTH</option>
</options>
</state>
</channel-type>
<channel-type id="eco-mode">
<item-type>Switch</item-type>
<label>Eco mode</label>
<description>Eco mode, Cool only, Temp: min. 24C, Fan: AUTO. </description>
<category>Switch</category>
</channel-type>
<channel-type id="turbo-mode">
<item-type>Switch</item-type>
<label>Turbo mode</label>
<description>Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. Only works in COOL and HEAT
mode.</description>
<category>Switch</category>
</channel-type>
<channel-type id="indoor-temperature">
<item-type>Number:Temperature</item-type>
<label>Indoor temperature</label>
<description>Indoor temperature measured by the internal unit. Not frequent when unit is off</description>
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="outdoor-temperature">
<item-type>Number:Temperature</item-type>
<label>Outdoor temperature</label>
<description>Outdoor temperature from the external unit. Not frequent when unit is off</description>
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="sleep-function">
<item-type>Switch</item-type>
<label>Sleep function</label>
<description>Sleep function ("Moon with a star" icon on IR Remote Controller).</description>
<category>Switch</category>
</channel-type>
<channel-type id="temperature-unit" advanced="true">
<item-type>Switch</item-type>
<label>Temperature unit on LED Display</label>
<description>On = Farenheit on Indoor AC unit LED display, Off = Celsius.</description>
<category>Switch</category>
</channel-type>
<channel-type id="screen-display" advanced="true">
<item-type>Switch</item-type>
<label>Screen display</label>
<description>Status of LEDs on the device. Not all models work on LAN (only IR). No confirmation
possible either.</description>
<category>Switch</category>
</channel-type>
<channel-type id="appliance-error" advanced="true">
<item-type>Switch</item-type>
<label>Appliance error</label>
<description>Appliance error (Read Only).</description>
<category>Switch</category>
<state readOnly="true"/>
</channel-type>
<channel-type id="on-timer" advanced="true">
<item-type>String</item-type>
<label>ON Timer</label>
<description>ON Timer (HH:MM) to set.</description>
</channel-type>
<channel-type id="off-timer" advanced="true">
<item-type>String</item-type>
<label>OFF Timer</label>
<description>OFF Timer (HH:MM) to set.</description>
</channel-type>
<channel-type id="auxiliary-heat" advanced="true">
<item-type>Switch</item-type>
<label>Auxiliary heat</label>
<description>Auxiliary heat (Read Only).</description>
<category>Switch</category>
<state readOnly="true"/>
</channel-type>
<channel-type id="humidity" advanced="true">
<item-type>Number</item-type>
<label>Humidity</label>
<description>Humidity measured in the room by the indoor unit.</description>
<category>Humidity</category>
<state readOnly="true" pattern="%d%%"/>
</channel-type>
<channel-type id="alternate-target-temperature" advanced="true">
<item-type>Number:Temperature</item-type>
<label>Alternate Target Temperature</label>
<description>Alternate Target Temperature (Read Only).</description>
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,138 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mideaac.internal.security.TokenKey;
/**
* Testing of the {@link MideaACConfigurationTest} Configuration
*
* @author Robert Eckhoff - Initial contribution
*/
@NonNullByDefault
public class MideaACConfigurationTest {
MideaACConfiguration config = new MideaACConfiguration();
/**
* Test for valid step 1 Configs
*/
@Test
public void testValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = 6444;
config.deviceId = "1234567890";
config.version = 3;
assertTrue(config.isValid());
assertFalse(config.isDiscoveryNeeded());
}
/**
* Test for non-valid step 1 configs
*/
@Test
public void testnonValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = 0;
config.deviceId = "1234567890";
config.version = 3;
assertFalse(config.isValid());
assertTrue(config.isDiscoveryNeeded());
}
/**
* Test for valid Security Configs
*/
@Test
public void testValidSecurityConfigs() {
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
config.cloud = "NetHome Plus";
assertTrue(config.isV3ConfigValid());
}
/**
* Test for Invalid Security Configs
*/
@Test
public void testInvalidSecurityConfigs() {
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
config.cloud = "";
assertFalse(config.isV3ConfigValid());
}
/**
* Test for if key and token are obtainable from cloud
*/
@Test
public void testIfTokenAndKeyCanBeObtainedFromCloud() {
config.email = "someemail.com";
config.password = "somestrongpassword";
config.cloud = "NetHome Plus";
assertTrue(config.isTokenKeyObtainable());
}
/**
* Test for if key and token cannot be obtaines from cloud
*/
@Test
public void testIfTokenAndKeyCanNotBeObtainedFromCloud() {
config.email = "";
config.password = "somestrongpassword";
config.cloud = "NetHome Plus";
assertFalse(config.isTokenKeyObtainable());
}
/**
* Test for bad IP v.4 address
*/
@Test
public void testBadIpConfigs() {
config.ipAddress = "192.1680.1";
config.ipPort = 6444;
config.deviceId = "1234567890";
config.version = 3;
assertTrue(config.isValid());
assertTrue(config.isDiscoveryNeeded());
}
/**
* Test to return cloud provider
*/
@Test
public void testCloudProvider() {
config.cloud = "NetHome Plus";
assertEquals(config.cloud, "NetHome Plus");
}
/**
* Test to return token and key pair
*/
@Test
public void testTokenKey() {
config.token = "D24046B597DB9C8A7CA029660BC606F3FD7EBF12693E73B2EF1FFE4C3B7CA00C824E408C9F3CE972CC0D3F8250AD79D0E67B101B47AC2DD84B396E52EA05193F";
config.key = "97c65a4eed4f49fda06a1a51d5cbd61d2c9b81d103ca4ca689f352a07a16fae6";
TokenKey tokenKey = new TokenKey(config.token, config.key);
String tokenTest = tokenKey.token();
String keyTest = tokenKey.key();
assertEquals(config.token, tokenTest);
assertEquals(config.key, keyTest);
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.discovery;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HexFormat;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mideaac.internal.Utils;
/**
* The {@link MideaACDiscoveryServiceTest} tests the discovery byte arrays
* (reply string already decrypted - See SecurityTest)
* to extract the correct device information
*
* @author Robert Eckhoff - Initial contribution
*/
@NonNullByDefault
public class MideaACDiscoveryServiceTest {
byte[] data = HexFormat.of().parseHex(
"837000C8200F00005A5A0111B8007A80000000006B0925121D071814C0110800008A0000000000000000018000000000AF55C8897BEA338348DA7FC0B3EF1F1C889CD57C06462D83069558B66AF14A2D66353F52BAECA68AEB4C3948517F276F72D8A3AD4652EFA55466D58975AEB8D948842E20FBDCA6339558C848ECE09211F62B1D8BB9E5C25DBA7BF8E0CC4C77944BDFB3E16E33D88768CC4C3D0658937D0BB19369BF0317B24D3A4DE9E6A13106AFFBBE80328AEA7426CD6BA2AD8439F72B4EE2436CC634040CB976A92A53BCD5");
byte[] reply = HexFormat.of().parseHex(
"F600A8C02C19000030303030303050303030303030305131423838433239353634334243303030300B6E65745F61635F343342430000870002000000000000000000AC00ACAC00000000B88C295643BC150023082122000300000000000000000000000000000000000000000000000000000000000000000000");
String mSmartId = "", mSmartVersion = "", mSmartip = "", mSmartPort = "", mSmartSN = "", mSmartSSID = "",
mSmartType = "";
/**
* Test Version
*/
@Test
public void testVersion() {
if (Utils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
mSmartVersion = "3";
} else {
mSmartVersion = "2";
}
assertEquals("3", mSmartVersion);
}
/**
* Test Id
*/
@Test
public void testId() {
if (Utils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A")) {
data = Arrays.copyOfRange(data, 8, data.length - 16);
}
byte[] id = Utils.reverse(Arrays.copyOfRange(data, 20, 26));
BigInteger bigId = new BigInteger(1, id);
mSmartId = bigId.toString(10);
assertEquals("151732605161920", mSmartId);
}
/**
* Test IP address of device
*/
@Test
public void testIPAddress() {
mSmartip = Byte.toUnsignedInt(reply[3]) + "." + Byte.toUnsignedInt(reply[2]) + "."
+ Byte.toUnsignedInt(reply[1]) + "." + Byte.toUnsignedInt(reply[0]);
assertEquals("192.168.0.246", mSmartip);
}
/**
* Test Device Port
*/
@Test
public void testPort() {
BigInteger portId = new BigInteger(Utils.reverse(Arrays.copyOfRange(reply, 4, 8)));
mSmartPort = portId.toString();
assertEquals("6444", mSmartPort);
}
/**
* Test serial Number
*/
@Test
public void testSN() {
mSmartSN = new String(reply, 8, 40 - 8, StandardCharsets.UTF_8);
assertEquals("000000P0000000Q1B88C295643BC0000", mSmartSN);
}
/**
* Test SSID - SN converted
*/
@Test
public void testSSID() {
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
assertEquals("net_ac_43BC", mSmartSSID);
}
/**
* Test Type
*/
@Test
public void testType() {
mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
mSmartType = mSmartSSID.split("_")[1];
assertEquals("ac", mSmartType);
}
}

View File

@ -0,0 +1,241 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mideaac.internal.handler.CommandBase.FanSpeed;
import org.openhab.binding.mideaac.internal.handler.CommandBase.OperationalMode;
import org.openhab.binding.mideaac.internal.handler.CommandBase.SwingMode;
/**
* The {@link CommandSetTest} compares example SET commands with the
* expected results.
*
* @author Bob Eckhoff - Initial contribution
*/
@NonNullByDefault
public class CommandSetTest {
/**
* Power State Test
*/
@Test
public void setPowerStateTest() {
boolean status = true;
boolean status1 = true;
CommandSet commandSet = new CommandSet();
commandSet.setPowerState(status);
assertEquals(status1, commandSet.getPowerState());
}
/**
* Target temperature tests
*/
@Test
public void testsetTargetTemperature() {
CommandSet commandSet = new CommandSet();
// Device is limited to 0.5 degree C increments. Check rounding too
// Test case 1
float targetTemperature1 = 25.4f;
commandSet.setTargetTemperature(targetTemperature1);
assertEquals(25.5f, commandSet.getTargetTemperature());
// Test case 2
float targetTemperature2 = 17.8f;
commandSet.setTargetTemperature(targetTemperature2);
assertEquals(18.0f, commandSet.getTargetTemperature());
// Test case 3
float targetTemperature3 = 21.26f;
commandSet.setTargetTemperature(targetTemperature3);
assertEquals(21.5f, commandSet.getTargetTemperature());
// Test case 4
float degreefahr = 72.0f;
float targetTemperature4 = ((degreefahr + 40.0f) * (5.0f / 9.0f)) - 40.0f;
commandSet.setTargetTemperature(targetTemperature4);
assertEquals(22.0f, commandSet.getTargetTemperature());
// Test case 5
float degreefahr2 = 66.0f;
float targetTemperature5 = ((degreefahr2 + 40.0f) * (5.0f / 9.0f)) - 40.0f;
commandSet.setTargetTemperature(targetTemperature5);
assertEquals(19.0f, commandSet.getTargetTemperature());
}
/**
* Swing Mode test
*/
@Test
public void testHandleSwingMode() {
SwingMode mode = SwingMode.VERTICAL3;
int mode1 = 60;
CommandSet commandSet = new CommandSet();
commandSet.setSwingMode(mode);
assertEquals(mode1, commandSet.getSwingMode());
}
/**
* Fan Speed test
*/
@Test
public void testHandleFanSpeedCommand() {
FanSpeed speed = FanSpeed.AUTO3;
int speed1 = 102;
CommandSet commandSet = new CommandSet();
commandSet.setFanSpeed(speed);
assertEquals(speed1, commandSet.getFanSpeed());
}
/**
* Operational mode test
*/
@Test
public void testHandleOperationalMode() {
OperationalMode mode = OperationalMode.COOL;
int mode1 = 64;
CommandSet commandSet = new CommandSet();
commandSet.setOperationalMode(mode);
assertEquals(mode1, commandSet.getOperationalMode());
}
/**
* On timer test
*/
@Test
public void testHandleOnTimer() {
CommandSet commandSet = new CommandSet();
boolean on = true;
int hours = 3;
int minutes = 59;
int bits = (int) Math.floor(minutes / 15);
int time = 143;
int remainder = (15 - (int) (minutes - bits * 15));
commandSet.setOnTimer(on, hours, minutes);
assertEquals(time, commandSet.getOnTimer());
assertEquals(remainder, commandSet.getOnTimer2());
}
/**
* On timer test3
*/
@Test
public void testHandleOnTimer2() {
CommandSet commandSet = new CommandSet();
boolean on = false;
int hours = 3;
int minutes = 60;
int time = 127;
int remainder = 0;
commandSet.setOnTimer(on, hours, minutes);
assertEquals(time, commandSet.getOnTimer());
assertEquals(remainder, commandSet.getOnTimer2());
}
/**
* On timer test3
*/
@Test
public void testHandleOnTimer3() {
CommandSet commandSet = new CommandSet();
boolean on = true;
int hours = 0;
int minutes = 14;
int time = 128;
int remainder = (15 - minutes);
commandSet.setOnTimer(on, hours, minutes);
assertEquals(time, commandSet.getOnTimer());
assertEquals(remainder, commandSet.getOnTimer2());
}
/**
* Off timer test
*/
@Test
public void testHandleOffTimer() {
CommandSet commandSet = new CommandSet();
boolean on = true;
int hours = 3;
int minutes = 59;
int bits = (int) Math.floor(minutes / 15);
int time = 143;
int remainder = (15 - (int) (minutes - bits * 15));
commandSet.setOffTimer(on, hours, minutes);
assertEquals(time, commandSet.getOffTimer());
assertEquals(remainder, commandSet.getOffTimer2());
}
/**
* Off timer test2
*/
@Test
public void testHandleOffTimer2() {
CommandSet commandSet = new CommandSet();
boolean on = false;
int hours = 3;
int minutes = 60;
int time = 127;
int remainder = 0;
commandSet.setOffTimer(on, hours, minutes);
assertEquals(time, commandSet.getOffTimer());
assertEquals(remainder, commandSet.getOffTimer2());
}
/**
* Off timer test3
*/
@Test
public void testHandleOffTimer3() {
CommandSet commandSet = new CommandSet();
boolean on = true;
int hours = 0;
int minutes = 14;
int time = 128;
int remainder = (15 - minutes);
commandSet.setOffTimer(on, hours, minutes);
assertEquals(time, commandSet.getOffTimer());
assertEquals(remainder, commandSet.getOffTimer2());
}
/**
* Test screen display change command
*/
@Test
public void testSetScreenDisplayOff() {
CommandSet commandSet = new CommandSet();
commandSet.setScreenDisplay(true);
// Check the modified bytes
assertEquals((byte) 0x20, commandSet.data[0x01]);
assertEquals((byte) 0x03, commandSet.data[0x09]);
assertEquals((byte) 0x41, commandSet.data[0x0a]);
assertEquals((byte) 0x02, commandSet.data[0x0b] & 0x02); // Check if bit 1 is set
assertEquals((byte) 0x00, commandSet.data[0x0b] & 0x80); // Check if bit 7 is cleared
assertEquals((byte) 0x00, commandSet.data[0x0c]);
assertEquals((byte) 0xff, commandSet.data[0x0d]);
assertEquals((byte) 0x02, commandSet.data[0x0e]);
assertEquals((byte) 0x00, commandSet.data[0x0f]);
assertEquals((byte) 0x02, commandSet.data[0x10]);
assertEquals((byte) 0x00, commandSet.data[0x11]);
assertEquals((byte) 0x00, commandSet.data[0x12]);
assertEquals((byte) 0x00, commandSet.data[0x13]);
assertEquals((byte) 0x00, commandSet.data[0x14]);
// Check the length of the data array
assertEquals(31, commandSet.data.length);
}
}

View File

@ -0,0 +1,197 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HexFormat;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* The {@link ResponseTest} extracts the AC device response and
* compares them to the expected result.
*
* @author Bob Eckhoff - Initial contribution
*/
@NonNullByDefault
public class ResponseTest {
@org.jupnp.registry.event.Before
byte[] data = HexFormat.of().parseHex("C00042668387123C00000460FF0C7000000000320000F9ECDB");
private int version = 3;
String responseType = "query";
byte bodyType = (byte) 0xC0;
Response response = new Response(data, version, responseType, bodyType);
/**
* Power State Test
*/
@Test
public void testGetPowerState() {
boolean actualPowerState = response.getPowerState();
assertEquals(false, actualPowerState);
}
/**
* Prompt Tone Test
*/
@Test
public void testGetPromptTone() {
assertEquals(false, response.getPromptTone());
}
/**
* Appliance Error Test
*/
@Test
public void testGetApplianceError() {
assertEquals(false, response.getApplianceError());
}
/**
* Target Temperature Test
*/
@Test
public void testGetTargetTemperature() {
assertEquals(18, response.getTargetTemperature());
}
/**
* Operational Mode Test
*/
@Test
public void testGetOperationalMode() {
CommandBase.OperationalMode mode = response.getOperationalMode();
assertEquals(CommandBase.OperationalMode.COOL, mode);
}
/**
* Fan Speed Test
*/
@Test
public void testGetFanSpeed() {
CommandBase.FanSpeed fanSpeed = response.getFanSpeed();
assertEquals(CommandBase.FanSpeed.AUTO3, fanSpeed);
}
/**
* On timer Test
*/
@Test
public void testGetOnTimer() {
Timer status = response.getOnTimer();
String expectedString = "enabled: true, hours: 0, minutes: 59";
assertEquals(expectedString, status.toString());
}
/**
* Off timer Test
*/
@Test
public void testGetOffTimer() {
Timer status = response.getOffTimer();
String expectedString = "enabled: true, hours: 1, minutes: 58";
assertEquals(expectedString, status.toString());
}
/**
* Swing mode Test
*/
@Test
public void testGetSwingMode() {
CommandBase.SwingMode swing = response.getSwingMode();
assertEquals(CommandBase.SwingMode.VERTICAL3, swing);
}
/**
* Auxiliary Heat Status Test
*/
@Test
public void testGetAuxHeat() {
assertEquals(false, response.getAuxHeat());
}
/**
* Eco Mode Test
*/
@Test
public void testGetEcoMode() {
assertEquals(false, response.getEcoMode());
}
/**
* Sleep Function Test
*/
@Test
public void testGetSleepFunction() {
assertEquals(false, response.getSleepFunction());
}
/**
* Turbo Mode Test
*/
@Test
public void testGetTurboMode() {
assertEquals(false, response.getTurboMode());
}
/**
* Fahrenheit Display Test
*/
@Test
public void testGetFahrenheit() {
assertEquals(true, response.getFahrenheit());
}
/**
* Indoor Temperature Test
*/
@Test
public void testGetIndoorTemperature() {
assertEquals(23, response.getIndoorTemperature());
}
/**
* Outdoor Temperature Test
*/
@Test
public void testGetOutdoorTemperature() {
assertEquals(0, response.getOutdoorTemperature());
}
/**
* LED Display Test
*/
@Test
public void testDisplayOn() {
assertEquals(false, response.getDisplayOn());
}
/**
* Humidity Test
*/
@Test
public void testGetHumidity() {
assertEquals(50, response.getHumidity());
}
/**
* Alternate Target temperature Test
*/
@Test
public void testAlternateTargetTemperature() {
assertEquals(24, response.getAlternateTargetTemperature());
}
}

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>