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>
This commit is contained in:
Bob Eckhoff 2024-11-06 11:08:59 -05:00
parent 925fc2860e
commit a2159bed2a
13 changed files with 1356 additions and 1294 deletions

View File

@ -64,7 +64,6 @@ Following channels are available:
| off-timer | String | Sets the future time to turn off the AC. | | Yes |
| screen-display | Switch | If device supports across LAN, turns off the LED display. | | Yes |
| humidity | Number | If device supports, the indoor humidity. | Yes | Yes |
| dropped-commands | Number | Quality of WiFi connections - For debugging only. | Yes | Yes |
| appliance-error | Switch | If device supports, appliance error | Yes | Yes |
| auxiliary-heat | Switch | If device supports, auxiliary heat | Yes | Yes |
| alternate-target-temperature | Number:Temperature | Alternate Target Temperature - not currently used | Yes | Yes |

View File

@ -25,7 +25,7 @@ public class MideaACConfiguration {
public String ipAddress = "";
public String ipPort = "6444";
public int ipPort = 6444;
public String deviceId = "";
@ -45,7 +45,7 @@ public class MideaACConfiguration {
public boolean promptTone;
public String version = "";
public int version = 0;
/**
* Check during initialization that the params are valid
@ -53,7 +53,7 @@ public class MideaACConfiguration {
* @return true(valid), false (not valid)
*/
public boolean isValid() {
return !("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort.isBlank() || ipAddress.isBlank());
return !("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank());
}
/**
@ -62,7 +62,26 @@ public class MideaACConfiguration {
* @return true(discovery needed), false (not needed)
*/
public boolean isDiscoveryNeeded() {
return ("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort.isBlank() || ipAddress.isBlank()
return ("0".equalsIgnoreCase(deviceId) || deviceId.isBlank() || ipPort <= 0 || ipAddress.isBlank()
|| !Utils.validateIP(ipAddress));
}
/**
* 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

@ -39,9 +39,9 @@ import org.osgi.service.component.annotations.Reference;
@Component(configurationPid = "binding.mideaac", service = ThingHandlerFactory.class)
public class MideaACHandlerFactory extends BaseThingHandlerFactory {
private UnitProvider unitProvider;
private final HttpClientFactory httpClientFactory;
private final CloudsDTO clouds;
private final UnitProvider unitProvider;
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
@ -56,8 +56,8 @@ public class MideaACHandlerFactory extends BaseThingHandlerFactory {
*/
@Activate
public MideaACHandlerFactory(@Reference UnitProvider unitProvider, @Reference HttpClientFactory httpClientFactory) {
this.unitProvider = unitProvider;
this.httpClientFactory = httpClientFactory;
this.unitProvider = unitProvider;
clouds = new CloudsDTO();
}

View File

@ -0,0 +1,438 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.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,555 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mideaac.internal.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;
/**
* True allows one short retry after connection problem
*/
private boolean retry = true;
/**
* Suppresses the connection message if was online before
*/
private boolean connectionMessage = 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 boolean deviceIsConnected;
private int droppedCommands = 0;
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();
}
/**
* Reset dropped commands from initialization in MideaACHandler
* Channel created for easy observation
* Dropped commands when no bytes to read after two tries or other
* byte reading problem. Device not responding.
*/
public void resetDroppedCommands() {
droppedCommands = 0;
}
/**
* Resets Dropped command
*
* @return dropped commands
*/
public int getDroppedCommands() {
return droppedCommands = 0;
}
/**
* 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);
// Open socket
try {
socket = new Socket();
socket.setSoTimeout(timeout * 1000);
socket.connect(new InetSocketAddress(ipAddress, ipPort), timeout * 1000);
} catch (IOException e) {
logger.debug("IOException connecting to {}: {}", ipAddress, e.getMessage());
deviceIsConnected = false;
if (retry) {
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
logger.debug("An interupted error (pause) has occured {}", ex.getMessage());
}
connect();
}
throw new MideaConnectionException(e);
}
// 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 (!deviceIsConnected || !connectionMessage) {
logger.info("Connected to IP {}", ipAddress);
resetConnectionMessage();
}
logger.debug("Connected to IP {}", ipAddress);
deviceIsConnected = true;
resetRetry();
if (version == 3) {
logger.debug("Device {} require authentication, going to authenticate", ipAddress);
try {
authenticate();
} catch (MideaAuthenticationException | MideaConnectionException e) {
deviceIsConnected = false;
throw e;
}
}
// requestStatus(getDoPoll());
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 {} 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 {} writing handshake_request: {}", ipAddress, Utils.bytesToHex(request));
write(request);
byte[] response = read();
if (response != null && response.length > 0) {
logger.trace("Device {} 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());
}
// requestStatus(getDoPoll()); need to handle
} 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("Invalid Key. Correct Key in configuration.");
}
}
} 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) or the set command can be retried
*
* @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. Command will be dropped
}
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) {
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 > 0) {
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 {
byte[] data = security.aesDecrypt(Arrays.copyOfRange(responseBytes, 40, responseBytes.length - 16));
// The response data from the appliance includes a packet header which we don't want
logger.trace("V2 Bytes decoded with header: length: {}, data: {}", data.length,
Utils.bytesToHex(data));
if (data.length > 0) {
data = Arrays.copyOfRange(data, 10, data.length);
logger.trace("V2 Bytes decoded and stripped without header: length: {}, data: {}", data.length,
Utils.bytesToHex(data));
lastResponse = new Response(data, version, "", (byte) 0x00);
logger.debug("V2 data length is {} version is {} Ip Address is {}", data.length, version,
ipAddress);
if (callback != null) {
callback.updateChannels(lastResponse);
}
} else {
droppedCommands = droppedCommands + 1;
logger.debug("Problem with reading V2 response, skipping command {} dropped count{}", command,
droppedCommands);
}
}
return;
} else {
droppedCommands = droppedCommands + 1;
logger.debug("Problem with reading response, skipping command {} dropped count{}", command,
droppedCommands);
return;
}
} catch (SocketException e) {
logger.debug("SocketException writing to {}: {}", ipAddress, e.getMessage());
droppedCommands = droppedCommands + 1;
logger.debug("Socket exception, skipping command {} dropped count{}", command, droppedCommands);
throw new MideaConnectionException(e);
} catch (IOException e) {
logger.debug(" Send IOException writing to {}: {}", ipAddress, e.getMessage());
droppedCommands = droppedCommands + 1;
logger.debug("Socket exception, skipping command {} dropped count{}", command, droppedCommands);
throw new MideaConnectionException(e);
}
}
/**
* Closes all elements of the connection before starting a new one
*/
public synchronized void disconnect() {
// Make sure writer, inputStream and socket are closed before each command is started
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
*/
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: {} Device 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);
}
}
/**
* Reset Retry controls the short 5 second delay
* Before starting 30 second delays. (More severe Wifi issue)
* It is reset after a successful connection
*/
private void resetRetry() {
retry = true;
}
/**
* Limit logging of INFO connection messages to
* only when the device was Offline in its prior
* state
*/
private void resetConnectionMessage() {
connectionMessage = true;
}
/**
* Disconnects from the device
*
* @param force
*/
public void dispose(boolean force) {
disconnect();
}
}

View File

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

View File

@ -17,6 +17,7 @@ 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
@ -28,18 +29,18 @@ import org.openhab.binding.mideaac.internal.Utils;
public class Packet {
private CommandBase command;
private byte[] packet;
private MideaACHandler mideaACHandler;
private Security security;
/**
* The Packet class parameters
*
* @param command command from Command Base
* @param deviceId the device ID
* @param mideaACHandler the MideaACHandler class
* @param security the Security class
*/
public Packet(CommandBase command, String deviceId, MideaACHandler mideaACHandler) {
public Packet(CommandBase command, String deviceId, Security security) {
this.command = command;
this.mideaACHandler = mideaACHandler;
this.security = security;
packet = new byte[] {
// 2 bytes - StaticHeader
@ -78,7 +79,7 @@ public class Packet {
command.compose();
// Append the command data(48 bytes) to the packet
byte[] cmdEncrypted = mideaACHandler.getSecurity().aesEncrypt(command.getBytes());
byte[] cmdEncrypted = security.aesEncrypt(command.getBytes());
// Ensure 48 bytes
if (cmdEncrypted.length < 48) {
@ -97,7 +98,7 @@ public class Packet {
System.arraycopy(lenBytes, 0, packet, 4, 2);
// calculate checksum data
byte[] checksumData = mideaACHandler.getSecurity().encode32Data(packet);
byte[] checksumData = security.encode32Data(packet);
// Append a basic checksum data(16 bytes) to the packet
byte[] newPacketTwo = new byte[packet.length + checksumData.length];

View File

@ -31,7 +31,6 @@
<channel id="humidity" typeId="humidity"/>
<channel id="screen-display" typeId="screen-display"/>
<channel id="alternate-target-temperature" typeId="alternate-target-temperature"/>
<channel id="dropped-commands" typeId="dropped-commands"/>
</channels>
<representation-property>ipAddress</representation-property>
@ -256,11 +255,4 @@
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="dropped-commands" advanced="true">
<item-type>Number</item-type>
<label>Dropped Command Monitor</label>
<description>Commands dropped due to TCP read() issues.</description>
<category>Number</category>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -36,7 +36,7 @@ public class MideaACConfigurationTest {
@Test
public void testValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = "6444";
config.ipPort = 6444;
config.deviceId = "1234567890";
assertTrue(config.isValid());
assertFalse(config.isDiscoveryNeeded());
@ -48,7 +48,7 @@ public class MideaACConfigurationTest {
@Test
public void testnonValidConfigs() {
config.ipAddress = "192.168.0.1";
config.ipPort = "";
config.ipPort = 0;
config.deviceId = "1234567890";
assertFalse(config.isValid());
assertTrue(config.isDiscoveryNeeded());
@ -60,7 +60,7 @@ public class MideaACConfigurationTest {
@Test
public void testBadIpConfigs() {
config.ipAddress = "192.1680.1";
config.ipPort = "6444";
config.ipPort = 6444;
config.deviceId = "1234567890";
assertTrue(config.isValid());
assertTrue(config.isDiscoveryNeeded());