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: + *
    + *
  1. Retrieves the configuration for the handler.
  2. + *
  3. Ensures the discovery or configuration is valid. If not, starts the discovery process and exits early.
  4. + *
  5. Ensures the token and key for V3 devices are available. If not, starts the retrieval process and exits + * early.
  6. + *
  7. Updates the thing's status to {@link ThingStatus#UNKNOWN}.
  8. + *
  9. Initializes the connection manager using the configuration.
  10. + *
  11. Requests device capabilities if they are missing.
  12. + *
  13. Starts any necessary schedulers for the handler.
  14. + *
+ */ + @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