diff --git a/CODEOWNERS b/CODEOWNERS
index 215180a43ae49..7ca2d5d362f02 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -232,6 +232,7 @@
/bundles/org.openhab.binding.meteostick/ @cdjackson
/bundles/org.openhab.binding.metofficedatahub/ @dag81
/bundles/org.openhab.binding.mffan/ @mark-brooks-180
+/bundles/org.openhab.binding.mideaac/ @apella12
/bundles/org.openhab.binding.miele/ @kgoderis @jlaur
/bundles/org.openhab.binding.mielecloud/ @BjoernLange
/bundles/org.openhab.binding.mihome/ @pboos
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index e67c844d38a74..2aa407838123e 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1146,6 +1146,11 @@
org.openhab.binding.mffan
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mideaac
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.miele
diff --git a/bundles/org.openhab.binding.mideaac/NOTICE b/bundles/org.openhab.binding.mideaac/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/NOTICE
@@ -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
diff --git a/bundles/org.openhab.binding.mideaac/README.md b/bundles/org.openhab.binding.mideaac/README.md
new file mode 100644
index 0000000000000..69d356672db87
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/README.md
@@ -0,0 +1,124 @@
+# 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.
+
+| Cloud Provider | Comment | Options | Default |
+|----------------------------------------------|------------------------------------------|--------------|---------|
+| 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 | Yes |
+| SmartHome/MSmartHome (com.midea.ai.overseas) | Note: Reports that this cloud is offline | MSmartHome | |
+
+Note: The Air Conditioner must already be set-up on your WiFi network with a fixed IP Address to be discovered.
+
+## Supported Things
+
+This binding supports one Thing type `ac`.
+
+## Discovery
+
+Once the Air Conditioner is on your network activating the Inbox scan with this binding will send an IP broadcast message.
+Every responding unit gets added to the Inbox. When adding each thing, the required parameters will be populated with either
+discovered values or the default settings. For a V.3 device, in the unlikely event the defaults did not get the token and key,
+enter your cloud provider, email and password.
+
+## Binding Configuration
+
+No binding configuration is required.
+
+## Thing Configuration
+
+| Parameter | Required ? | Comment | Default | Advanced |
+|---------------|-------------|-------------------------------------------------------------------|---------------------------|----------|
+| ipAddress | Yes | IP Address of the device. | | |
+| ipPort | Yes | IP port of the device | 6444 | Yes |
+| deviceId | Yes | ID of the device. Leave 0 to do ID discovery. | 0 | Yes |
+| cloud | Yes for V.3 | Your Cloud Provider name (or default). | NetHome Plus | |
+| email | No | Email for your cloud account (or default). | nethome+us@mailinator.com | |
+| password | No | Password for your cloud account (or default). | password1 | |
+| token | Yes for V.3 | Secret Token - Retrieved from cloud | | Yes |
+| key | Yes for V.3 | Secret Key - Retrieved from cloud | | Yes |
+| pollingTime | Yes | Frequency to Poll AC Status in seconds. Minimum is 30. | 60 seconds | |
+| keyTokenUpdate| No | Frequency to update key-token from cloud in hours. Minimum is 24 | 0 hours (disabled) | Yes |
+| energyPoll | Yes | Frequency to poll energy data (if supported) | 0 minutes (disabled) | |
+| timeout | Yes | Socket connection timeout in seconds. Min. is 2, max. 10. | 4 seconds | Yes |
+| promptTone | Yes | "Ding" tone when command is received and executed. | false | |
+| version | Yes | Version 3 has token, key and cloud requirements. | 3 | Yes |
+| energyDecode | Yes | Binary Coded Decimal (BCD) = true. Big-endian = false. | true | Yes |
+
+## Channels
+
+Following channels are available:
+Note: After discovery, the thing properties dropdown on the Thing UI page will show what channels and modes your device supports.
+
+| Channel | Type | Description | Read only | Advanced |
+|----------------------|--------------------|--------------------------------------------------------------------------------------------------------|-----------|----------|
+| power | Switch | Turn the AC on or off. | | |
+| target-temperature | Number:Temperature | Target temperature. | | |
+| operational-mode | String | Operational modes: OFF, AUTO, COOL, DRY, HEAT, FAN ONLY | | |
+| fan-speed | String | Fan speeds: 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 |
+| maximum-humidity | Number | If device supports, allows setting the maximum humidity in DRY mode | | Yes
+| humidity | Number | If device supports, the indoor room humidity. | Yes | Yes |
+| energy-consumption | Number | If device supports, cumulative Kilowatt-Hours usage | Yes | Yes |
+| current-draw | Number | If device supports, instantaneous amperage usage | Yes | Yes |
+| power-consumption | Number | If device supports, instantaneous wattage reading | Yes | Yes |
+| appliance-error | Switch | If device supports, appliance error notification | Yes | Yes |
+| filter-status | Switch | If device supports, notification that filter needs cleaning | Yes | Yes |
+| auxiliary-heat | Switch | If device supports, auxiliary heat (On or Off) | Yes | Yes |
+
+## Examples
+
+### `demo.things` Examples
+
+```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, keyTokenUpdate = 0, energyPoll = 0, timeout=4, promptTone="false", version="3", energyDecode="true"]
+```
+
+Minimal IP Address Option to use the built-in defaults.
+
+```java
+Thing mideaac:ac:mideaac "myAC" @ "myRoom" [ ipAddress="192.168.0.200"]
+```
+
+### `demo.items` Examples
+
+```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" }
+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` Examples
+
+```java
+sitemap midea label="Midea AC"{
+ 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=62.0 maxValue=86 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"
+ }
+}
+```
diff --git a/bundles/org.openhab.binding.mideaac/pom.xml b/bundles/org.openhab.binding.mideaac/pom.xml
new file mode 100644
index 0000000000000..7e353bfbd94b5
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 5.1.0-SNAPSHOT
+
+
+ org.openhab.binding.mideaac
+
+ openHAB Add-ons :: Bundles :: MideaAC Binding
+
+
diff --git a/bundles/org.openhab.binding.mideaac/src/main/feature/feature.xml b/bundles/org.openhab.binding.mideaac/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..e7f89f4dcb847
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.mideaac/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACBindingConstants.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACBindingConstants.java
new file mode 100644
index 0000000000000..95147b85a7cfc
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACBindingConstants.java
@@ -0,0 +1,152 @@
+/*
+ * 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 and capability properties
+ */
+@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 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_MAXIMUM_HUMIDITY = "maximum-humidity";
+ public static final String CHANNEL_HUMIDITY = "humidity";
+ public static final String CHANNEL_SCREEN_DISPLAY = "screen-display";
+ public static final String CHANNEL_FILTER_STATUS = "filter-status";
+ public static final String CHANNEL_ENERGY_CONSUMPTION = "energy-consumption";
+ public static final String CHANNEL_CURRENT_DRAW = "current-draw";
+ public static final String CHANNEL_POWER_CONSUMPTION = "power-consumption";
+
+ public static final Unit 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_KEY_TOKEN_UPDATE = "keyTokenUpdate";
+ public static final String CONFIG_ENERGY_POLL = "energyPoll";
+ 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 CONFIG_ENERGY_DECODE = "energyDecode";
+
+ // Properties from LAN Discovery
+ public static final String PROPERTY_SN = "sn";
+ public static final String PROPERTY_SSID = "ssid";
+ public static final String PROPERTY_TYPE = "type";
+
+ // Capabilities properties discoverable
+ public static final String PROPERTY_ANION = "anion";
+ public static final String PROPERTY_AUX_ELECTRIC_HEAT = "auxElectricHeat";
+ public static final String PROPERTY_BREEZE_AWAY = "breezeAway";
+ public static final String PROPERTY_BREEZE_CONTROL = "breezeControl";
+ public static final String PROPERTY_BREEZELESS = "breezeless";
+ public static final String PROPERTY_BUZZER = "buzzer";
+ public static final String PROPERTY_DISPLAY_CONTROL = "displayControl";
+ public static final String PROPERTY_ENERGY_STATS = "energyStats";
+ public static final String PROPERTY_ENERGY_SETTING = "energySetting";
+ public static final String PROPERTY_ENERGY_BCD = "energyBCD";
+ public static final String PROPERTY_FAHRENHEIT = "fahrenheit";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_LOW = "fanLow";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_MEDIUM = "fanMedium";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_HIGH = "fanHigh";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_AUTO = "fanAuto";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_SILENT = "fanSilent";
+ public static final String PROPERTY_FAN_SPEED_CONTROL_CUSTOM = "fanCustom";
+ public static final String PROPERTY_FILTER_REMIND_NOTICE = "filterNotice";
+ public static final String PROPERTY_FILTER_REMIND_CLEAN = "filterClean";
+ public static final String PROPERTY_HUMIDITY_AUTO_SET = "humidityAutoSet";
+ public static final String PROPERTY_HUMIDITY_MANUAL_SET = "humidityManualSet";
+ public static final String PROPERTY_MODES_AUTO = "modeAuto";
+ public static final String PROPERTY_MODES_AUX = "modeAux";
+ public static final String PROPERTY_MODES_AUX_HEAT = "modeAuxHeat";
+ public static final String PROPERTY_MODES_COOL = "modeCool";
+ public static final String PROPERTY_MODES_DRY = "modeDry";
+ public static final String PROPERTY_MODES_FAN_ONLY = "modeFanOnly";
+ public static final String PROPERTY_MODES_HEAT = "modeHeat";
+ public static final String PROPERTY_PRESET_ECO = "ecoCool";
+ public static final String PROPERTY_PRESET_FREEZE_PROTECTION = "freezeProtection";
+ public static final String PROPERTY_PRESET_IECO = "ieco";
+ public static final String PROPERTY_PRESET_TURBO_COOL = "turboCool";
+ public static final String PROPERTY_PRESET_TURBO_HEAT = "turboHeat";
+ public static final String PROPERTY_RATE_SELECT = "rateSelect5Level";
+ public static final String PROPERTY_SELF_CLEAN = "selfClean";
+ public static final String PROPERTY_SMART_EYE = "smartEye";
+ public static final String PROPERTY_SWING_LR_ANGLE = "swingHorizontalAngle";
+ public static final String PROPERTY_SWING_UD_ANGLE = "swingVerticalAngle";
+ public static final String PROPERTY_SWING_MODES_HORIZONTAL = "swingHorizontal";
+ public static final String PROPERTY_SWING_MODES_VERTICAL = "swingVertical";
+ public static final String PROPERTY_TEMPERATURES_MIN_DEFAULT = "minTargetTemperature";
+ public static final String PROPERTY_TEMPERATURES_MAX_DEFAULT = "maxTargetTemperature";
+ public static final String PROPERTY_TEMPERATURES_COOL_MIN = "coolMinTemperature";
+ public static final String PROPERTY_TEMPERATURES_COOL_MAX = "coolMaxTemperature";
+ public static final String PROPERTY_TEMPERATURES_AUTO_MIN = "autoMinTemperature";
+ public static final String PROPERTY_TEMPERATURES_AUTO_MAX = "autoMaxTemperature";
+ public static final String PROPERTY_TEMPERATURES_HEAT_MIN = "heatMinTemperature";
+ public static final String PROPERTY_TEMPERATURES_HEAT_MAX = "heatMaxTemperature";
+ public static final String PROPERTY_WIND_OFF_ME = "windOffMe";
+ public static final String PROPERTY_WIND_ON_ME = "windOnMe";
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACConfiguration.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACConfiguration.java
new file mode 100644
index 0000000000000..ada8cb7381bad
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACConfiguration.java
@@ -0,0 +1,147 @@
+/*
+ * 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.regex.Pattern;
+
+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, modified checks and defaults
+ */
+@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 = "nethome+us@mailinator.com";
+
+ /**
+ * Cloud Account Password
+ */
+ public String password = "password1";
+
+ /**
+ * Cloud Provider
+ */
+ public String cloud = "NetHome Plus";
+
+ /**
+ * Token 128 hex length
+ */
+ public String token = "";
+
+ /**
+ * Key 64 hex length
+ */
+ public String key = "";
+
+ /**
+ * Poll Frequency - seconds
+ */
+ public int pollingTime = 60;
+
+ /**
+ * Energy Update Frequency while running
+ * (if supported) in minutes
+ */
+ public int energyPoll = 0;
+
+ /**
+ * Key and Token Update Frequency in hours
+ * 0 to disable. Minimum 24 hours best practice if used
+ */
+ public int keyTokenUpdate = 0;
+
+ /**
+ * Socket Timeout in seconds
+ */
+ public int timeout = 4;
+
+ /**
+ * Prompt tone from indoor unit with a Set Command
+ */
+ public boolean promptTone = false;
+
+ /**
+ * AC Version
+ */
+ public int version = 3;
+
+ /**
+ * Choose between Energy Decoding methods
+ * true = BCD, false = binary
+ */
+ public boolean energyDecode = true;
+
+ /**
+ * 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 possible. This needs a valid IP
+ *
+ * @return true(discovery needed), false (not needed)
+ */
+ public boolean isDiscoveryPossible() {
+ return 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() && !cloud.isBlank();
+ }
+
+ /**
+ * 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 isHexString(key, 64) && isHexString(token, 128) && !cloud.isBlank();
+ }
+
+ private boolean isHexString(String str, int length) {
+ return str.length() == length && Pattern.matches("[0-9a-fA-F]{" + length + "}", str);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACHandlerFactory.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACHandlerFactory.java
new file mode 100644
index 0000000000000..f8defc4ce2611
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/MideaACHandlerFactory.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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.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 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;
+ }
+
+ @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());
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/Utils.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/Utils.java
new file mode 100644
index 0000000000000..3f89559caca3e
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/Utils.java
@@ -0,0 +1,217 @@
+/*
+ * 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.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+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 org.openhab.core.util.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+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, reversed array and refined query String method
+ */
+@NonNullByDefault
+public class Utils {
+ private static Logger logger = LoggerFactory.getLogger(Utils.class);
+
+ static byte[] empty = new byte[0];
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 string string to convert to byte array
+ * @return byte [] array
+ */
+ public static byte[] hexStringToByteArray(String string) {
+ return HexUtils.hexToBytes(string);
+ }
+
+ /**
+ * 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 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 for Hash
+ *
+ * @param json JSON object
+ * @return string
+ */
+ public static String getQueryString(JsonObject json, boolean hash) {
+ StringBuilder sb = new StringBuilder();
+ Iterator keys = json.keySet().stream().sorted().iterator();
+ while (keys.hasNext()) {
+ @Nullable
+ String key = keys.next();
+ String value = json.get(key).getAsString();
+
+ try {
+ String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8.toString());
+ String encodedValue = URLEncoder.encode(value, StandardCharsets.UTF_8.toString());
+
+ if (hash) {
+ // For hash generation, preserve + and @ characters in values.
+ encodedValue = encodedValue.replace("%2B", "+");
+ encodedValue = encodedValue.replace("%40", "@");
+ }
+
+ // Append the encoded key and value to the query string
+ sb.append(encodedKey).append("=").append(encodedValue);
+
+ if (keys.hasNext()) {
+ sb.append("&"); // To allow for another argument.
+ }
+ } catch (UnsupportedEncodingException e) {
+ logger.debug("Error encoding key and value", e);
+ }
+ }
+ 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;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/Cloud.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/Cloud.java
new file mode 100644
index 0000000000000..404405b6ff050
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/Cloud.java
@@ -0,0 +1,337 @@
+/*
+ * 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.cloud;
+
+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.NonNullByDefault;
+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.openhab.core.util.StringUtils;
+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 Cloud} class connects to the Cloud Provider
+ * with user supplied information (or defaults) to retrieve the Security
+ * Token and Key.
+ *
+ * @author Jacek Dobrowolski - Initial contribution
+ * @author Bob Eckhoff - JavaDoc and changed getQueryString for special characters
+ * to allow for default email with a "+"
+ */
+@NonNullByDefault
+public class Cloud {
+ private final Logger logger = LoggerFactory.getLogger(Cloud.class);
+
+ private static final int CLIENT_TYPE = 1; // Android
+ private static final int FORMAT = 2; // JSON
+ private static final String LANGUAGE = "en_US";
+
+ private HttpClient httpClient;
+
+ private String errMsg = "";
+
+ private @Nullable String accessToken = "";
+
+ private String loginAccount;
+ private String password;
+ private CloudProvider 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
+ * @param httpClient Used to send posts to the cloud
+ */
+ public Cloud(String email, String password, CloudProvider cloudProvider, HttpClient httpClient) {
+ this.loginAccount = email;
+ this.password = password;
+ this.cloudProvider = cloudProvider;
+ this.security = new Security(cloudProvider);
+ this.httpClient = httpClient;
+ logger.debug("Cloud provider: {}", cloudProvider.name());
+ }
+
+ /**
+ * This is called during the loginId(), login() and getToken() methods to send a HHTP Post
+ * to the cloud provider. There are two different messages and formats separated
+ * by the type of Cloud Provider (proxied or Not). The return is msg "ok", "errorCode":"0"
+ * There is also information for the next message or the key and token themselves.
+ */
+ private @Nullable JsonObject apiRequest(String endpoint, @Nullable JsonObject args, @Nullable 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()));
+ }
+
+ // For the getLoginId() this adds the email account
+ // For the login() this adds the email account and the encrpted password
+ // For the getToken() this adds the udpid
+ if (args != null) {
+ for (Map.Entry entry : args.entrySet()) {
+ data.add(entry.getKey(), entry.getValue().getAsJsonPrimitive());
+ }
+ }
+
+ // This adds the first 16 characters of a 16 byte string
+ // if Cloud provider uses proxied and wasn't added by the method()
+ if (!data.has("reqId") && !cloudProvider.proxied().isBlank()) {
+ data.addProperty("reqId", StringUtils.getRandomHex(16));
+ }
+
+ String url = cloudProvider.apiurl() + endpoint;
+ logger.debug("Url for request {}", url);
+
+ String json = data.toString();
+ logger.debug("Request json: {}", json);
+
+ int time = (int) (new Date().getTime() / 1000);
+ String random = String.valueOf(time);
+
+ 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 (!cloudProvider.proxied().isBlank()) {
+ request.header("Content-Type", "application/json");
+ } else {
+ request.header("Content-Type", "application/x-www-form-urlencoded");
+ }
+
+ request.header("secretVersion", "1");
+
+ // Add the sign to the header, different for proxied
+ if (!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);
+
+ // If available, blank if not
+ request.header("accessToken", accessToken);
+
+ logger.debug("Request headers: {}", request.getHeaders().toString());
+
+ // Different formats for proxied
+ if (!cloudProvider.proxied().isBlank()) {
+ request.content(new StringContentProvider(json));
+ } else {
+ String body = Utils.getQueryString(data, false);
+ logger.debug("Request body: {}", body);
+ request.content(new StringContentProvider(body));
+ }
+
+ // POST the payload
+ @Nullable
+ ContentResponse cr = null;
+ try {
+ cr = request.send();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt(); // Restore interrupt flag
+ logger.warn("Request interrupted: {}", e.getMessage());
+ return null; // Return quickly
+ } catch (TimeoutException e) {
+ logger.warn("Request timed out: {}", e.getMessage());
+ } catch (ExecutionException e) {
+ logger.warn("Request execution failed: {}", 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;
+ logger.debug("Error {} logging to Cloud: {}", code, msg);
+ throw new LoginFailedException("Login failed with error code " + code + ": " + msg);
+ } else {
+ logger.debug("Api response ok: {} ({})", code, msg);
+ if (!cloudProvider.proxied().isBlank()) {
+ return result.get("data").getAsJsonObject();
+ } else {
+ return result.get("result").getAsJsonObject();
+ }
+ }
+ } else {
+ logger.warn("No response from cloud!");
+ }
+
+ return null;
+ }
+
+ /**
+ * First gets the loginId from the Cloud using the email, then gets the session
+ * Id with the email and encypted password (using the LoginId). Then
+ * gets the token and key. If loginId and sessionId exist from an earlier
+ * attempt, it goes directly to getting the token and key.
+ *
+ * @return true or false
+ */
+ public boolean login() {
+ // First get the loginId using the your email
+ if (loginId == null) {
+ if (!getLoginId()) {
+ return false;
+ }
+ }
+ // No need to login again, skip to getToken() with device Id
+ if (!Objects.isNull(sessionId) && !sessionId.isBlank()) {
+ return true;
+ }
+
+ logger.trace("Using loginId: {}", loginId);
+ logger.trace("Using password: {}", password);
+
+ if (!cloudProvider.proxied().isBlank()) {
+ // This is for the MSmartHome (proxied) cloud
+ 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", StringUtils.getRandomHex(16));
+ iotData.addProperty("src", cloudProvider.appid());
+ iotData.addProperty("stamp", new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()));
+ newData.add("iotData", iotData);
+
+ @Nullable
+ JsonObject response = apiRequest("/mj/user/login", null, newData);
+ if (response == null) {
+ return false;
+ }
+
+ accessToken = response.getAsJsonObject("mdata").get("accessToken").getAsString();
+ } else {
+ // This for the non-proxied cloud providers
+ 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;
+ }
+
+ /**
+ * Gets token and key with the device Id modified to udpid
+ * after SessionId (non-proxied) accessToken is established
+ *
+ * @param deviceId The AC Device ID to be modified
+ * @return token and key
+ */
+ public TokenKey getToken(String deviceId) {
+ long i = Long.valueOf(deviceId);
+
+ 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 new TokenKey("", "");
+ }
+
+ JsonArray tokenlist = response.getAsJsonArray("tokenlist");
+ JsonObject el = tokenlist.get(0).getAsJsonObject();
+ String token = el.getAsJsonPrimitive("token").getAsString();
+ String key = el.getAsJsonPrimitive("key").getAsString();
+
+ return new TokenKey(token, key);
+ }
+
+ /**
+ * Gets the login ID from your email address
+ *
+ * @return true or false
+ */
+ public 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;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/CloudProvider.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/CloudProvider.java
new file mode 100644
index 0000000000000..2a357241313ac
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/CloudProvider.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mideaac.internal.cloud;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link CloudProvider} 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 iotkey iot key - MSmarthome only
+ * @param hmackey hmac key - MSmarthome only
+ * @param proxied proxy - MSmarthome only
+ *
+ * @author Jacek Dobrowolski - Initial Contribution
+ * @author Bob Eckhoff - JavaDoc and conversion to record, NetHome Plus as default
+ */
+@NonNullByDefault
+public record CloudProvider(String name, String appkey, String appid, String apiurl, String signkey, String iotkey,
+ String hmackey, String proxied) {
+
+ /**
+ * Cloud provider information
+ * 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, iotkey, hmackey, proxied)
+ */
+ public static CloudProvider getCloudProvider(String name) {
+ switch (name) {
+ case "NetHome Plus":
+ return new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
+ "https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+ case "Midea Air":
+ return new CloudProvider("Midea Air", "ff0cf6f5f0c3471de36341cab3f7a9af", "1117",
+ "https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+ // Reported in HA version of the Midea binding that this cloud has been shutdown.
+ // There is a possible v2 version of security down the road?
+ case "MSmartHome":
+ return new CloudProvider("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "1010",
+ "https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S",
+ "meicloud", "PROD_VnoClJI9aikS8dyy", "v5");
+ }
+ // Blank is okay for version 2 as a hard coded signkey is used for all devices
+ return new CloudProvider("", "", "", "", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/LoginFailedException.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/LoginFailedException.java
new file mode 100644
index 0000000000000..b6e71bc4cd719
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/cloud/LoginFailedException.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.openhab.binding.mideaac.internal.cloud;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LoginFailedException} is used to indicate login failures to Midea
+ * cloud services.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+
+@NonNullByDefault
+public class LoginFailedException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public LoginFailedException(String message) {
+ super(message);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/CommandHelper.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/CommandHelper.java
new file mode 100644
index 0000000000000..8e881d21614ca
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/CommandHelper.java
@@ -0,0 +1,470 @@
+/*
+ * 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;
+ }
+
+ // Some devices might support 16.0 degrees C
+ 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 (inside unit) 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 - See capabilities.
+ * 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 - See capabilities
+ * 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 - See capabilities - 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;
+ }
+
+ // Limit Humidity to range
+ private static int limitHumidityToRange(int humidity) {
+ if (humidity < 32) {
+ return 32;
+ }
+ if (humidity > 80) {
+ return 80;
+ }
+
+ return humidity;
+ }
+
+ /**
+ * Sets the Maximum Humidity for Dry Mode
+ *
+ * @param command Maximum Humidity
+ */
+ public static CommandSet handleMaximumHumidity(Command command, Response lastResponse) {
+ CommandSet commandSet = CommandSet.fromResponse(lastResponse);
+
+ if (command instanceof DecimalType decimalCommand) {
+ int humidity = decimalCommand.intValue();
+ commandSet.setMaximumHumidity(limitHumidityToRange(humidity));
+ } else {
+ logger.debug("Unknown maximum humidity command: {}", command);
+ }
+
+ return commandSet;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/ConnectionManager.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/ConnectionManager.java
new file mode 100644
index 0000000000000..8decc90ecca35
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/ConnectionManager.java
@@ -0,0 +1,604 @@
+/*
+ * 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.net.SocketTimeoutException;
+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.cloud.CloudProvider;
+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.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.EnergyResponse;
+import org.openhab.binding.mideaac.internal.handler.HumidityResponse;
+import org.openhab.binding.mideaac.internal.handler.Packet;
+import org.openhab.binding.mideaac.internal.handler.Response;
+import org.openhab.binding.mideaac.internal.handler.TemperatureResponse;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilitiesResponse;
+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.openhab.core.util.HexUtils;
+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. Also added additional
+ * command processing.
+ *
+ * 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 Scheduled 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 CloudProvider cloudProvider;
+ private Security security;
+ private final int version;
+ private final boolean promptTone;
+ private boolean deviceIsConnected;
+ private int droppedCommands = 0;
+
+ /**
+ * True allows command resend if null and timeout response
+ */
+ private boolean resend = true;
+
+ /**
+ * Connection manager configuration
+ *
+ * @param ipAddress Device IP
+ * @param ipPort Device Port
+ * @param timeout Socket timeout
+ * @param key Security key V.3
+ * @param token Security token V.3
+ * @param cloud Cloud Provider
+ * @param email Cloud Provider login email
+ * @param password Cloud Provider login password
+ * @param deviceId Device ID
+ * @param version Device version
+ * @param promptTone Tone after command true or false
+ */
+ 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);
+ this.cloudProvider = CloudProvider.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;
+ }
+
+ /**
+ * The socket is established with the writer and inputStream (for reading responses)
+ * V2 devices will proceed to send the poll or the set command.
+ * V3 devices will proceed to authenticate
+ *
+ * @throws MideaConnectionException
+ * @throws MideaAuthenticationException
+ * @throws SocketTimeoutException
+ * @throws IOException
+ */
+ public synchronized void connect()
+ throws MideaConnectionException, MideaAuthenticationException, SocketTimeoutException, IOException {
+ logger.trace("Connecting to {}:{}", ipAddress, ipPort);
+
+ int maxTries = 3;
+ int retrySocket = 0;
+
+ // If resending command add delay. Device needs time to clear.
+ if (!resend) {
+ try {
+ Thread.sleep(5000);
+ } catch (InterruptedException ex) {
+ logger.debug("An interupted sleep error (resend command delay) has occured {}", ex.getMessage());
+ Thread.currentThread().interrupt();
+ throw new MideaConnectionException(ex);
+ }
+ }
+
+ // Open socket
+ // RetrySocket addresses the Timeout exception only, others exceptions end the thread. Same as HA python version
+ while (retrySocket < maxTries) {
+ try {
+ socket = new Socket();
+ socket.setSoTimeout(timeout * 1000);
+ socket.connect(new InetSocketAddress(ipAddress, ipPort), timeout * 1000);
+ break;
+ } catch (SocketTimeoutException e) {
+ retrySocket++;
+ if (retrySocket < maxTries) {
+ // Device needs time to clear after timeout exception.
+ try {
+ Thread.sleep(5000);
+ } catch (InterruptedException ex) {
+ logger.debug("An interupted sleep error (socket retry delay) has occured {}", ex.getMessage());
+ Thread.currentThread().interrupt();
+ throw new MideaConnectionException(ex);
+ }
+ logger.debug("Socket retry count {}, Socket timeout connecting to {}: {}", retrySocket, ipAddress,
+ e.getMessage());
+ }
+ } catch (IOException e) {
+ logger.debug("Socket retry count {}, IOException connecting to {}: {}", retrySocket, ipAddress,
+ e.getMessage());
+ throw new MideaConnectionException(e);
+ }
+ }
+ if (retrySocket == maxTries) {
+ deviceIsConnected = false;
+ logger.debug("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) {
+ // Info logger on first connection after being disconnected
+ logger.info("Connected to IP {}", ipAddress);
+ } else {
+ 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 (!token.isBlank() && !key.isBlank() && !cloud.isBlank()) {
+ 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.
+ * After success, but without the 1000 ms sleep delay there are problems.
+ * Suspect that the device needs a moment to clear before the Poll.
+ */
+ 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, HexUtils.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");
+ // Reducing the sleep can cause write failures. Device needs time to clear.
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ logger.debug("An interupted sleep error (Authentication) has occured {}", e.getMessage());
+ Thread.currentThread().interrupt();
+ throw new MideaConnectionException(e);
+ }
+ } 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.debug("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, IOException {
+ CommandBase requestStatusCommand = new CommandBase();
+ sendCommand(requestStatusCommand, callback);
+ }
+
+ private void ensureConnected() throws MideaConnectionException, MideaAuthenticationException, IOException {
+ 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 the socket times out with no bytes read()
+ * one resend of the command will be sent. A second failure the command is dropped.
+ * The scheduler will still send the next poll.
+ *
+ * @param command either the set or polling command
+ * @param callback communication with the MideaACHandler to update channel status
+ * @throws MideaConnectionException
+ * @throws MideaAuthenticationException
+ * @throws MideaException
+ * @throws IOException
+ */
+ public synchronized void sendCommand(CommandBase command, @Nullable Callback callback)
+ throws MideaConnectionException, MideaAuthenticationException, MideaException, IOException {
+ ensureConnected();
+
+ if (command instanceof CommandSet cmdSet) {
+ cmdSet.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 (write command2) has occured {}", e.getMessage());
+ Thread.currentThread().interrupt();
+ throw new MideaConnectionException(e);
+ }
+
+ // Input stream is checked after 1.5 seconds
+ // Socket timeout (UI parameter) 2 seconds minimum.
+ if (inputStream.available() == 0) {
+ logger.debug("Input stream still empty sending second write {}", command);
+ write(bytes);
+ }
+
+ byte[] responseBytes = read();
+
+ if (responseBytes != null) {
+ resend = true;
+ int processed = 0;
+ if (version == 3) {
+ Decryption8370Result result = security.decode8370(responseBytes);
+ for (byte[] response : result.getResponses()) {
+ byte[] data = null;
+ logger.debug("Response length: {} IP address: {} ", response.length, ipAddress);
+ if (response.length > 40 + 16) {
+ data = security.aesDecrypt(Arrays.copyOfRange(response, 40, response.length - 16));
+ logger.trace("Bytes in HEX, decoded and with header: length: {}, data: {}", data.length,
+ HexUtils.bytesToHex(data));
+ }
+ // The response data from the appliance includes a packet header which we don't want
+ if (data != null && data.length > 10) {
+ byte[] processedData = Arrays.copyOfRange(data, 10, data.length);
+ byte bodyType = processedData[0x0];
+ handleResponse(processedData, bodyType, callback);
+ processed++;
+ if (processed >= 2) {
+ break;
+ }
+ } else {
+ logger.warn("Decryption failed or insufficient data length to strike header");
+ }
+ }
+ } else if (version == 2) {
+ byte[] data = null;
+ if (responseBytes.length > 40 + 16) {
+ data = security.aesDecrypt(Arrays.copyOfRange(responseBytes, 40, responseBytes.length - 16));
+ logger.trace("V2 Bytes decoded with header: length: {}, data: {}", data.length,
+ HexUtils.bytesToHex(data));
+ }
+ // The response data from the appliance includes a packet header which we don't want
+ if (data != null && data.length > 10) {
+ byte[] processedData = Arrays.copyOfRange(data, 10, data.length);
+ byte bodyType = processedData[0x0];
+ handleResponse(processedData, bodyType, callback);
+ } else {
+ logger.warn("Decryption failed or insufficient data length to strike header");
+ }
+ }
+ return;
+ } else {
+ if (resend) {
+ logger.debug("Resending Command {}", command);
+ resend = false;
+ sendCommand(command, callback);
+ } else {
+ droppedCommands = droppedCommands + 1;
+ logger.debug("Problem with reading response, skipping {} skipped count since startup {}", command,
+ droppedCommands);
+ resend = 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);
+ }
+ }
+
+ private void handleResponse(byte[] data, byte bodyType, @Nullable Callback callback) throws MideaException {
+ logger.trace("Response bodyType: {}", bodyType);
+ logger.debug("Bytes in HEX, decoded and stripped without header: length: {}, data: {}", data.length,
+ HexUtils.bytesToHex(data));
+ logger.trace("Bytes in BINARY, decoded and stripped without header: length: {}, data: {}", data.length,
+ Utils.bytesToBinary(data));
+
+ // Validate the proper length
+ if (data.length < 21) {
+ logger.warn("Response data is {} long, minimum is 21!", data.length);
+ return;
+ }
+
+ switch (bodyType) {
+ case (byte) 0x1E:
+ logger.warn("Error response 0x1E received {} from IP Address:{}", bodyType, ipAddress);
+ return;
+
+ case (byte) 0xB5:
+ try {
+ logger.debug("Capabilities response detected with bodyType 0xB5.");
+ CapabilitiesResponse capabilitiesResponse = new CapabilitiesResponse(data);
+ if (callback != null) {
+ callback.updateChannels(capabilitiesResponse);
+ }
+ } catch (Exception ex) {
+ logger.debug("Capability response exception: {}", ex.getMessage());
+ throw new MideaException(ex);
+ }
+ return;
+
+ case (byte) 0xC1:
+ try {
+ logger.debug("Energy/Humidity response detected with bodyType 0xC1.");
+ EnergyResponse energyUpdate = new EnergyResponse(data);
+ // Separate Humidity from Energy
+ if (callback != null) {
+ if (data[3] == (byte) 0x45) {
+ callback.updateHumidityFromEnergy(energyUpdate);
+ } else {
+ callback.updateChannels(energyUpdate);
+ }
+ }
+ } catch (Exception ex) {
+ logger.debug("Energy/Humidity response exception: {}", ex.getMessage());
+ throw new MideaException(ex);
+ }
+ return;
+
+ case (byte) 0xA0:
+ try {
+ logger.debug("Response detected with bodyType 0xA0. Data length: {}", data.length);
+ HumidityResponse humidityResponse = new HumidityResponse(data);
+ if (callback != null) {
+ callback.updateChannels(humidityResponse);
+ } else {
+ logger.debug("Callback is null for unsolicited 0xA0 humidity response, channels not updated.");
+ }
+ } catch (Exception ex) {
+ logger.debug("Unsolicited 0xA0 response exception: {}", ex.getMessage());
+ throw new MideaException(ex);
+ }
+ return;
+
+ case (byte) 0xA1:
+ try {
+ logger.debug("Response detected with bodyType 0xA1. Data length: {}", data.length);
+ TemperatureResponse temperatureResponse = new TemperatureResponse(data);
+ if (callback != null) {
+ callback.updateChannels(temperatureResponse);
+ } else {
+ logger.debug(
+ "Callback is null for unsolicited 0xA1 temperature response, channels not updated.");
+ }
+ } catch (Exception ex) {
+ logger.debug("Unsolicited 0xA1 response exception: {}", ex.getMessage());
+ throw new MideaException(ex);
+ }
+ return;
+
+ case (byte) 0xA5:
+ // A500000000 0000000000 0000000000 0000000000 0000005F13 FF00000000 0000236C7C
+ logger.debug("Not fully documented, possible filter runtime in minutes");
+ return;
+
+ case (byte) 0xC0:
+ lastResponse = new Response(data, version);
+ 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.debug("Poll response exception: {}", ex.getMessage());
+ throw new MideaException(ex);
+ }
+ return;
+
+ default:
+ logger.warn("Unexpected response bodyType {}", bodyType);
+ }
+ }
+
+ /**
+ * 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 (Handshake or command)
+ *
+ * @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 true or false
+ */
+ public void dispose(boolean force) {
+ disconnect();
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaAuthenticationException.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaAuthenticationException.java
new file mode 100644
index 0000000000000..50b72f0889274
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaAuthenticationException.java
@@ -0,0 +1,40 @@
+/*
+ * 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
+ * Authentication 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);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaConnectionException.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaConnectionException.java
new file mode 100644
index 0000000000000..96be6b98b1836
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaConnectionException.java
@@ -0,0 +1,40 @@
+/*
+ * 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
+ * Connection {@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);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaException.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaException.java
new file mode 100644
index 0000000000000..c2bcbb21893c7
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/connection/exception/MideaException.java
@@ -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);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/DiscoveryHandler.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/DiscoveryHandler.java
new file mode 100644
index 0000000000000..36f4c0a43ef19
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/DiscoveryHandler.java
@@ -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);
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryService.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryService.java
new file mode 100644
index 0000000000000..0299cba68f11a
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryService.java
@@ -0,0 +1,392 @@
+/*
+ * 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.cloud.CloudProvider;
+import org.openhab.binding.mideaac.internal.handler.CommandBase;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilityParser;
+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.openhab.core.util.HexUtils;
+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 and Capabilities capture
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, configurationPid = "discovery.mideaac")
+public class MideaACDiscoveryService extends AbstractDiscoveryService {
+
+ private static int discoveryTimeoutSeconds = 10;
+ private final int receiveJobTimeout = 20000;
+ private final int udpPacketTimeout = receiveJobTimeout - 50;
+ private final String mideaacNamePrefix = "MideaAC";
+ /**
+ * 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 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(CloudProvider.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).
+ * with unknown IP address.
+ */
+ 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("Discovery poll timeout");
+ } catch (IOException e) {
+ logger.debug("Exception during discovery - no issue if socket closed: {}", e.getMessage());
+ } finally {
+ closeDiscoverSocket();
+ removeOlderResults(getTimestampOfLastScan());
+ }
+ }
+
+ /**
+ * Performs the actual discovery of a specific Midea AC device (thing)
+ * with a known IP address.
+ *
+ * @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(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, MIDEAAC_SEND_PORT1);
+ discoverSocket.send(discoverPacket);
+ logger.trace("Broadcast discovery package sent to port: {}", MIDEAAC_SEND_PORT1);
+ }
+ {
+ final DatagramPacket discoverPacket = new DatagramPacket(CommandBase.discover(),
+ CommandBase.discover().length, broadcast, MIDEAAC_SEND_PORT2);
+ discoverSocket.send(discoverPacket);
+ logger.trace("Broadcast discovery package sent to port: {}", 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, HexUtils.bytesToHex(data));
+
+ if (data.length >= 104 && (HexUtils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")
+ || HexUtils.bytesToHex(Arrays.copyOfRange(data, 8, 10)).equals("5A5A"))) {
+ logger.trace("Device supported");
+ String mSmartId, mSmartip = "", mSmartSN = "", mSmartSSID = "", mSmartType = "", mSmartPort = "",
+ mSmartVersion = "";
+
+ if (HexUtils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("5A5A")) {
+ mSmartVersion = "2";
+ }
+ if (HexUtils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
+ mSmartVersion = "3";
+ }
+ if (HexUtils.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: {}", HexUtils.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: '{}'", HexUtils.bytesToHex(encryptData));
+
+ byte[] reply = security.aesDecrypt(encryptData);
+ logger.trace("Length: {}, Reply: '{}'", reply.length, HexUtils.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, new TreeMap<>(), // Placeholder for capabilities
+ new TreeMap<>())) // Placeholder for numericCapabilities
+ .build();
+ } else if (HexUtils.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 discovered properties into a map and empty Maps
+ * for capabilities.
+ *
+ * @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 collectProperties(String ipAddress, String version, String id, String port, String sn,
+ String ssid, String type, @Nullable Map> capabilities,
+ @Nullable Map> numericCapabilities) {
+ Map properties = new TreeMap<>();
+
+ // Basic properties
+ 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);
+
+ // Default empty maps for boolean and numeric capabilities
+ if (capabilities != null) {
+ capabilities.forEach((capabilityId, capabilityMap) -> {
+ capabilityMap.forEach((key, value) -> {
+ properties.put(capabilityId.name() + "_" + key, value);
+ });
+ });
+ }
+
+ if (numericCapabilities != null) {
+ numericCapabilities.forEach((capabilityId, temperatureMap) -> {
+ temperatureMap.forEach((key, value) -> {
+ properties.put(capabilityId.name() + "_" + key, value);
+ });
+ });
+ }
+
+ return properties;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Callback.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Callback.java
new file mode 100644
index 0000000000000..cad9e8a70f102
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Callback.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mideaac.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilitiesResponse;
+
+/**
+ * The {@link Response} performs the polling byte data stream decoding
+ * The {@link CapabilitiesResponse} performs the capability byte data stream decoding
+ * The {@link EnergyResponse} performs the energy byte stream data decoding
+ * The {@link HumidityResponse} performs decoding of unsolicited message 0xA0
+ * The {@link TemperatureResponse} performs decoding of unsolicited message 0xA1
+ *
+ * @author Leo Siepel - Initial contribution
+ * @author Bob Eckhoff - added additional Callbacks after Response
+ */
+@NonNullByDefault
+public interface Callback {
+ /**
+ * Updates channels with a standard response (0xC0).
+ *
+ * @param response The standard response from the device used to update channels.
+ */
+ void updateChannels(Response response);
+
+ /**
+ * Updates channels with a capabilities response (0xB5).
+ *
+ * @param capabilitiesResponse The capabilities response from the device used to update properties.
+ */
+ void updateChannels(CapabilitiesResponse capabilitiesResponse);
+
+ /**
+ * Updates channels with a Energy response (0xC1 - 0x44).
+ *
+ * @param energyResponse The Energy response from the device used to update energy.
+ */
+ void updateChannels(EnergyResponse energyResponse);
+
+ /**
+ * Updates humidity with a Energy response (0xC1 - 0x45).
+ *
+ * @param energyResponse The Energy response from a humidity Poll used to update humidity.
+ */
+ void updateHumidityFromEnergy(EnergyResponse energyResponse);
+
+ /**
+ * Updates channels with an unsolicted Humidity Response (0xA0).
+ *
+ * @param humidityResponse The unsolicited (0xA0) response from the device used to update properties.
+ */
+ void updateChannels(HumidityResponse humidityResponse);
+
+ /**
+ * Updates channels with an unsolicited Temperature response (0xA1).
+ *
+ * @param temperatureResponse The unsolicited (0xA1) response from the device used to update properties.
+ */
+ void updateChannels(TemperatureResponse temperatureResponse);
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandBase.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandBase.java
new file mode 100644
index 0000000000000..8255268dce14c
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandBase.java
@@ -0,0 +1,313 @@
+/*
+ * 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.security.Crc8;
+import org.openhab.core.util.HexUtils;
+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 - This is the message length
+ (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 {}", HexUtils.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);
+ }
+ return (byte) ((255 - (sum % 256)) + 1);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandSet.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandSet.java
new file mode 100644
index 0000000000000..786853ee623b1
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/CommandSet.java
@@ -0,0 +1,526 @@
+/*
+ * 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.Timer.TimerData;
+import org.openhab.core.util.HexUtils;
+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, Timer Display LED and capabilities
+ */
+@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());
+ commandSet.setMaximumHumidity(response.getMaximumHumidity());
+ 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 Display Bytes before encrypt {}", HexUtils.bytesToHex(data));
+ }
+
+ private void modifyBytesForDisplayOff() {
+ data[0x01] = (byte) 0x20;
+ data[0x09] = (byte) 0x03;
+ data[0x0a] = (byte) 0x41;
+ data[0x0b] = (byte) 0x61; // Includes beep 0x40
+ 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;
+ }
+
+ /**
+ * Creates the Initial Get Capability message
+ *
+ * @return Capability message
+ */
+ public void getCapabilities() {
+ modifyBytesForCapabilities();
+ removeExtraCapabilityBytes();
+ logger.trace("Set Capability Bytes before encrypt {}", HexUtils.bytesToHex(data));
+ }
+
+ private void modifyBytesForCapabilities() {
+ data[0x01] = (byte) 0x0E;
+ data[0x09] = (byte) 0x03;
+ data[0x0a] = (byte) 0xB5;
+ data[0x0b] = (byte) 0x01;
+ data[0x0c] = (byte) 0x00;
+ }
+
+ private void removeExtraCapabilityBytes() {
+ byte[] newData = new byte[data.length - 21];
+ System.arraycopy(data, 0, newData, 0, newData.length);
+ data = newData;
+ }
+
+ /**
+ * Creates the Additional Get capability message
+ *
+ * @return Additional Capability message
+ */
+ public void getAdditionalCapabilities() {
+ modifyBytesForAdditionalCapabilities();
+ removeExtraAdditionalCapabilityBytes();
+ logger.trace("Set Additional Capability Bytes before encrypt {}", HexUtils.bytesToHex(data));
+ }
+
+ private void modifyBytesForAdditionalCapabilities() {
+ data[0x01] = (byte) 0x0F;
+ data[0x09] = (byte) 0x03;
+ data[0x0a] = (byte) 0xB5;
+ data[0x0b] = (byte) 0x01;
+ data[0x0c] = (byte) 0x01;
+ data[0x0d] = (byte) 0x01;
+ }
+
+ private void removeExtraAdditionalCapabilityBytes() {
+ byte[] newData = new byte[data.length - 20];
+ 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;
+ }
+
+ /**
+ * Energy detail polling
+ * Response will be C1, not C0
+ *
+ */
+ public void energyPoll() {
+ modifyBytesForEnergyPoll();
+ removeExtraEnergyPollBytes();
+ logger.trace("Set Energy Bytes before encrypt {}", HexUtils.bytesToHex(data));
+ }
+
+ private void modifyBytesForEnergyPoll() {
+ data[0x01] = (byte) 0x20;
+ data[0x09] = (byte) 0x03;
+ data[0x0a] = (byte) 0x41;
+ data[0x0b] = (byte) 0x21;
+ data[0x0c] = (byte) 0x01;
+ data[0x0d] = (byte) 0x44;
+ data[0x0e] = (byte) 0x00;
+ data[0x0f] = (byte) 0x00;
+ data[0x10] = (byte) 0x00;
+ data[0x11] = (byte) 0x00;
+ data[0x12] = (byte) 0x00;
+ data[0x13] = (byte) 0x00;
+ data[0x14] = (byte) 0x00;
+ }
+
+ private void removeExtraEnergyPollBytes() {
+ byte[] newData = new byte[data.length - 3];
+ System.arraycopy(data, 0, newData, 0, newData.length);
+ data = newData;
+ }
+
+ /**
+ * Humidity detail polling
+ * Response will be C1, not C0
+ *
+ */
+ public void humidityPoll() {
+ modifyBytesForHumidityPoll();
+ removeExtraHumidityPollBytes();
+ logger.trace("Set Humidity Poll Bytes before encrypt {}", HexUtils.bytesToHex(data));
+ }
+
+ private void modifyBytesForHumidityPoll() {
+ data[0x01] = (byte) 0x20;
+ data[0x09] = (byte) 0x03;
+ data[0x0a] = (byte) 0x41;
+ data[0x0b] = (byte) 0x21;
+ data[0x0c] = (byte) 0x01;
+ data[0x0d] = (byte) 0x45;
+ data[0x0e] = (byte) 0x00;
+ data[0x0f] = (byte) 0x00;
+ data[0x10] = (byte) 0x00;
+ data[0x11] = (byte) 0x00;
+ data[0x12] = (byte) 0x00;
+ data[0x13] = (byte) 0x00;
+ data[0x14] = (byte) 0x00;
+ }
+
+ private void removeExtraHumidityPollBytes() {
+ byte[] newData = new byte[data.length - 3];
+ System.arraycopy(data, 0, newData, 0, newData.length);
+ data = newData;
+ }
+
+ /**
+ * Sets the Maximum Humidity for Dry Mode
+ *
+ * @param humidity
+ */
+ public void setMaximumHumidity(int humidity) {
+ data[0x1D] &= ~(byte) 0xff;
+ data[0x1D] |= humidity;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/EnergyResponse.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/EnergyResponse.java
new file mode 100644
index 0000000000000..05ab2035b97f2
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/EnergyResponse.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mideaac.internal.handler;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EnergyResponse} handles the energy messages
+ * from the device
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class EnergyResponse {
+ private final byte[] rawData;
+ private Logger logger = LoggerFactory.getLogger(EnergyResponse.class);
+
+ /**
+ * Initialization
+ *
+ * @param rawData as bytes
+ */
+ public EnergyResponse(byte[] rawData) {
+ this.rawData = rawData;
+
+ if (logger.isDebugEnabled()) {
+ if (isHumidityResponse()) {
+ logger.debug("Humidity from Poll: {}", getHumidity());
+ } else {
+ logger.debug("Total Kilowatt Hours: {}", getKilowattHours());
+ logger.debug("Current Amperes: {}", getAmperes());
+ logger.debug("Power Watts: {}", getWatts());
+ logger.debug("Total Kilowatt Hours BCD: {}", getKilowattHoursBCD());
+ logger.debug("Current Amperes BCD: {}", getAmperesBCD());
+ logger.debug("Power Watts BCD: {}", getWattsBCD());
+ }
+ }
+ }
+
+ private boolean isHumidityResponse() {
+ return rawData.length > 3 && rawData[3] == (byte) 0x45;
+ }
+
+ /**
+ * Humidity reading if Byte[3] == (byte) 0x45
+ *
+ * @return humidity
+ */
+ public int getHumidity() {
+ return rawData[4];
+ }
+
+ /**
+ * Kilowatt Hours using binary
+ *
+ * @return kilowatt Hours
+ */
+ public double getKilowattHours() {
+ return ByteBuffer.wrap(rawData, 4, 4).getInt() / 100.00;
+ }
+
+ /**
+ * Amperes in use using binary
+ *
+ * @return amperes
+ */
+ public double getAmperes() {
+ return ByteBuffer.wrap(rawData, 12, 4).getInt() / 10.0;
+ }
+
+ /**
+ * Watts in use using binary
+ *
+ * @return watts
+ */
+ public double getWatts() {
+ return ((rawData[16] & 0xFF) << 16 | (rawData[17] & 0xFF) << 8 | (rawData[18] & 0xFF)) / 10.0;
+ }
+
+ /**
+ * Kilowatt Hours using BCD
+ *
+ * @return kilowatt Hours
+ */
+ public double getKilowattHoursBCD() {
+ double kilowattHours = 0.0;
+ kilowattHours = bcdToDecimal(rawData, 4, 4) / 100.00;
+ return kilowattHours;
+ }
+
+ /**
+ * Amperes using BCD
+ *
+ * @return amperes
+ */
+ public double getAmperesBCD() {
+ double amperes = 0.0;
+ amperes = bcdToDecimal(rawData, 12, 4) / 10.0;
+ return amperes;
+ }
+
+ /**
+ * Watts Using BCD
+ *
+ * @return watts
+ */
+ public double getWattsBCD() {
+ double watts = 0.0;
+ watts = bcdToDecimal(rawData, 16, 3) / 10.0;
+ return watts;
+ }
+
+ private long bcdToDecimal(byte[] data, int offset, int length) {
+ long decimalValue = 0;
+ for (int i = 0; i < length; i++) {
+ int byteValue = data[offset + i] & 0xFF; // Ensure byte is unsigned
+ decimalValue = decimalValue * 100 + ((byteValue >> 4) * 10) + (byteValue & 0x0F);
+ }
+ return decimalValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/HumidityResponse.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/HumidityResponse.java
new file mode 100644
index 0000000000000..b2115c51ea6cc
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/HumidityResponse.java
@@ -0,0 +1,111 @@
+/*
+ * 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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link HumidityResponse} handles the unsolicited 0xA0 humidity report messages
+ * from the device
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class HumidityResponse {
+ private final byte[] rawData;
+ private Logger logger = LoggerFactory.getLogger(HumidityResponse.class);
+ private int version = 3;
+
+ /**
+ * Initialization
+ *
+ * @param rawData as bytes
+ */
+ public HumidityResponse(byte[] rawData) {
+ this.rawData = rawData;
+ if (logger.isDebugEnabled()) {
+ logger.debug("Humidity: {}", getHumidity());
+ logger.debug("Power from 0xA0 {}", getPowerState());
+ logger.debug("Operational Mode from 0xA0 {}", getOperationalMode());
+ logger.debug("Target Temperature from 0xA0 {}", getTargetTemperature());
+ logger.debug("Fan Speed from 0xA0 {}", getFanSpeed());
+ logger.debug("Swing Mode from 0xA0 {}", getSwingMode());
+ }
+ }
+
+ /**
+ * Reported Room Humidity from 0xA0 message type
+ *
+ * @return humidity
+ */
+ public int getHumidity() {
+ return (rawData[13] & (byte) 0x7f);
+ }
+
+ /**
+ * Power from 0xA0 message type
+ * not used in channels
+ *
+ * @return power status
+ */
+ public boolean getPowerState() {
+ return (rawData[0x01] & 0x1) > 0;
+ }
+
+ /**
+ * Cool, Heat, Fan Only, etc. from 0xA0 message type
+ * Not used in Channels
+ *
+ * @return Cool, Heat, Fan Only, etc.
+ */
+ public OperationalMode getOperationalMode() {
+ return OperationalMode.fromId((rawData[0x02] & 0xe0) >> 5);
+ }
+
+ /**
+ * Target Temperature from 0xA0 message type - Different
+ * Not used in Channels
+ *
+ * @return current setpoint in degrees C
+ */
+ public float getTargetTemperature() {
+ return ((rawData[0x01] & 0x3E) >> 1) + 12.0f + (((rawData[0x01] & 0x40) >> 6 > 0) ? 0.5f : 0.0f);
+ }
+
+ /**
+ * Low, Medium, High, Auto etc. See Command Base class
+ * From message 0XA0; assumed version 3 for this message
+ * Not Used in Channels
+ *
+ * @return Low, Medium, High, Auto etc.
+ */
+ public FanSpeed getFanSpeed() {
+ return FanSpeed.fromId(rawData[0x03] & 0x7f, version);
+ }
+
+ /**
+ * Status of the vertical and/or horzontal louver
+ * From message 0XA0; assumed version 3 for this message
+ * Not Used in Channels
+ *
+ * @return Vertical, Horizontal, Off, Both
+ */
+ public SwingMode getSwingMode() {
+ return SwingMode.fromId(rawData[0x07] & 0x3f, version);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/MideaACHandler.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/MideaACHandler.java
new file mode 100644
index 0000000000000..6a6037bcc92ca
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/MideaACHandler.java
@@ -0,0 +1,670 @@
+/*
+ * 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.io.IOException;
+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.cloud.Cloud;
+import org.openhab.binding.mideaac.internal.cloud.CloudProvider;
+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.handler.capabilities.CapabilitiesResponse;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilityParser;
+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, OH developer guidelines added other messages
+ * @author Leo Siepel - Refactored class, improved separation of concerns
+ */
+@NonNullByDefault
+public class MideaACHandler extends BaseThingHandler implements DiscoveryHandler, Callback {
+ private final Logger logger = LoggerFactory.getLogger(MideaACHandler.class);
+ private final boolean imperialUnits;
+ private final HttpClient httpClient;
+
+ private MideaACConfiguration config = new MideaACConfiguration();
+ private Map 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(3);
+ private @Nullable ScheduledFuture> scheduledTask;
+ private @Nullable ScheduledFuture> scheduledKeyTokenUpdate;
+ private @Nullable ScheduledFuture> scheduledEnergyUpdate;
+
+ private final Callback callbackLambda = new Callback() {
+ @Override
+ public void updateChannels(Response response) {
+ MideaACHandler.this.updateChannels(response);
+ }
+
+ @Override
+ public void updateChannels(CapabilitiesResponse capabilitiesResponse) {
+ MideaACHandler.this.updateChannels(capabilitiesResponse);
+ }
+
+ @Override
+ public void updateChannels(EnergyResponse energyUpdate) {
+ MideaACHandler.this.updateChannels(energyUpdate);
+ }
+
+ @Override
+ public void updateHumidityFromEnergy(EnergyResponse energyUpdate) {
+ MideaACHandler.this.updateHumidityFromEnergy(energyUpdate);
+ }
+
+ @Override
+ public void updateChannels(HumidityResponse humidityResponse) {
+ MideaACHandler.this.updateChannels(humidityResponse);
+ }
+
+ @Override
+ public void updateChannels(TemperatureResponse temperatureResponse) {
+ MideaACHandler.this.updateChannels(temperatureResponse);
+ }
+ };
+
+ /**
+ * Initial creation of the Midea AC Handler
+ *
+ * @param thing Thing
+ * @param unitProvider OH core unit provider
+ * @param httpClient http Client
+ */
+ public MideaACHandler(Thing thing, UnitProvider unitProvider, HttpClient httpClient) {
+ super(thing);
+ this.thing = thing;
+ this.imperialUnits = unitProvider.getMeasurementSystem() instanceof ImperialUnits;
+ this.httpClient = httpClient;
+ }
+
+ /**
+ * 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.
+ * For a Refresh both regular and energy polls are triggerred.
+ */
+ @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);
+ // Read only Energy and Humidity channels not updated with routine poll
+ CommandSet energyUpdate = new CommandSet();
+ energyUpdate.energyPoll();
+ connectionManager.sendCommand(energyUpdate, this);
+ CommandSet humidityUpdate = new CommandSet();
+ humidityUpdate.humidityPoll();
+ connectionManager.sendCommand(humidityUpdate, this);
+ } catch (MideaAuthenticationException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ } catch (MideaConnectionException | MideaException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (IOException 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);
+ } else if (channelUID.getId().equals(CHANNEL_MAXIMUM_HUMIDITY)) {
+ connectionManager.sendCommand(CommandHelper.handleMaximumHumidity(command, lastresponse),
+ callbackLambda);
+ }
+ } catch (MideaConnectionException | MideaAuthenticationException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (MideaException | IOException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ }
+ }
+
+ /**
+ * Initializes the handler by performing the following steps:
+ *
+ * - Retrieves the configuration for the handler.
+ * - Ensures the discovery or configuration is valid. If not, starts the discovery process and exits early.
+ * - Ensures the token and key for V3 devices are available. If not, starts the retrieval process and exits
+ * early.
+ * - Updates the thing's status to {@link ThingStatus#UNKNOWN}.
+ * - Initializes the connection manager using the configuration.
+ * - Requests device capabilities if they are missing.
+ * - Starts any necessary schedulers for the handler.
+ *
+ */
+ @Override
+ public void initialize() {
+ config = getConfigAs(MideaACConfiguration.class);
+
+ // 1) Ensure discovery/config is valid or start discovery and exit early
+ if (!ensureConfigOrStartDiscovery()) {
+ return;
+ }
+
+ // 2) Ensure token/key for V3 devices or start retrieval and exit early
+ if (!ensureTokenKeyOrStartRetrieval()) {
+ return;
+ }
+
+ updateStatus(ThingStatus.UNKNOWN);
+
+ initConnectionManagerFromConfig();
+
+ requestCapabilitiesIfMissing();
+
+ startSchedulers();
+ }
+
+ /**
+ * Ensure we have all configuration needed to reach the device. If incomplete
+ * but discoverable,
+ * trigger discovery asynchronously and return false to stop current
+ * initialization.
+ */
+ private boolean ensureConfigOrStartDiscovery() {
+ if (config.isValid()) {
+ logger.debug("Discovery parameters are valid for {}", thing.getUID());
+ return true;
+ }
+
+ if (!config.isDiscoveryPossible()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.configuration_error_invalid_discovery");
+ return false;
+ }
+
+ MideaACDiscoveryService discoveryService = new MideaACDiscoveryService();
+
+ // Kick off discovery asynchronously and end this initialization thread.
+
+ scheduler.execute(() -> {
+ try {
+ // Keep thing OFFLINE with message about attempting discovery.
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.configuration_pending_discovery");
+ discoveryService.discoverThing(config.ipAddress, this);
+ } catch (Exception e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/offline.communication_error_discovery");
+ }
+ });
+
+ return false;
+ }
+
+ /**
+ * Ensure token/key are available for V3 devices. If retrievable from cloud,
+ * trigger async
+ * retrieval and return false to stop current initialization.
+ */
+ private boolean ensureTokenKeyOrStartRetrieval() {
+ if (config.version != 3 || config.isV3ConfigValid()) {
+ logger.debug("Valid token and key for V.3 device {}", thing.getUID());
+ return true;
+ }
+
+ if (!config.isTokenKeyObtainable()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.configuration_error_invalid_token");
+ return false;
+ }
+
+ scheduler.execute(() -> {
+ try {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.configuration_pending_token");
+ CloudProvider cloudProvider = CloudProvider.getCloudProvider(config.cloud);
+ getTokenKeyCloud(cloudProvider);
+ } catch (Exception e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/offline.communication_error_token");
+ }
+ });
+
+ return false;
+ }
+
+ /** Initialize the connection manager from the current configuration. */
+ private void initConnectionManagerFromConfig() {
+ 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);
+ }
+
+ /**
+ * Send capabilities command(s) if we don't yet have them stored in properties.
+ */
+ private void requestCapabilitiesIfMissing() {
+ if (properties.containsKey("modeFanOnly")) {
+ return;
+ }
+
+ scheduler.execute(() -> {
+ try {
+ CommandSet initializationCommand = new CommandSet();
+ initializationCommand.getCapabilities();
+ this.connectionManager.sendCommand(initializationCommand, this);
+
+ // Check if additional capabilities are available and fetch them if so
+ CapabilityParser parser = new CapabilityParser();
+ logger.debug("additional capabilities {}", parser.hasAdditionalCapabilities());
+ if (parser.hasAdditionalCapabilities()) {
+ scheduler.schedule(() -> {
+ try {
+ CommandSet additionalCommand = new CommandSet();
+ additionalCommand.getAdditionalCapabilities();
+ this.connectionManager.sendCommand(additionalCommand, this);
+ } catch (Exception e) {
+ logger.debug("AC additional capabilities not returned {}", e.getMessage());
+ }
+ }, 2, TimeUnit.SECONDS);
+ }
+ } catch (Exception e) {
+ // Will not affect AC device readiness, just log the issue
+ logger.debug("AC capabilities not returned {}", e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * Start routine, token refresh and energy schedulers according to
+ * configuration.
+ */
+ private void startSchedulers() {
+ // Routine polling
+ if (scheduledTask == null) {
+ scheduledTask = scheduler.scheduleWithFixedDelay(this::pollJob, 2, config.pollingTime, TimeUnit.SECONDS);
+ logger.debug("Scheduled task started, Poll Time {} seconds", config.pollingTime);
+ } else {
+ logger.debug("Scheduler already running");
+ }
+
+ // Token/key update
+ if (config.keyTokenUpdate != 0 && scheduledKeyTokenUpdate == null) {
+ scheduledKeyTokenUpdate = scheduler.scheduleWithFixedDelay(
+ () -> getTokenKeyCloud(CloudProvider.getCloudProvider(config.cloud)), config.keyTokenUpdate,
+ config.keyTokenUpdate, TimeUnit.HOURS);
+ logger.debug("Token Key Update Scheduler started, update interval {} hours", config.keyTokenUpdate);
+ } else {
+ logger.debug("Token Key Scheduler already running or disabled");
+ }
+
+ // Energy polling
+ if (config.energyPoll != 0 && scheduledEnergyUpdate == null) {
+ scheduledEnergyUpdate = scheduler.scheduleWithFixedDelay(this::energyUpdate, 1, config.energyPoll,
+ TimeUnit.MINUTES);
+ logger.debug("Scheduled Energy Update started, Poll Time {} minutes", config.energyPoll);
+ } else {
+ logger.debug("Energy Scheduler already running or disabled");
+ }
+ }
+
+ private void energyUpdate() {
+ ConnectionManager connectionManager = this.connectionManager;
+
+ try {
+ CommandSet energyUpdate = new CommandSet();
+ energyUpdate.energyPoll();
+ connectionManager.sendCommand(energyUpdate, this);
+ } 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());
+ } catch (IOException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ }
+ }
+
+ private void pollJob() {
+ ConnectionManager connectionManager = this.connectionManager;
+
+ try {
+ connectionManager.getStatus(callbackLambda);
+ // If we reach here, the device is online.
+ 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());
+ } catch (IOException 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);
+ }
+ }
+
+ @Override
+ public 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_FILTER_STATUS, OnOffType.from(response.getFilterStatus()));
+ updateChannel(CHANNEL_MAXIMUM_HUMIDITY, new DecimalType(response.getMaximumHumidity()));
+
+ QuantityType targetTemperature = new QuantityType(response.getTargetTemperature(),
+ SIUnits.CELSIUS);
+ QuantityType outdoorTemperature = new QuantityType(response.getOutdoorTemperature(),
+ SIUnits.CELSIUS);
+ QuantityType indoorTemperature = new QuantityType(response.getIndoorTemperature(),
+ SIUnits.CELSIUS);
+
+ if (imperialUnits) {
+ targetTemperature = Objects.requireNonNull(targetTemperature.toUnit(ImperialUnits.FAHRENHEIT));
+ indoorTemperature = Objects.requireNonNull(indoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
+ outdoorTemperature = Objects.requireNonNull(outdoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
+ }
+
+ updateChannel(CHANNEL_TARGET_TEMPERATURE, targetTemperature);
+ updateChannel(CHANNEL_INDOOR_TEMPERATURE, indoorTemperature);
+ updateChannel(CHANNEL_OUTDOOR_TEMPERATURE, outdoorTemperature);
+ }
+
+ // Handle capabilities responses
+ @Override
+ public void updateChannels(CapabilitiesResponse capabilitiesResponse) {
+ CapabilityParser parser = new CapabilityParser();
+ parser.parse(capabilitiesResponse.getRawData());
+
+ properties = editProperties();
+
+ parser.getCapabilities().forEach((capabilityId, capabilityMap) -> {
+ capabilityMap.forEach((key, value) -> {
+ properties.put(key, String.valueOf(value));
+ });
+ });
+
+ parser.getNumericCapabilities().forEach((capabilityId, temperatureMap) -> {
+ temperatureMap.forEach((key, value) -> {
+ properties.put(key, String.valueOf(value));
+ });
+ });
+
+ // Default to false if "display_control" is missing from DISPLAY_CONTROL response
+ if (!properties.containsKey("displayControl")) {
+ properties.put("displayControl", "false - default");
+ }
+ // Default to true if "fan_only" is missing from MODE response
+ if (!properties.containsKey("modeFanOnly")) {
+ properties.put("modeFanOnly", "true - default");
+ }
+ // Defaults if FAN_SPEED_CONTROL is missing from response
+ if (!properties.containsKey("fanLow")) {
+ properties.put("fanLow", "true - default");
+ }
+ if (!properties.containsKey("fanMedium")) {
+ properties.put("fanMedium", "true - default");
+ }
+ if (!properties.containsKey("fanHigh")) {
+ properties.put("fanHigh", "true - default");
+ }
+ if (!properties.containsKey("fanAuto")) {
+ properties.put("fanAuto", "true - default");
+ }
+ // Defaults if no TEMPERATURES were in response
+ if (!properties.containsKey("coolMinTemperature")) {
+ properties.put("minTargetTemperature", "17°C / 62°F default");
+ }
+ if (!properties.containsKey("heatMaxTemperature")) {
+ properties.put("maxTargetTemperature", "30°C / 86°F default");
+ }
+
+ updateProperties(properties);
+
+ logger.debug("Capabilities and temperature settings parsed and stored in properties: {}", properties);
+ }
+
+ // Handle Energy response updates - Config flags sets what decoding to use
+ @Override
+ public void updateChannels(EnergyResponse energyUpdate) {
+ if (config.energyDecode) {
+ updateChannel(CHANNEL_ENERGY_CONSUMPTION, new DecimalType(energyUpdate.getKilowattHoursBCD()));
+ updateChannel(CHANNEL_CURRENT_DRAW, new DecimalType(energyUpdate.getAmperesBCD()));
+ updateChannel(CHANNEL_POWER_CONSUMPTION, new DecimalType(energyUpdate.getWattsBCD()));
+ } else {
+ updateChannel(CHANNEL_ENERGY_CONSUMPTION, new DecimalType(energyUpdate.getKilowattHours()));
+ updateChannel(CHANNEL_CURRENT_DRAW, new DecimalType(energyUpdate.getAmperes()));
+ updateChannel(CHANNEL_POWER_CONSUMPTION, new DecimalType(energyUpdate.getWatts()));
+ }
+ }
+
+ // Handle Humidity from energy poll command (0xC1)
+ @Override
+ public void updateHumidityFromEnergy(EnergyResponse energyUpdate) {
+ updateChannel(CHANNEL_HUMIDITY, new DecimalType(energyUpdate.getHumidity()));
+ }
+
+ // Handle unsolicted Humidity response in room (0xA0)
+ @Override
+ public void updateChannels(HumidityResponse humidityResponse) {
+ updateChannel(CHANNEL_HUMIDITY, new DecimalType(humidityResponse.getHumidity()));
+ }
+
+ // Handle unsolicted Temperature and Humidity response in room (0xA1)
+ // Temperatures are also updated via the poll
+ @Override
+ public void updateChannels(TemperatureResponse temperatureResponse) {
+ updateChannel(CHANNEL_HUMIDITY, new DecimalType(temperatureResponse.getHumidity()));
+
+ QuantityType outdoorTemperature = new QuantityType(
+ temperatureResponse.getOutdoorTemperature(), SIUnits.CELSIUS);
+ QuantityType indoorTemperature = new QuantityType(
+ temperatureResponse.getIndoorTemperature(), SIUnits.CELSIUS);
+
+ if (imperialUnits) {
+ indoorTemperature = Objects.requireNonNull(indoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
+ outdoorTemperature = Objects.requireNonNull(outdoorTemperature.toUnit(ImperialUnits.FAHRENHEIT));
+ }
+
+ updateChannel(CHANNEL_INDOOR_TEMPERATURE, indoorTemperature);
+ updateChannel(CHANNEL_OUTDOOR_TEMPERATURE, outdoorTemperature);
+ }
+
+ @Override
+ public void discovered(DiscoveryResult discoveryResult) {
+ logger.debug("Discovered {}", thing.getUID());
+ Map 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(CloudProvider cloudProvider) {
+ if (scheduledTask != null) {
+ stopScheduler();
+ }
+ logger.debug("Retrieving Token and/or Key from cloud");
+ Cloud cloud = new Cloud(config.email, config.password, cloudProvider, 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.debug("Token and Key obtained from cloud, saving, back to initialize");
+ initialize();
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.configuration_error_invalid_token ");
+ }
+ }
+
+ private void stopScheduler() {
+ ScheduledFuture> localScheduledTask = this.scheduledTask;
+
+ if (localScheduledTask != null && !localScheduledTask.isCancelled()) {
+ localScheduledTask.cancel(true);
+ logger.debug("Scheduled task cancelled.");
+ scheduledTask = null;
+ }
+ }
+
+ private void stopTokenKeyUpdate() {
+ ScheduledFuture> localScheduledTask = this.scheduledKeyTokenUpdate;
+
+ if (localScheduledTask != null && !localScheduledTask.isCancelled()) {
+ localScheduledTask.cancel(true);
+ logger.debug("Scheduled Key Token Update cancelled.");
+ scheduledKeyTokenUpdate = null;
+ }
+ }
+
+ private void stopEnergyUpdate() {
+ ScheduledFuture> localScheduledTask = this.scheduledEnergyUpdate;
+
+ if (localScheduledTask != null && !localScheduledTask.isCancelled()) {
+ localScheduledTask.cancel(true);
+ logger.debug("Scheduled Energy Update cancelled.");
+ scheduledEnergyUpdate = null;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ stopScheduler();
+ stopTokenKeyUpdate();
+ stopEnergyUpdate();
+ connectionManager.dispose(true);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Packet.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Packet.java
new file mode 100644
index 0000000000000..873f165082bf9
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Packet.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Response.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Response.java
new file mode 100644
index 0000000000000..8322cd583c39f
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Response.java
@@ -0,0 +1,320 @@
+/*
+ * 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;
+
+ private int getVersion() {
+ return version;
+ }
+
+ /**
+ * Response class Parameters
+ *
+ * @param data byte array from device
+ * @param version version of the device
+ */
+ public Response(byte[] data, int version) {
+ this.data = data;
+ this.version = version;
+
+ 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());
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("LED Display: {}", getDisplayOn());
+ logger.trace("Prompt Tone: {}", getPromptTone());
+ logger.trace("Appliance Error: {}", getApplianceError());
+ logger.trace("Auxiliary Heat: {}", getAuxHeat());
+ logger.trace("Fahrenheit: {}", getFahrenheit());
+ logger.trace("Maximum Humidity in Dry mode: {}", getMaximumHumidity());
+ logger.trace("Filter Notification: {}", getFilterStatus());
+ }
+ }
+
+ /**
+ * 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 (((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);
+ }
+ }
+
+ /**
+ * 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 Filter Status
+ * Documents show two possibles, hedged with both
+ *
+ * @return Filter needs changing = true
+ */
+ public boolean getFilterStatus() {
+ return ((data[13] & (byte) 0x20) != 0) || ((data[13] & (byte) 0x40) != 0);
+ }
+
+ /**
+ * Returns status of Device LEDs
+ * This is not affected when the IR controller turns
+ * them off
+ *
+ * @return LEDs on (true) or (false)
+ */
+ public boolean getDisplayOn() {
+ return (data[14] & (byte) 0x70) != (byte) 0x70;
+ }
+
+ /**
+ * This returns the maximum humidity for Dry mode, if supported
+ * Possibly an add-on sensor is required in some cases
+ *
+ * @return Maximum Humidity in Dry Mode
+ */
+ public int getMaximumHumidity() {
+ return (data[19] & (byte) 0x7f);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponse.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponse.java
new file mode 100644
index 0000000000000..d8c53ee6cf102
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponse.java
@@ -0,0 +1,98 @@
+/*
+ * 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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link TemperatureResponse} handles the unsolicited 0xA1 temperature report messages
+ * from the device
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class TemperatureResponse {
+ private final byte[] rawData;
+ private Logger logger = LoggerFactory.getLogger(TemperatureResponse.class);
+
+ /**
+ * Initialization
+ *
+ * @param rawData as bytes
+ */
+ public TemperatureResponse(byte[] rawData) {
+ this.rawData = rawData;
+ if (logger.isDebugEnabled()) {
+ logger.debug("Humidity from 0xA1: {}", getHumidity());
+ logger.debug("Indoor Temperature from 0xA1: {}", getIndoorTemperature());
+ logger.debug("Outdoor Temperature from 0xA1: {}", getOutdoorTemperature());
+ logger.debug("Current Work Time (minutes) from 0xA1: {}", getCurrentWorkTime()); // Not a channel
+ }
+ }
+
+ /**
+ * Reported Room Humidity from 0xA1 message type
+ *
+ * @return humidity
+ */
+ public int getHumidity() {
+ return (rawData[17] & (byte) 0x7f);
+ }
+
+ /**
+ * Current Work Time from 0xA1 message type
+ * not validated from test
+ *
+ * @return CurrentWorkTime
+ */
+ public int getCurrentWorkTime() {
+ return ((((rawData[9] & 0xFF) << 8) & 0xFF00) | (rawData[10] & 0x00FF)) * 60 * 24 + (rawData[11] & 0xFF) * 60
+ + (rawData[12] & 0xFF);
+ }
+
+ /**
+ * Reported indoor temperature from 0xA1 message type
+ *
+ * @return indoor temperature
+ */
+ public Float getIndoorTemperature() {
+ Float indoorTemperatureValue = (float) 0.0;
+ Float smallIndoorTemperatureValue = (float) 0.0;
+ Float indoorTemperature = (float) 0.0;
+ if ((rawData[13] & 0xFF) != 0x00 && (rawData[13] & 0xFF) != 0xFF) {
+ indoorTemperatureValue = (float) ((rawData[13] & 0xFF) - 50.0f) / 2.0f;
+ smallIndoorTemperatureValue = (float) (rawData[18] & 0x0F);
+ indoorTemperature = indoorTemperatureValue + smallIndoorTemperatureValue / 10f;
+ }
+ return indoorTemperature;
+ }
+
+ /**
+ * Reported Outdoor Temperature from 0xA1 message type
+ *
+ * @return outdoor temperature
+ */
+ public Float getOutdoorTemperature() {
+ Float outdoorTemperatureValue = (float) 0.0;
+ Float smallOutdoorTemperatureValue = (float) 0.0;
+ Float outdoorTemperature = (float) 0.0;
+ if ((rawData[14] & 0xFF) != 0x00 && (rawData[14] & 0xFF) != 0xFF) {
+ outdoorTemperatureValue = (float) ((rawData[14] & 0xFF) - 50.0f) / 2.0f;
+ smallOutdoorTemperatureValue = (float) ((rawData[18] & 0xFF) >>> 4);
+ outdoorTemperature = outdoorTemperatureValue + smallOutdoorTemperatureValue / 10.0f;
+ }
+ return outdoorTemperature;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Timer.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Timer.java
new file mode 100644
index 0000000000000..d448bd478e64b
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/Timer.java
@@ -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;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilitiesResponse.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilitiesResponse.java
new file mode 100644
index 0000000000000..04afe1958f798
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilitiesResponse.java
@@ -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.handler.capabilities;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link CapabilityResponse} handles the raw capability message
+ * from the device
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class CapabilitiesResponse {
+ private final byte[] rawData;
+
+ /**
+ * Initialization
+ *
+ * @param rawData as bytes
+ */
+ public CapabilitiesResponse(byte[] rawData) {
+ this.rawData = rawData;
+ }
+
+ public byte[] getRawData() {
+ return rawData;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParser.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParser.java
new file mode 100644
index 0000000000000..224758cecec80
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParser.java
@@ -0,0 +1,196 @@
+/*
+ * 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.capabilities;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link CapabilityParser} parses the capability Response into
+ * boolean and number responses. Decodes and reads the number responses (Temps)
+ * and decodes the boolean responses for the {@link CapabilityReaders}
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class CapabilityParser {
+ private final Logger logger = LoggerFactory.getLogger(CapabilityParser.class);
+ private final Map> capabilities = new HashMap<>();
+ private final Map> numericCapabilities = new HashMap<>();
+ private final int trailingBytes = 2; // We expect exactly 2 trailing bytes
+ private boolean additionalCapabilities = false;
+
+ public void parse(byte[] payload) {
+ // Check if the payload is empty or too short to process
+ if (payload.length < 2 + trailingBytes) {
+ return; // Exit the method without processing
+ }
+
+ // The first byte indicates the number of capabilities
+ int count = payload[1] & 0xFF; // Unsigned byte
+ int offset = 2; // Start after the count
+
+ while (offset < payload.length - trailingBytes && count-- > 0) {
+ if (offset + 3 > payload.length - trailingBytes) {
+ break; // Exit if there's insufficient data
+ }
+
+ // Size of the capability value
+ int size = payload[offset + 2] & 0xFF;
+ if (size == 0) {
+ offset += 3; // Skip empty capabilities
+ continue;
+ }
+
+ // Read the reversed 16-bit capability ID (little-endian)
+ int rawId = ByteBuffer.wrap(payload, offset, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() & 0xFFFF;
+
+ // Map the ID to a CapabilityId enum
+ CapabilityId capabilityId;
+ try {
+ capabilityId = CapabilityId.fromId(rawId);
+ } catch (IllegalArgumentException e) {
+ logger.debug("Unknown capability ID: {}, Size: {}", rawId, size);
+ offset += 3 + size; // Skip unknown capability
+ continue;
+ }
+
+ // Fetch the first value after the key and size
+ int value = payload[offset + 3] & 0xFF;
+
+ // Apply predefined capability readers if available
+ if (CapabilityReaders.hasReader(capabilityId)) {
+ CapabilityReaders.apply(capabilityId, value, capabilities);
+ } else if (capabilityId == CapabilityId.TEMPERATURES) {
+ if (size >= 6) {
+ numericCapabilities.put(capabilityId,
+ Map.of("coolMinTemperature", payload[offset + 3] * 0.5, "coolMaxTemperature",
+ payload[offset + 4] * 0.5, "autoMinTemperature", payload[offset + 5] * 0.5,
+ "autoMaxTemperature", payload[offset + 6] * 0.5, "heatMinTemperature",
+ payload[offset + 7] * 0.5, "heatMaxTemperature", payload[offset + 8] * 0.5));
+ }
+ } else if (capabilityId == CapabilityId._UNKNOWN) {
+ logger.debug("Ignored unknown capability ID: {}, Size: {}", rawId, size);
+ } else {
+ logger.debug("Unsupported capability {}, Size: {}", capabilityId, size);
+ }
+
+ offset += 3 + size; // Advance to the next capability
+ }
+
+ // Check for additional capability without interference from CRC8 and chksum
+ if (offset + 2 <= payload.length - trailingBytes) {
+ // Extract the two-byte additional capability flag
+ int additionalCapabilityFlag = ((payload[offset] & 0xFF) << 8) | (payload[offset + 1] & 0xFF);
+ // Check if the flag matches 0x0100
+ additionalCapabilities = (additionalCapabilityFlag == 0x0100);
+ }
+ logger.debug("Additional capabilities {}", additionalCapabilities);
+ }
+
+ public Map> getCapabilities() {
+ return capabilities;
+ }
+
+ public Map> getNumericCapabilities() {
+ return numericCapabilities;
+ }
+
+ public boolean hasAdditionalCapabilities() {
+ return additionalCapabilities;
+ }
+
+ /**
+ * From original source, kept notes
+ */
+ public enum CapabilityId {
+ SWING_UD_ANGLE(0x0009),
+ SWING_LR_ANGLE(0x000A),
+ BREEZELESS(0x0018), // AKA "No Wind Sense"
+ SMART_EYE(0x0030),
+ WIND_ON_ME(0x0032),
+ WIND_OFF_ME(0x0033),
+ SELF_CLEAN(0x0039), // AKA Active Clean
+ _UNKNOWN(0x0040), // Unknown ID from various logs
+ BREEZE_AWAY(0x0042), // AKA "Prevent Straight Wind"
+ BREEZE_CONTROL(0x0043), // AKA "FA No Wind Sense"
+ RATE_SELECT(0x0048),
+ FRESH_AIR(0x004B),
+ PARENT_CONTROL(0x0051), // ??
+ PREVENT_STRAIGHT_WIND_SELECT(0x0058), // ??
+ WIND_AROUND(0x0059), // ??
+ JET_COOL(0x0067), // ??
+ PRESET_IECO(0x00E3),
+ ICHECK(0x0091), // ??
+ EMERGENT_HEAT_WIND(0x0093), // ??
+ HEAT_PTC_WIND(0x0094), // ??
+ CVP(0x0098), // ??
+ FAN_SPEED_CONTROL(0x0210),
+ PRESET_ECO(0x0212),
+ PRESET_FREEZE_PROTECTION(0x0213),
+ MODES(0x0214),
+ SWING_MODES(0x0215),
+ ENERGY(0x0216), // AKA electricity
+ FILTER_REMIND(0x0217),
+ AUX_ELECTRIC_HEAT(0x0219), // AKA PTC
+ PRESET_TURBO(0x021A),
+ FILTER_CHECK(0x0221),
+ ANION(0x021E),
+ HUMIDITY(0x021F),
+ FAHRENHEIT(0x0222),
+ DISPLAY_CONTROL(0x0224),
+ TEMPERATURES(0x0225),
+ BUZZER(0x022C), // Reference refers to this as "sound". Is this different then beep?
+ MAIN_HORIZONTAL_GUIDE_STRIP(0x0230), // ??
+ SUP_HORIZONTAL_GUIDE_STRIP(0x0231), // ??
+ TWINS_MACHINE(0x0232), // ??
+ GUIDE_STRIP_TYPE(0x0233), // ??
+ BODY_CHECK(0x0234); // ??
+
+ private final int id;
+
+ CapabilityId(int id) {
+ this.id = id;
+ }
+
+ /**
+ * Gets ID
+ *
+ * @return Id from enum
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * Gets String name of capability
+ *
+ * @param id of enum
+ * @return String Name
+ */
+ public static CapabilityId fromId(int id) {
+ for (CapabilityId capability : values()) {
+ if (capability.id == id) {
+ return capability;
+ }
+ }
+ throw new IllegalArgumentException("Unknown Capability ID: " + id);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityReaders.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityReaders.java
new file mode 100644
index 0000000000000..ad06f8dd49701
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityReaders.java
@@ -0,0 +1,132 @@
+/*
+ * 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.capabilities;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilityParser.CapabilityId;
+
+/**
+ * The {@link CapabilityReaders} reads the raw capability message and
+ * breaks them into detailed capabilities.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class CapabilityReaders {
+ private static final Map> READERS = new HashMap<>();
+
+ static {
+ // Helper to simplify creation of READERS
+ Function> getValue = (expected) -> (value) -> Objects.equals(value, expected);
+
+ // Add READERS for each capability - Not all are supported by all AC devices
+ READERS.put(CapabilityId.ANION, List.of(new Reader("anion", getValue.apply(1))));
+ READERS.put(CapabilityId.AUX_ELECTRIC_HEAT, List.of(new Reader("auxElectricHeat", getValue.apply(1))));
+ READERS.put(CapabilityId.BREEZE_AWAY, List.of(new Reader("breezeAway", getValue.apply(1))));
+ READERS.put(CapabilityId.BREEZE_CONTROL, List.of(new Reader("breezeControl", getValue.apply(1))));
+ READERS.put(CapabilityId.BREEZELESS, List.of(new Reader("breezeless", getValue.apply(1))));
+ READERS.put(CapabilityId.BUZZER, List.of(new Reader("buzzer", getValue.apply(1))));
+
+ READERS.put(CapabilityId.DISPLAY_CONTROL,
+ List.of(new Reader("displayControl", (value) -> List.of(1, 2, 100).contains(value))));
+
+ READERS.put(CapabilityId.ENERGY,
+ List.of(new Reader("energyStats", (value) -> List.of(2, 3, 4, 5).contains(value)),
+ new Reader("energySetting", (value) -> List.of(3, 5).contains(value)),
+ new Reader("energyBCD", (value) -> List.of(2, 3).contains(value))));
+
+ READERS.put(CapabilityId.FAHRENHEIT, List.of(new Reader("fahrenheit", getValue.apply(0))));
+
+ READERS.put(CapabilityId.FAN_SPEED_CONTROL,
+ List.of(new Reader("fanSilent", getValue.apply(6)),
+ new Reader("fanLow", (value) -> List.of(3, 4, 5, 6, 7).contains(value)),
+ new Reader("fanMedium", (value) -> List.of(5, 6, 7).contains(value)),
+ new Reader("fanHigh", (value) -> List.of(3, 4, 5, 6, 7).contains(value)),
+ new Reader("fanAuto", (value) -> List.of(4, 5, 6).contains(value)),
+ new Reader("fanCustom", getValue.apply(1))));
+
+ READERS.put(CapabilityId.FILTER_REMIND,
+ List.of(new Reader("filterNotice", (value) -> List.of(1, 2, 4).contains(value)),
+ new Reader("filterClean", (value) -> List.of(3, 4).contains(value))));
+
+ READERS.put(CapabilityId.HUMIDITY,
+ List.of(new Reader("humidityAutoSet", (value) -> List.of(1, 2).contains(value)),
+ new Reader("humidityManualSet", (value) -> List.of(2, 3).contains(value))));
+
+ READERS.put(CapabilityId.MODES,
+ List.of(new Reader("modeHeat", (value) -> List.of(1, 2, 4, 6, 7, 9, 10, 11, 12, 13).contains(value)),
+ new Reader("modeCool", (value) -> !List.of(2, 10, 12).contains(value)),
+ new Reader("modeDry", (value) -> List.of(0, 1, 5, 6, 9, 11, 13).contains(value)),
+ new Reader("modeAuto", (value) -> List.of(0, 1, 2, 7, 8, 9, 13).contains(value)),
+ new Reader("modeAuxHeat", getValue.apply(9)),
+ new Reader("modeAux", (value) -> List.of(9, 10, 11, 13).contains(value))));
+
+ READERS.put(CapabilityId.PRESET_ECO, List.of(new Reader("ecoCool", (value) -> List.of(1, 2).contains(value))));
+ READERS.put(CapabilityId.PRESET_FREEZE_PROTECTION, List.of(new Reader("freezeProtection", getValue.apply(1))));
+ READERS.put(CapabilityId.PRESET_IECO, List.of(new Reader("ieco", getValue.apply(1))));
+
+ READERS.put(CapabilityId.PRESET_TURBO,
+ List.of(new Reader("turboHeat", (value) -> List.of(1, 3).contains(value)),
+ new Reader("turboCool", (value) -> value < 2)));
+
+ READERS.put(CapabilityId.RATE_SELECT, List.of(new Reader("rate_select_2_level", getValue.apply(1)),
+ new Reader("rateSelect5Level", (value) -> List.of(2, 3).contains(value))));
+
+ READERS.put(CapabilityId.SELF_CLEAN, List.of(new Reader("selfClean", getValue.apply(1))));
+ READERS.put(CapabilityId.SMART_EYE, List.of(new Reader("smartEye", getValue.apply(1))));
+ READERS.put(CapabilityId.SWING_LR_ANGLE, List.of(new Reader("swingHorizontalAngle", getValue.apply(1))));
+ READERS.put(CapabilityId.SWING_UD_ANGLE, List.of(new Reader("swingVerticalAngle", getValue.apply(1))));
+
+ READERS.put(CapabilityId.SWING_MODES,
+ List.of(new Reader("swingHorizontal", (value) -> List.of(1, 3).contains(value)),
+ new Reader("swingVertical", (value) -> value < 2)));
+
+ READERS.put(CapabilityId.WIND_OFF_ME, List.of(new Reader("windOffMe", getValue.apply(1))));
+ READERS.put(CapabilityId.WIND_ON_ME, List.of(new Reader("windOnMe", getValue.apply(1))));
+ }
+
+ /**
+ * Validates if Reader exists for the capability
+ *
+ * @param id capability id
+ * @return true or false
+ */
+ public static boolean hasReader(CapabilityId id) {
+ return READERS.containsKey(id);
+ }
+
+ /**
+ * Applies the appropriate Reader
+ *
+ * @param id id
+ * @param value value from reader
+ * @param capabilities summary
+ */
+ public static void apply(CapabilityId id, int value, Map> capabilities) {
+ Optional.ofNullable(READERS.get(id)).ifPresent(readersList -> {
+ Map result = readersList.stream()
+ .map(reader -> Map.entry(reader.name, reader.predicate.test(value)))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ capabilities.put(id, result);
+ });
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/Reader.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/Reader.java
new file mode 100644
index 0000000000000..12a29c9479c3c
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/handler/capabilities/Reader.java
@@ -0,0 +1,35 @@
+/*
+ * 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.capabilities;
+
+import java.util.function.Predicate;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link Reader} reads the raw capability message and
+ * breaks them down for further parsing.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class Reader {
+ public final String name;
+ public final Predicate predicate;
+
+ public Reader(String name, Predicate predicate) {
+ this.name = name;
+ this.predicate = predicate;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Crc8.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Crc8.java
new file mode 100644
index 0000000000000..6b24ccb0bbbd2
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Crc8.java
@@ -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.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 = (crcValue ^ m) & 0xFF;
+ crcValue = CRC8_854_TABLE[k];
+ }
+ return crcValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Decryption8370Result.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Decryption8370Result.java
new file mode 100644
index 0000000000000..1044a6ec60a27
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Decryption8370Result.java
@@ -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 additions
+ */
+@NonNullByDefault
+public class Decryption8370Result {
+ /**
+ * Set up for decryption
+ *
+ * @return responses
+ */
+ public ArrayList getResponses() {
+ return responses;
+ }
+
+ /**
+ * Buffer
+ *
+ * @return buffer
+ */
+ public byte[] getBuffer() {
+ return buffer;
+ }
+
+ ArrayList responses;
+ byte[] buffer;
+
+ /**
+ * Decryption result
+ *
+ * @param responses responses
+ * @param buffer buffer
+ */
+ public Decryption8370Result(ArrayList responses, byte[] buffer) {
+ super();
+ this.responses = responses;
+ this.buffer = buffer;
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Security.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Security.java
new file mode 100644
index 0000000000000..cfc8488bcdb26
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/Security.java
@@ -0,0 +1,629 @@
+/*
+ * 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.cloud.CloudProvider;
+import org.openhab.core.util.HexUtils;
+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 });
+
+ CloudProvider cloudProvider;
+
+ /**
+ * Set Cloud Provider to get provider specific keys
+ *
+ * @param cloudProvider Name of Cloud provider
+ */
+ public Security(CloudProvider 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) {
+ logger.warn("Encode32 Data: NoSuchAlgorithmException {}", e.getMessage());
+ }
+ 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: {}", HexUtils.bytesToHex(finalHeader));
+
+ if (msgtype == MsgType.MSGTYPE_ENCRYPTED_RESPONSE || msgtype == MsgType.MSGTYPE_ENCRYPTED_REQUEST) {
+ byte[] sign = sha256(Utils.concatenateArrays(finalHeader, finalData));
+ logger.trace("Sign: {}", HexUtils.bytesToHex(sign));
+ logger.trace("TcpKey: {}", HexUtils.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(), data);
+ }
+ byte[] header = Arrays.copyOfRange(data, 0, 6);
+ logger.trace("Header: {}", HexUtils.bytesToHex(header));
+ if (header[0] != (byte) 0x83 || header[1] != (byte) 0x70) {
+ logger.warn("Not an 8370 message");
+ return new Decryption8370Result(new ArrayList(), 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(), 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: {}", HexUtils.bytesToHex(sign));
+ logger.trace("SignLocal: {}", HexUtils.bytesToHex(signLocal));
+ logger.trace("TcpKey: {}", HexUtils.bytesToHex(tcpKey));
+ logger.trace("Data: {}", HexUtils.bytesToHex(data));
+
+ if (!Arrays.equals(sign, signLocal)) {
+ logger.warn("Sign does not match");
+ return new Decryption8370Result(new ArrayList(), data);
+ }
+
+ if (padding > 0) {
+ data = Arrays.copyOfRange(data, 0, data.length - padding);
+ }
+ } else {
+ logger.debug("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 responses = r.getResponses();
+ responses.add(0, data);
+ return new Decryption8370Result(responses, r.buffer);
+ }
+
+ ArrayList responses = new ArrayList();
+ 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: {}", HexUtils.bytesToHex(payload));
+ logger.trace("Sign: {}", HexUtils.bytesToHex(sign));
+ logger.trace("SignLocal: {}", HexUtils.bytesToHex(signLocal));
+ logger.trace("Plain: {}", HexUtils.bytesToHex(plain));
+
+ if (!Arrays.equals(sign, signLocal)) {
+ logger.warn("Sign does not match");
+ return false;
+ }
+ tcpKey = Utils.strxor(plain, key);
+ logger.trace("TcpKey: {}", HexUtils.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;
+ }
+
+ /**
+ * Encodes the sign for a non-proxy Cloud provider Request
+ * using the url and the payload into a lower case hex string
+ *
+ * @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, true);
+
+ String sign = path + query + cloudProvider.appkey();
+ logger.trace("sign: {}", sign);
+ return HexUtils.bytesToHex(sha256(sign.getBytes(StandardCharsets.US_ASCII))).toLowerCase();
+ } catch (URISyntaxException e) {
+ logger.warn("Error parsing URI '{}': {}", url, e.getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * Encodes the sign for a proxied Cloud provider Request
+ * using the iotkey and a random lower case hex string
+ *
+ * @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 HexUtils.bytesToHex(mac.doFinal(data.getBytes())).toLowerCase();
+ }
+
+ /**
+ * Encrypts password for cloud API using SHA-256
+ *
+ * @param loginId Login ID
+ * @param password Login password
+ * @return lower case byte 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 + HexUtils.bytesToHex(m.digest()).toLowerCase() + cloudProvider.appkey();
+ m = MessageDigest.getInstance("SHA-256");
+ m.update(loginHash.getBytes(StandardCharsets.US_ASCII));
+ return HexUtils.bytesToHex(m.digest()).toLowerCase();
+ } catch (NoSuchAlgorithmException e) {
+ logger.warn("encryptPassword error: NoSuchAlgorithmException: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Encrypts password for cloud API using MD5 for proxied provider
+ *
+ * @param loginId Login ID
+ * @param password Login password
+ * @return lower case byte 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((HexUtils.bytesToHex(md.digest()).toLowerCase()).getBytes(StandardCharsets.US_ASCII));
+
+ String loginHash = loginId + HexUtils.bytesToHex(mdSecond.digest()).toLowerCase() + cloudProvider.appkey();
+ return HexUtils.bytesToHex(sha256(loginHash.getBytes(StandardCharsets.US_ASCII))).toLowerCase();
+ } catch (NoSuchAlgorithmException e) {
+ logger.warn("encryptIamPasswordt error: NoSuchAlgorithmException: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Gets Udpid from byte data
+ * This is based on the Device Id
+ *
+ * @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 HexUtils.bytesToHex(b3).toLowerCase();
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/TokenKey.java b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/TokenKey.java
new file mode 100644
index 0000000000000..ff279c514da99
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/java/org/openhab/binding/mideaac/internal/security/TokenKey.java
@@ -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 convert to record
+ */
+@NonNullByDefault
+public record TokenKey(String token, String key) {
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 0000000000000..1ee28925ed16d
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,21 @@
+
+
+
+ binding
+ MideaAC Binding
+ This is the binding for MideaAC.
+ local
+
+
+ mdns
+
+
+ mdnsServiceType
+ _mideaair._tcp.local.
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/i18n/mideaac.properties b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/i18n/mideaac.properties
new file mode 100644
index 0000000000000..26031658b078d
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/i18n/mideaac.properties
@@ -0,0 +1,112 @@
+# 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.energyDecode.label = Energy Response Decode Method
+thing-type.config.mideaac.ac.energyDecode.description = Binary-Coded Decimal (BCD) = true. Big-endian = false.
+thing-type.config.mideaac.ac.energyPoll.label = Energy Poll
+thing-type.config.mideaac.ac.energyPoll.description = Energy polling in minutes. default 0 ; (in case not supported).
+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, password for Cloud account to retrieve it).
+thing-type.config.mideaac.ac.keyTokenUpdate.label = Key Token Update
+thing-type.config.mideaac.ac.keyTokenUpdate.description = Update the Key and Token from the cloud in hours, min 24h, default 0 to disable.
+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 = Poll Frequency
+thing-type.config.mideaac.ac.pollingTime.description = Base device poll in seconds. Minimum is 30, default 60.
+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 = Socket Timeout
+thing-type.config.mideaac.ac.timeout.description = Connecting socket timeout (seconds). Minimum is 2, maximum is 10 (4 is 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, password for Cloud account 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.appliance-error.label = Appliance Error
+channel-type.mideaac.auxiliary-heat.label = Auxiliary Heat
+channel-type.mideaac.current-draw.label = Amperes
+channel-type.mideaac.current-draw.description = Amperes (current) reported by the indoor unit.
+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.energy-consumption.label = Kilowatt Hours
+channel-type.mideaac.energy-consumption.description = kilowatt Hours reported by the indoor unit.
+channel-type.mideaac.fan-speed.label = Fan Speed
+channel-type.mideaac.fan-speed.description = Fan speeds: 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.filter-status.label = Filter Needs Cleaning
+channel-type.mideaac.humidity.label = Humidity
+channel-type.mideaac.humidity.description = Humidity in room (if supported).
+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.maximum-humidity.label = Maximum Humidity
+channel-type.mideaac.maximum-humidity.description = Set Maximum Humidity level for Dry Mode (if supported).
+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 modes: AUTO, COOL, DRY, HEAT, FAN ONLY.
+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-consumption.label = Watts
+channel-type.mideaac.power-consumption.description = Watts reported by the indoor unit.
+channel-type.mideaac.power.label = Power
+channel-type.mideaac.power.description = Turn the AC on or 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 modes: 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.temperature-unit.label = Temperature Unit F/C
+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.
+
+# thing status descriptions
+
+offline.configuration_pending_discovery = Retrieving required parameters via device discovery
+offline.communication_error_discovery = Could not retrieve required parameters via device discovery
+offline.configuration_error_invalid_discovery = Invalid discovery configuration parameters
+offline.configuration_pending_token = Retrieving required parameters from the cloud provider
+offline.communication_error_token = Could not retrieve required parameters from the cloud
+offline.configuration_error_invalid_token = Invalid cloud configuration parameters provided
diff --git a/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 0000000000000..2701a40881fb1
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,390 @@
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ipAddress
+
+
+
+ network-address
+
+ IP Address of the device.
+
+
+
+ IP port of the device.
+ 6444
+ true
+
+
+
+ ID of the device. Leave 0 to do ID discovery.
+ 0
+ true
+
+
+
+ Cloud Provider name for email and password.
+
+
+
+
+
+
+ true
+ NetHome Plus
+
+
+ email
+
+ Email for cloud account chosen in Cloud Provider.
+ nethome+us@mailinator.com
+
+
+ password
+
+ Password for cloud account chosen in Cloud Provider.
+ password1
+
+
+
+ Secret Token (length 128 HEX) used for secure connection authentication used with devices v3 (if not
+ known, enter email, password for Cloud account to retrieve it).
+ true
+
+
+
+ Secret Key (length 64 HEX) used for secure connection authentication used with devices v3 (if not
+ known, enter email, password for Cloud account to retrieve it).
+ true
+
+
+
+ Base device poll in seconds. Minimum is 30, default 60.
+ 60
+
+
+
+ Energy polling in minutes. default 0 ; (in case not supported).
+ 0
+
+
+
+ Update the Key and Token from the cloud in hours, min 24h, default 0 to disable.
+ 0
+ true
+
+
+
+ Connecting socket timeout (seconds). Minimum is 2, maximum is 10 (4 is default).
+ 4
+ true
+
+
+
+ After sending a command device will play "ding" tone when command is received and executed.
+ false
+
+
+
+ Version 3 requires Token, Key and Cloud provider. Version 2 doesn't.
+ 3
+ true
+
+
+
+ Binary-Coded Decimal (BCD) = true. Big-endian = false.
+ true
+ true
+
+
+
+
+
+
+ Switch
+
+ Turn the AC on or off.
+ Switch
+
+ Switch
+ Power
+
+
+
+ Number:Temperature
+
+ Temperature
+
+ Setpoint
+ Temperature
+
+
+
+
+ String
+
+ Operational modes: AUTO, COOL, DRY, HEAT, FAN ONLY.
+
+ Control
+ Mode
+
+
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Fan speeds: SILENT, LOW, MEDIUM, HIGH, FULL, AUTO.
+
+ Control
+ Airflow
+
+
+
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Swing modes: OFF, VERTICAL, HORIZONTAL, BOTH. Some V3 versions do not support
+
+ Control
+ Tilt
+
+
+
+
+
+
+
+
+
+
+
+ Switch
+
+ Eco mode, Cool only, Temp: min. 24C, Fan: AUTO.
+ Switch
+
+ Switch
+ Airconditioning
+
+
+
+ Switch
+
+ Turbo mode, "Boost" in Midea Air app, long press "+" on IR Remote Controller. Only works in COOL and HEAT
+ mode.
+ Switch
+
+ Switch
+ Airconditioning
+
+
+
+ Number:Temperature
+
+ Indoor temperature measured by the internal unit. Not frequent when unit is off
+ Temperature
+
+ Measurement
+ Temperature
+
+
+
+
+ Number:Temperature
+
+ Outdoor temperature from the external unit. Not frequent when unit is off
+ Temperature
+
+ Measurement
+ Temperature
+
+
+
+
+ Switch
+
+ Sleep function ("Moon with a star" icon on IR Remote Controller).
+ Switch
+
+ Switch
+ Airconditioning
+
+
+
+ Switch
+
+ On = Farenheit on Indoor AC unit LED display, Off = Celsius.
+ Switch
+
+ Switch
+ Enabled
+
+
+
+ Switch
+
+ Status of LEDs on the device. Not all models work on LAN (only IR). No confirmation
+ possible either.
+ Switch
+
+ Switch
+ Brightness
+
+
+
+ Switch
+
+ Switch
+
+ Alarm
+ Airconditioning
+
+
+
+
+ Switch
+
+ Switch
+
+ Alarm
+ Airflow
+
+
+
+
+ String
+
+ On Timer (HH:MM) to set.
+
+ Control
+ Duration
+
+
+
+ String
+
+ Off Timer (HH:MM) to set.
+
+ Control
+ Duration
+
+
+
+ Switch
+
+ Switch
+
+ Status
+ Heating
+
+
+
+
+ Number
+
+ Set Maximum Humidity level for Dry Mode (if supported).
+ Humidity
+
+ Control
+ Humidity
+
+
+
+
+ Number
+
+ Humidity in room (if supported).
+ Humidity
+
+ Measurement
+ Humidity
+
+
+
+
+ Number
+
+ kilowatt Hours reported by the indoor unit.
+ Energy
+
+ Measurement
+ Energy
+
+
+
+
+ Number
+
+ Amperes (current) reported by the indoor unit.
+ Energy
+
+ Measurement
+ Energy
+
+
+
+
+ Number
+
+ Watts reported by the indoor unit.
+ Energy
+
+ Measurement
+ Energy
+
+
+
+
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/MideaACConfigurationTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/MideaACConfigurationTest.java
new file mode 100644
index 0000000000000..c7e92ba1c9a9a
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/MideaACConfigurationTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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} Midea AC
+ * Configuration methods
+ *
+ * @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());
+ assertTrue(config.isDiscoveryPossible());
+ }
+
+ /**
+ * 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.isDiscoveryPossible());
+ }
+
+ /**
+ * 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());
+ assertFalse(config.isDiscoveryPossible());
+ }
+
+ /**
+ * 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);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/cloud/CloudTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/cloud/CloudTest.java
new file mode 100644
index 0000000000000..ad553321cad5f
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/cloud/CloudTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.cloud;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+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.HttpFields;
+import org.eclipse.jetty.http.HttpMethod;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The {@link CloudTest} tests the methods in the Cloud
+ * class with mock responses.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class CloudTest {
+
+ @Test
+ public void testLogin() throws Exception {
+ // Mock HttpClient and ContentResponse
+ HttpClient mockHttpClient = mock(HttpClient.class);
+ Request mockRequest = mock(Request.class);
+ ContentResponse mockResponse = mock(ContentResponse.class);
+ HttpFields mockHeaders = mock(HttpFields.class);
+
+ // Define behavior of HttpFields
+ when(mockHeaders.toString()).thenReturn("Mocked Headers");
+
+ // Mock fluent methods of Request
+ when(mockHttpClient.newRequest(anyString())).thenReturn(mockRequest);
+ when(mockRequest.method(HttpMethod.POST)).thenReturn(mockRequest);
+ when(mockRequest.timeout(anyLong(), any(TimeUnit.class))).thenReturn(mockRequest);
+ when(mockRequest.content(any(StringContentProvider.class))).thenReturn(mockRequest);
+ when(mockRequest.getHeaders()).thenReturn(mockHeaders); // Attach mocked headers
+ when(mockRequest.send()).thenReturn(mockResponse);
+
+ // Define behavior of ContentResponse
+ when(mockResponse.getStatus()).thenReturn(200);
+ when(mockResponse.getContentAsString()).thenReturn(
+ "{\"msg\": \"ok\", \"result\": {\"accessToken\": \"mock-token\", \"sessionId\": \"mock-session-id\"}, \"errorCode\": \"0\"}");
+
+ // Create a CloudProvider instance (replace arguments with appropriate values)
+ CloudProvider provider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
+ "https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+
+ // Inject the mocked HttpClient into the Cloud class
+ Cloud cloud = new Cloud("email", "password", provider, mockHttpClient);
+
+ // Set loginId using reflection so that the getLoginId() check doesn't trigger
+ Field loginIdField = Cloud.class.getDeclaredField("loginId");
+ loginIdField.setAccessible(true);
+ loginIdField.set(cloud, "mock-loginId");
+
+ // Execute the login method
+ boolean login = cloud.login();
+
+ // Assert the result
+ assertTrue(login);
+
+ // Verify that accessToken is returned
+ Field accessTokenField = Cloud.class.getDeclaredField("accessToken");
+ accessTokenField.setAccessible(true);
+ assertEquals("mock-token", accessTokenField.get(cloud));
+
+ // Verify that accessToken is returned
+ Field sessionIdField = Cloud.class.getDeclaredField("sessionId");
+ sessionIdField.setAccessible(true);
+ assertEquals("mock-session-id", sessionIdField.get(cloud));
+ }
+
+ @Test
+ public void testLoginproxy() throws Exception {
+ // Mock HttpClient and ContentResponse
+ HttpClient mockHttpClient = mock(HttpClient.class);
+ Request mockRequest = mock(Request.class);
+ ContentResponse mockResponse = mock(ContentResponse.class);
+ HttpFields mockHeaders = mock(HttpFields.class);
+
+ // Define behavior of HttpFields
+ when(mockHeaders.toString()).thenReturn("Mocked Headers");
+
+ // Mock fluent methods of Request
+ when(mockHttpClient.newRequest(anyString())).thenReturn(mockRequest);
+ when(mockRequest.method(HttpMethod.POST)).thenReturn(mockRequest);
+ when(mockRequest.timeout(anyLong(), any(TimeUnit.class))).thenReturn(mockRequest);
+ when(mockRequest.content(any(StringContentProvider.class))).thenReturn(mockRequest);
+ when(mockRequest.getHeaders()).thenReturn(mockHeaders); // Attach mocked headers
+ when(mockRequest.send()).thenReturn(mockResponse);
+
+ // Define behavior of ContentResponse
+ when(mockResponse.getStatus()).thenReturn(200);
+ when(mockResponse.getContentAsString()).thenReturn(
+ "{\"msg\":\"ok\",\"data\":{\"mdata\":{\"accessToken\":\"mock-token\"}},\"errorCode\":\"0\"}");
+
+ // Create a CloudProvider instance (replace arguments with appropriate values)
+ CloudProvider provider = new CloudProvider("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "1010",
+ "https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S",
+ "meicloud", "PROD_VnoClJI9aikS8dyy", "v5");
+
+ // Inject the mocked HttpClient into the Cloud class
+ Cloud cloud = new Cloud("email", "password", provider, mockHttpClient);
+
+ // Set loginId using reflection so that the getLoginId() check doesn't trigger
+ Field loginIdField = Cloud.class.getDeclaredField("loginId");
+ loginIdField.setAccessible(true);
+ loginIdField.set(cloud, "mock-loginId");
+
+ // Execute the login method
+ boolean login = cloud.login();
+
+ // Assert the result
+ assertTrue(login);
+
+ // Verify that accessToken is returned
+ Field accessTokenField = Cloud.class.getDeclaredField("accessToken");
+ accessTokenField.setAccessible(true);
+ assertEquals("mock-token", accessTokenField.get(cloud));
+ }
+
+ @Test
+ public void testLoginWithSessionId() throws Exception {
+ // Create a CloudProvider instance
+ CloudProvider provider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
+ "https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+
+ // Create the Cloud class
+ HttpClient mockHttpClient = mock(HttpClient.class);
+ Cloud cloud = new Cloud("email", "password", provider, mockHttpClient);
+
+ // Set loginId using reflection so that the getLoginId() check doesn't trigger
+ Field loginIdField = Cloud.class.getDeclaredField("loginId");
+ loginIdField.setAccessible(true);
+ loginIdField.set(cloud, "mock-loginId");
+
+ // Set sessionId using reflection
+ Field sessionIdField = Cloud.class.getDeclaredField("sessionId");
+ sessionIdField.setAccessible(true);
+ sessionIdField.set(cloud, "mock-session-id"); // Set sessionId to trigger early exit
+
+ // Execute the login method
+ boolean login = cloud.login();
+
+ // Assert the result
+ assertTrue(login); // Validate early exit with sessionId
+ }
+
+ @Test
+ public void testGetLoginId() throws Exception {
+ // Mock HttpClient and dependent objects
+ HttpClient mockHttpClient = mock(HttpClient.class);
+ Request mockRequest = mock(Request.class);
+ ContentResponse mockResponse = mock(ContentResponse.class);
+ HttpFields mockHeaders = mock(HttpFields.class);
+
+ // Define behavior for HttpFields
+ when(mockHeaders.toString()).thenReturn("Mocked Headers");
+
+ // Properly configure the fluent chain for Request
+ when(mockHttpClient.newRequest(any(String.class))).thenReturn(mockRequest);
+ when(mockRequest.method(HttpMethod.POST)).thenReturn(mockRequest);
+ when(mockRequest.timeout(any(Long.class), any(TimeUnit.class))).thenReturn(mockRequest);
+ when(mockRequest.content(any(StringContentProvider.class))).thenReturn(mockRequest);
+ when(mockRequest.getHeaders()).thenReturn(mockHeaders);
+ when(mockRequest.send()).thenReturn(mockResponse);
+
+ // Define behavior for ContentResponse
+ when(mockResponse.getStatus()).thenReturn(200);
+ // Include "code": 0 to simulate a successful response
+ when(mockResponse.getContentAsString())
+ .thenReturn("{\"msg\": \"ok\", \"result\": {\"loginId\": \"mock-loginId\"}, \"errorCode\": \"0\"}");
+
+ // Create a CloudProvider instance
+ CloudProvider provider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "1017",
+ "https://mapp.appsmb.com", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S", "", "", "");
+
+ // Inject the mocked HttpClient into the Cloud class
+ Cloud cloud = new Cloud("email", "password", provider, mockHttpClient);
+
+ // Execute the getLoginId method
+ boolean getLogin = cloud.getLoginId();
+
+ // Assert the result
+ assertTrue(getLogin); // Validate that getLoginId returned true
+
+ // Verify that loginId was set correctly
+ Field loginIdField = Cloud.class.getDeclaredField("loginId");
+ loginIdField.setAccessible(true);
+ assertEquals("mock-loginId", loginIdField.get(cloud)); // Ensure loginId is correctly set
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryServiceTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryServiceTest.java
new file mode 100644
index 0000000000000..4f28c5f940ad2
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/discovery/MideaACDiscoveryServiceTest.java
@@ -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.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;
+import org.openhab.core.util.HexUtils;
+
+/**
+ * The {@link MideaACDiscoveryServiceTest} tests the discovery byte arrays
+ * (reply string already decrypted - See SecurityTest)
+ * against the methods in the MideaACDiscoveryService for correctness
+ *
+ * @author Robert Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class MideaACDiscoveryServiceTest {
+
+ byte[] data = HexFormat.of().parseHex(
+ "837000C8200F00005A5A0111B8007A80000000006B0925121D071814C0110800008A0000000000000000018000000000AF55C8897BEA338348DA7FC0B3EF1F1C889CD57C06462D83069558B66AF14A2D66353F52BAECA68AEB4C3948517F276F72D8A3AD4652EFA55466D58975AEB8D948842E20FBDCA6339558C848ECE09211F62B1D8BB9E5C25DBA7BF8E0CC4C77944BDFB3E16E33D88768CC4C3D0658937D0BB19369BF0317B24D3A4DE9E6A13106AFFBBE80328AEA7426CD6BA2AD8439F72B4EE2436CC634040CB976A92A53BCD5");
+ byte[] reply = HexFormat.of().parseHex(
+ "E600A8C02C19000030303030303050303030303030305131423838433239353634334243303030300B6E65745F61635F343342430000870002000000000000000000AC00ACAC00000000B88C295643BC150023082122000300000000000000000000000000000000000000000000000000000000000000000000");
+ String mSmartId = "", mSmartVersion = "", mSmartip = "", mSmartPort = "", mSmartSN = "", mSmartSSID = "",
+ mSmartType = "";
+
+ /**
+ * Test AC Version
+ */
+ @Test
+ public void testVersion() {
+ if (HexUtils.bytesToHex(Arrays.copyOfRange(data, 0, 2)).equals("8370")) {
+ mSmartVersion = "3";
+ } else {
+ mSmartVersion = "2";
+ }
+ assertEquals("3", mSmartVersion);
+ }
+
+ /**
+ * Test AC Id
+ */
+ @Test
+ public void testId() {
+ if (HexUtils.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 AC 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.230", mSmartip);
+ }
+
+ /**
+ * Test AC Device Port
+ */
+ @Test
+ public void testPort() {
+ BigInteger portId = new BigInteger(Utils.reverse(Arrays.copyOfRange(reply, 4, 8)));
+ mSmartPort = portId.toString();
+ assertEquals("6444", mSmartPort);
+ }
+
+ /**
+ * Test AC serial Number
+ */
+ @Test
+ public void testSN() {
+ mSmartSN = new String(reply, 8, 40 - 8, StandardCharsets.UTF_8);
+ assertEquals("000000P0000000Q1B88C295643BC0000", mSmartSN);
+ }
+
+ /**
+ * Test AC response SSID Conversion
+ */
+ @Test
+ public void testSSID() {
+ mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
+ assertEquals("net_ac_43BC", mSmartSSID);
+ }
+
+ /**
+ * Test Type - Only ac supported
+ */
+ @Test
+ public void testType() {
+ mSmartSSID = new String(reply, 41, reply[40], StandardCharsets.UTF_8);
+ mSmartType = mSmartSSID.split("_")[1];
+ assertEquals("ac", mSmartType);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/CommandSetTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/CommandSetTest.java
new file mode 100644
index 0000000000000..acd37155b8869
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/CommandSetTest.java
@@ -0,0 +1,309 @@
+/*
+ * 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} tests the methods in the CommandSet class
+ * for correctness.
+ *
+ * @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) 0x61, commandSet.data[0x0b]);
+ 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);
+ }
+
+ /**
+ * Energy poll command Test
+ *
+ */
+ @Test
+ public void testEnergyPoll() {
+ CommandSet commandSet = new CommandSet();
+ commandSet.energyPoll();
+
+ // Check the modified bytes
+ assertEquals((byte) 0x20, commandSet.data[0x01]);
+ assertEquals((byte) 0x03, commandSet.data[0x09]);
+ assertEquals((byte) 0x41, commandSet.data[0x0a]);
+ assertEquals((byte) 0x21, commandSet.data[0x0b]);
+ assertEquals((byte) 0x01, commandSet.data[0x0c]);
+ assertEquals((byte) 0x44, commandSet.data[0x0d]);
+ assertEquals((byte) 0x00, commandSet.data[0x0e]);
+ assertEquals((byte) 0x00, commandSet.data[0x0f]);
+ assertEquals((byte) 0x00, 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);
+ }
+
+ /**
+ * Capabilities Command Test
+ *
+ */
+ @Test
+ public void testCapabilities() {
+ CommandSet commandSet = new CommandSet();
+ commandSet.getCapabilities();
+
+ // Check the modified bytes
+ assertEquals((byte) 0x0e, commandSet.data[0x01]);
+ assertEquals((byte) 0x03, commandSet.data[0x09]);
+ assertEquals((byte) 0xB5, commandSet.data[0x0a]);
+ assertEquals((byte) 0x01, commandSet.data[0x0b]);
+ assertEquals((byte) 0x00, commandSet.data[0x0c]);
+
+ // Check the length of the data array
+ assertEquals(13, commandSet.data.length);
+ }
+
+ /**
+ * Additional Capabilities Command Test
+ *
+ */
+ @Test
+ public void testAdditionalCapabilities() {
+ CommandSet commandSet = new CommandSet();
+ commandSet.getAdditionalCapabilities();
+
+ // Check the modified bytes
+ assertEquals((byte) 0x0f, commandSet.data[0x01]);
+ assertEquals((byte) 0x03, commandSet.data[0x09]);
+ assertEquals((byte) 0xB5, commandSet.data[0x0a]);
+ assertEquals((byte) 0x01, commandSet.data[0x0b]);
+ assertEquals((byte) 0x01, commandSet.data[0x0c]);
+ assertEquals((byte) 0x01, commandSet.data[0x0d]);
+
+ // Check the length of the data array
+ assertEquals(14, commandSet.data.length);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/EnergyResponseTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/EnergyResponseTest.java
new file mode 100644
index 0000000000000..a06bd5f87f0e0
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/EnergyResponseTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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 EnergyResponseTest} tests the Energy response methods
+ * from the device using test strings. There are two decoding
+ * algorithms supported by different Midea ACs.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class EnergyResponseTest {
+ byte[] dataEnergy = HexFormat.of().parseHex("c1210144000005e00000000000000006000aeb000000487a5e");
+ byte[] dataEnergy2 = HexFormat.of().parseHex("C1210144000246540000000000000000000000001953");
+ byte[] dataEnergy3 = HexFormat.of().parseHex("C1210145350000000000000000000000000000001953");
+ EnergyResponse responseEnergy = new EnergyResponse(dataEnergy);
+ EnergyResponse responseEnergy2 = new EnergyResponse(dataEnergy2);
+ EnergyResponse responseEnergy3 = new EnergyResponse(dataEnergy3);
+
+ /**
+ * Test Humidity response
+ */
+ @Test
+ public void testGetHumidity() {
+ assertEquals(53, responseEnergy3.getHumidity());
+ }
+
+ /**
+ * Test Energy Kilowatt Hours 3 tests
+ */
+ @Test
+ public void testGetKilowattHours() {
+ double kilowattHours = responseEnergy.getKilowattHours();
+ assertEquals(15.04, kilowattHours);
+ }
+
+ @Test
+ public void testGetKilowattHours2() {
+ double kilowattHours = responseEnergy2.getKilowattHours();
+ assertEquals(1490.76, kilowattHours);
+ }
+
+ @Test
+ public void testGetKilowattHours2BCD() {
+ double kilowattHours = responseEnergy2.getKilowattHoursBCD();
+ assertEquals(246.54, kilowattHours);
+ }
+
+ /**
+ * Test amperes 2 tests
+ */
+ @Test
+ public void testAmperes() {
+ double amperes = responseEnergy.getAmperes();
+ assertEquals(0.6, amperes);
+ }
+
+ @Test
+ public void testAmperesBCD() {
+ double amperes = responseEnergy.getAmperesBCD();
+ assertEquals(0.6, amperes);
+ }
+
+ /**
+ * Test watts 2 tests
+ */
+ @Test
+ public void testGetWatts() {
+ double watts = responseEnergy.getWatts();
+ assertEquals(279.5, watts);
+ }
+
+ @Test
+ public void testGetWattsBCD() {
+ double watts = responseEnergy.getWattsBCD();
+ assertEquals(115.1, watts);
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/HumidityResponseTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/HumidityResponseTest.java
new file mode 100644
index 0000000000000..55db706aa490f
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/HumidityResponseTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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 HumidityResponseTest} tests the methods in the HumidityResponse class
+ * against an example response string.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class HumidityResponseTest {
+ @org.jupnp.registry.event.Before
+
+ // From OH forum; Midea topic July 2025
+ byte[] data = HexFormat.of().parseHex("A01240660000003C00000000003600000000000000004234EA");
+ HumidityResponse response = new HumidityResponse(data);
+
+ /**
+ * Humidity Test
+ */
+ @Test
+ public void testGetHumidity() {
+ assertEquals(54, response.getHumidity());
+ }
+
+ /**
+ * Power State
+ */
+ @Test
+ public void testPowerState() {
+ assertEquals(false, response.getPowerState());
+ }
+
+ /**
+ * Target Temperature from 0XA0 Message
+ */
+ @Test
+ public void testTargetTemperature() {
+ assertEquals(21.0, response.getTargetTemperature());
+ }
+
+ /**
+ * Operational mode from 0XA0 Message
+ */
+ @Test
+ public void testOperationalMode() {
+ assertEquals(CommandBase.OperationalMode.COOL, response.getOperationalMode());
+ }
+
+ /**
+ * Fan Speed from 0XA0 Message
+ */
+ @Test
+ public void testFanSpeed() {
+ assertEquals(CommandBase.FanSpeed.AUTO3, response.getFanSpeed());
+ }
+
+ /**
+ * Swing Mode from 0XA0 Message
+ */
+ @Test
+ public void testSwingMode() {
+ assertEquals(CommandBase.SwingMode.VERTICAL3, response.getSwingMode());
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/ResponseTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/ResponseTest.java
new file mode 100644
index 0000000000000..cdedb5146ae74
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/ResponseTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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} tests the methods in the Response class
+ * against an example response string.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class ResponseTest {
+ @org.jupnp.registry.event.Before
+
+ byte[] data = HexFormat.of().parseHex("C00042668387123C00000460FF0C7000000000320000F9ECDB");
+ private int version = 3;
+ Response response = new Response(data, version);
+
+ /**
+ * 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());
+ }
+
+ /**
+ * Filter Status Test
+ */
+ @Test
+ public void testFilterStatus() {
+ assertEquals(false, response.getFilterStatus());
+ }
+
+ /**
+ * Humidity Test
+ */
+ @Test
+ public void testGetHumidity() {
+ assertEquals(50, response.getMaximumHumidity());
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponseTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponseTest.java
new file mode 100644
index 0000000000000..dcd1629d9e1e2
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/TemperatureResponseTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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 TemperatureResponseTest} tests the methods in the HumidityResponse class
+ * against an example response string.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class TemperatureResponseTest {
+ @org.jupnp.registry.event.Before
+
+ // From OH forum; Midea topic July 2025
+ byte[] data = HexFormat.of().parseHex("A10000000000000000000A0A0A64FF000031050000000000000000000000003CD5DB");
+
+ TemperatureResponse response = new TemperatureResponse(data);
+
+ /**
+ * Humidity Test 0XA1
+ */
+ @Test
+ public void testGetHumidity() {
+ assertEquals(49, response.getHumidity());
+ }
+
+ /**
+ * Indoor Temperature Test 0xA1
+ */
+ @Test
+ public void testGetIndoorTemperature() {
+ assertEquals(25.5, response.getIndoorTemperature(), 0.0001);
+ }
+
+ /**
+ * Outdoor Temperature Test 0xA1
+ */
+ @Test
+ public void testGetOutdoorTemperature() {
+ assertEquals(0.0, response.getOutdoorTemperature(), 0.0001);
+ }
+
+ /**
+ * Get Current work time in minutes 0xA1
+ */
+ @Test
+ public void testGetCurrentWorkTime() {
+ assertEquals(15010, response.getCurrentWorkTime());
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParserTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParserTest.java
new file mode 100644
index 0000000000000..fbda33e3538a8
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/handler/capabilities/CapabilityParserTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.capabilities;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Map;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.mideaac.internal.handler.capabilities.CapabilityParser.CapabilityId;
+
+/**
+ * The {@link CapabilityParserTest} tests the methods in the
+ * CapabilityParser against test payloads.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class CapabilityParserTest {
+
+ @Test
+ void testParseWithValidPayload() {
+ // Arrange: Create a valid payload with known capabilities
+ byte[] payload = new byte[] { (byte) 0xB5, 0x03, // Header and count (3 capabilities)
+ 0x14, 0x02, 0x01, 0x01, // Capability 1 (0x0214 = MODES, value = 1)
+ 0x16, 0x02, 0x01, 0x02, // Capability 2 (0x0216 = ENERGY, value = 2)
+ 0x1A, 0x02, 0x01, 0x03, // Capability 3 (0x021A = PRESET_TURBO, value = 3)
+ (byte) 0xDE, (byte) 0xDF // CRC Check (trailing bytes)
+ };
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Check the parsed results
+ Map> capabilities = parser.getCapabilities();
+ assertNotNull(capabilities);
+
+ // Check individual capabilities
+ assertTrue(capabilities.containsKey(CapabilityId.MODES));
+ Optional.ofNullable(capabilities.get(CapabilityId.MODES)).map(modes -> modes.get("heatMode"))
+ .ifPresent(value -> assertEquals(true, value));
+
+ assertTrue(capabilities.containsKey(CapabilityId.ENERGY));
+ Optional.ofNullable(capabilities.get(CapabilityId.ENERGY)).map(modes -> modes.get("energyStats"))
+ .ifPresent(value -> assertEquals(true, value));
+
+ assertTrue(capabilities.containsKey(CapabilityId.PRESET_TURBO));
+ Optional.ofNullable(capabilities.get(CapabilityId.PRESET_TURBO)).map(modes -> modes.get("turboHeat"))
+ .ifPresent(value -> assertEquals(true, value));
+ }
+
+ @Test
+ void testParseWithEmptyPayload() {
+ // Arrange: Create an empty payload
+ byte[] payload = new byte[] {};
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Ensure no capabilities are parsed
+ assertTrue(parser.getCapabilities().isEmpty());
+ }
+
+ @Test
+ void testParseWithUnknownCapability() {
+ // Arrange: Create a payload with an unknown capability
+ byte[] payload = new byte[] { (byte) 0xB5, 0x01, // Header and count (1 capability)
+ 0x50, 0x50, 0x01, 0x01 // Unknown capability (0x5050)
+ };
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Ensure unknown capability is ignored
+ Map> capabilities = parser.getCapabilities();
+ assertTrue(capabilities.isEmpty());
+ }
+
+ @Test
+ void testParseWithInvalidSize() {
+ // Arrange: Create a payload with an invalid size
+ byte[] payload = new byte[] { (byte) 0xB5, 0x01, // Header and count (1 capability)
+ 0x14, 0x02, 0x00 // Capability 1 (0x0214 = MODES, size = 0)
+ };
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Ensure no capabilities are parsed
+ assertTrue(parser.getCapabilities().isEmpty());
+ }
+
+ @Test
+ void testParseWithTrailingCRC() {
+ // Arrange: Create a payload with extra capabilities
+ byte[] payload = new byte[] { (byte) 0xB5, 0x07, // Header and count (7 capabilities)
+ 0x12, 0x02, 0x01, 0x01, // Capability 1 (0x0212 = PRESET_ECO, value = 1)
+ 0x13, 0x02, 0x01, 0x01, // Capability 2 (0x0213 = PRESET_FREEZE_PROTECTION, value = 1)
+ 0x14, 0x02, 0x01, 0x01, // Capability 3 (0x0214 = MODES, value = 1)
+ 0x15, 0x02, 0x01, 0x01, // Capability 4 (0x0215 = SWING_MODES, value = 1)
+ 0x16, 0x02, 0x01, 0x01, // Capability 5 (0x0216 = ENERGY, value = 1)
+ 0x17, 0x02, 0x01, 0x00, // Capability 6 (0x0217 = FILTER_REMIND, value = 0)
+ 0x1A, 0x02, 0x01, 0x01, // Capability 7 (0x021A = PRESET_TURBO, value = 1)
+ 0x01, 0x00, // extra capabilities - run command
+ (byte) 0xDE, (byte) 0xDF // CRC Check (trailing bytes)
+ };
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Verify capabilities are parsed correctly
+ Map> capabilities = parser.getCapabilities();
+ assertNotNull(capabilities);
+
+ // Verify specific capabilities
+ assertTrue(capabilities.containsKey(CapabilityId.PRESET_ECO));
+ Optional.ofNullable(capabilities.get(CapabilityId.PRESET_ECO)).map(modes -> modes.get("eco"))
+ .ifPresent(value -> assertEquals(true, value));
+
+ assertTrue(capabilities.containsKey(CapabilityId.MODES));
+ Optional.ofNullable(capabilities.get(CapabilityId.MODES)).map(modes -> modes.get("heatMode"))
+ .ifPresent(value -> assertEquals(true, value));
+
+ // Ensure CRC did not cause parsing issues
+ assertTrue(parser.hasAdditionalCapabilities());
+ }
+
+ @Test
+ void testParseWithTemperature() {
+ // Arrange: Create a payload with Temperature bytes
+ byte[] payload = new byte[] { (byte) 0xB5, 0x08, // Header and count (7 capabilities)
+ 0x12, 0x02, 0x01, 0x01, // Capability 1 (0x0212 = PRESET_ECO, value = 1)
+ 0x13, 0x02, 0x01, 0x01, // Capability 2 (0x0213 = PRESET_FREEZE_PROTECTION, value = 1)
+ 0x14, 0x02, 0x01, 0x01, // Capability 3 (0x0214 = MODES, value = 1)
+ 0x15, 0x02, 0x01, 0x01, // Capability 4 (0x0215 = SWING_MODES, value = 1)
+ 0x16, 0x02, 0x01, 0x01, // Capability 5 (0x0216 = ENERGY, value = 1)
+ 0x17, 0x02, 0x01, 0x00, // Capability 6 (0x0217 = FILTER_REMIND, value = 0)
+ 0x1A, 0x02, 0x01, 0x01, // Capability 7 (0x021A = PRESET_TURBO, value = 1)
+ 0x25, 0x02, 0x07, 0x20, 0x3c, 0x20, 0x3c, 0x20, 0x3c, 0x00, // Temperature
+ (byte) 0xDE, (byte) 0xDF // CRC Check (trailing bytes)
+ };
+
+ CapabilityParser parser = new CapabilityParser();
+
+ // Act: Parse the payload
+ parser.parse(payload);
+
+ // Assert: Verify capabilities are parsed correctly
+ Map> numericCapabilities = parser.getNumericCapabilities();
+ assertNotNull(numericCapabilities);
+
+ Optional.ofNullable(numericCapabilities.get(CapabilityId.TEMPERATURES))
+ .map(modes -> modes.get("coolMinTemperature")).ifPresent(value -> assertEquals(16.0, value));
+ Optional.ofNullable(numericCapabilities.get(CapabilityId.TEMPERATURES))
+ .map(modes -> modes.get("heatMaxTemperature")).ifPresent(value -> assertEquals(30.0, value));
+ }
+}
diff --git a/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/security/SecurityTest.java b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/security/SecurityTest.java
new file mode 100644
index 0000000000000..a31e9185f51d2
--- /dev/null
+++ b/bundles/org.openhab.binding.mideaac/src/test/java/org/openhab/binding/mideaac/internal/security/SecurityTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.ByteOrder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.mideaac.internal.Utils;
+import org.openhab.binding.mideaac.internal.cloud.CloudProvider;
+
+import com.google.gson.JsonObject;
+
+/**
+ * The {@link SecurityTest} tests methods and compares
+ * them to the expected result with sample data.
+ *
+ * @author Bob Eckhoff - Initial contribution
+ */
+@NonNullByDefault
+public class SecurityTest {
+
+ @Test
+ public void testGetUdpId() {
+ // Cloud Provider has no effect. Based on the deviceId
+ CloudProvider cloudProvider = new CloudProvider("", "", "", "", "", "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String deviceId = "151749305185620";
+ long deviceIdAsInteger = Long.valueOf(deviceId);
+ byte[] sixByteArray = Utils.toIntTo6ByteArray(deviceIdAsInteger, ByteOrder.BIG_ENDIAN);
+ String udpid = security.getUdpId(sixByteArray);
+ byte[] expectedArray = new byte[] { -118, 3, -29, 110, 53, 84 };
+ assertArrayEquals(expectedArray, sixByteArray);
+ assertEquals("c52a10094ba5dc9866cdf657606d4bbd", udpid);
+ }
+
+ @Test
+ public void testGetUdpIdLittle() {
+ // Cloud Provider has no effect. Based on the deviceId
+ CloudProvider cloudProvider = new CloudProvider("", "", "", "", "", "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String deviceId = "151732605161920";
+ long deviceIdAsInteger = Long.valueOf(deviceId);
+ byte[] sixByteArray = Utils.toIntTo6ByteArray(deviceIdAsInteger, ByteOrder.LITTLE_ENDIAN);
+ String udpid = security.getUdpId(sixByteArray);
+ byte[] expectedArray = new byte[] { -64, 17, 8, 0, 0, -118 };
+ assertArrayEquals(expectedArray, sixByteArray);
+ assertEquals("1a795626332686b426df2939df3e9e3a", udpid);
+ }
+
+ @Test
+ public void testencryptIamPassword() {
+ // Cloud provider appkey is used to encrypt
+ CloudProvider cloudProvider = new CloudProvider("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "", "", "",
+ "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String loginId = "39c5f83c-63d0-4b4b-8f44-2823699857cd";
+ String password = "mYPaSsWoRd";
+ String encryptedPassword = security.encryptIamPassword(loginId, password);
+ assertEquals("e1ce0ff005e35c2a4832afba5310ee3f265663319146ba0a2e35974eef54d08a", encryptedPassword);
+ }
+
+ @Test
+ public void testencryptPassword() {
+ // Cloud provider appkey is used to encrypt
+ CloudProvider cloudProvider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "", "", "",
+ "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String loginId = "0cff1f62-37a2-4500-a4d5-be5924e02823";
+ String password = "mYPaSsWoRd";
+ String encryptedPassword = security.encryptPassword(loginId, password);
+ assertEquals("178026cb7c5a299d6abd1cd779d11bf1a47045b09e410db5fe139f7b25c8eb35", encryptedPassword);
+ }
+
+ @Test
+ public void testSign1() {
+ // Cloud provider url used to create the sign (with the endpoint)
+ CloudProvider cloudProvider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "",
+ "https://mapp.appsmb.com", "", "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String url = "https://mapp.appsmb.com/v1/user/login/id/get";
+
+ // Create JsonObject using Gson
+ JsonObject data = new JsonObject();
+ data.addProperty("appId", "1017");
+ data.addProperty("format", 2);
+ data.addProperty("clientType", 1);
+ data.addProperty("language", "en_US");
+ data.addProperty("src", "1017");
+ data.addProperty("stamp", "20250327172341");
+ data.addProperty("loginAccount", "myemail@gmail.com");
+
+ // Sign the data
+ String sign = security.sign(url, data);
+
+ // Verify the signature
+ assertEquals("648ed795a7b0faf566226cc5abc72bd32a51ede0ffbf9649b34c0f25a3508ef4", sign);
+ }
+
+ @Test
+ public void testSign2() {
+ // Cloud provider url used to create the sign (with the endpoint)
+ CloudProvider cloudProvider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "",
+ "https://mapp.appsmb.com", "", "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String url = "https://mapp.appsmb.com/v1/user/login";
+
+ // Create JsonObject using Gson
+ JsonObject data = new JsonObject();
+ data.addProperty("appId", "1017");
+ data.addProperty("format", 2);
+ data.addProperty("clientType", 1);
+ data.addProperty("language", "en_US");
+ data.addProperty("src", "1017");
+ data.addProperty("stamp", "20250327172342");
+ data.addProperty("loginAccount", "myemail@gmail.com");
+ data.addProperty("password", "178026cb7c5a299d6abd1cd779d11bf1a47045b09e410db5fe139f7b25c8eb35");
+
+ // Sign the data
+ String sign = security.sign(url, data);
+
+ // Verify the signature
+ assertEquals("43e1031addb18994d686cae8ac3612e75e4862eea67c7a5b9dde19c68508b90b", sign);
+ }
+
+ @Test
+ public void testSign3() {
+ // Cloud provider url used to create the sign (with the endpoint)
+ CloudProvider cloudProvider = new CloudProvider("NetHome Plus", "3742e9e5842d4ad59c2db887e12449f9", "",
+ "https://mapp.appsmb.com", "", "", "", "");
+ Security security = new Security(cloudProvider);
+
+ String url = "https://mapp.appsmb.com/v1/iot/secure/getToken";
+
+ // Create JsonObject using Gson
+ JsonObject data = new JsonObject();
+ data.addProperty("appId", "1017");
+ data.addProperty("format", 2);
+ data.addProperty("clientType", 1);
+ data.addProperty("language", "en_US");
+ data.addProperty("src", "1017");
+ data.addProperty("stamp", "20250331202017");
+ data.addProperty("udpid", "c52a10094ba5dc9866cdf657606d4bbd");
+ data.addProperty("sessionId", "a4261dbfd2e748fb89da33a2be3ace3020250325232016876");
+
+ // Sign the data
+ String sign = security.sign(url, data);
+
+ // Verify the signature
+ assertEquals("4cbd636506460ba43251ae254ac1a36457b6e976639cd29b0628818228eefd9d", sign);
+ }
+
+ @Test
+ public void testnewSign() {
+ // Cloud provider that uses newSign ie. proxied
+ CloudProvider cloudProvider = new CloudProvider("MSmartHome", "ac21b9f9cbfe4ca5a88562ef25e2b768", "1010",
+ "https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=", "xhdiwjnchekd4d512chdjx5d8e4c394D2D7S",
+ "meicloud", "PROD_VnoClJI9aikS8dyy", "v5");
+ Security security = new Security(cloudProvider);
+
+ String json = "{\"appId\":\"1010\",\"format\":2,\"clientType\":1,\"language\":\"en_US\",\"src\":\"1010\",\"stamp\":\"20250331151111\",\"loginAccount\":\"myemail@gmail.com\",\"reqId\":\"f2c3e2c3365a4d4f\"}";
+ String random = "1742860285";
+ String proxysign = security.newSign(json, random);
+ assertEquals("1afdddedb746d70086dc3c50b35a8ece1e4aadc0030b58e73542528461769a9c", proxysign);
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 22b817ff3ca64..fd25a11558232 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -265,6 +265,7 @@
org.openhab.binding.meteostick
org.openhab.binding.metofficedatahub
org.openhab.binding.mffan
+ org.openhab.binding.mideaac
org.openhab.binding.miele
org.openhab.binding.mielecloud
org.openhab.binding.mihome