Midea Dehumidifier

This is a custom driver for Midea Dehumidifier. tested working on Midea Cube.


/*

based on
https://github.com/tomwpublic/hubitat_midea/blob/main/mideaAC_localController
https://github.com/nbogojevic/midea-beautiful-air/blob/main/midea_beautiful/command.py

*/


metadata
{
    definition(name: "Midea Dehumidifier", namespace: "Chen", author: "Chen", importUrl: "")
    {
        capability "Initialize"
        capability "Refresh"
        capability "Switch"
        capability "RelativeHumidityMeasurement"
		command "setFanSpeed", [[
			name: "Fan Speed",
			constraints: [ "High", "Low" ],
            type: "ENUM"]]
        command "setMode", [[
            name: "Mode",
            constraints: [ "Set", "Continuous", "Max" ],
            type: "ENUM"]]
        command "setTargetHumidity", [[
            name: "Humidity",
            constraints: [ "35", "40", "45", "50", "55", "60", "65", "70", "75" ],
            type: "ENUM"]]
        attribute "Mode", "string"
        attribute "TargetHumidity", "number"
        attribute "FanSpeed", "string"
    }
}

preferences
{
    section
    {
        input name: "ipAddress", type: "text", title: "IP address", required: true
        input name: "port", type: "number", title: "port", required: true, defaultValue: 6444
        input name: "id", type: "number", title: "id", required: true
        input name: "token", type: "text", title: "token", required: true
        input name: "key", type: "text", title: "key", required: true        
    }
    section
    {
        input ("refresh_Rate", "enum", title: "Device Refresh Interval (minutes)", options: ["1", "5", "10", "15", "30", "60", "180"], defaultValue: "5")
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

def updated()
{
    state.errorCount = 0
 	unschedule()   
    switch(refresh_Rate) {
		case "1" : runEvery1Minute(refresh); break
		case "5" : runEvery5Minutes(refresh); break
		case "10" : runEvery10Minutes(refresh); break
		case "15" : runEvery15Minutes(refresh); break
		case "30" : runEvery30Minutes(refresh); break
		case "180": runEvery3Hours(refresh); break
		default: runEvery1Hour(refresh)
    }
    initialize()
}

def initialize()
{
    refresh()
}

def off()
{
    if( device.currentValue( "switch" ) != "off" )
    {
        def data = ini_set_command( [switch: "off"] )
        apply(data)
    }
}

def on()
{
    if( device.currentValue( "switch" ) != "on" )
    {
        def data = ini_set_command( [switch: "on"] )
        apply(data)
    }
}

def setMode(Mode)
{
    if( device.currentValue( "Mode" ) != Mode )
    {
        def data = ini_set_command(["Mode": Mode] )
        apply(data)
    }
}

def setFanSpeed(Speed)
{
    if( device.currentValue( "FanSpeed" ) != Speed )
    {
        def data = ini_set_command(["FanSpeed": Speed] )
        apply(data)
    }
}

def setTargetHumidity(Humidity)
{
    if( device.currentValue( "TargetHumidity" ) != Humidity.toInteger() )
    {
        def data = ini_set_command(["TargetHumidity": Humidity.toInteger()])
        apply(data)
    }
}

def refresh()
{
    if(getVolatileState("sessionActive"))
    {
        logDebug("refresh(): socket access failed. another session is already in progress.")
        return
    }
    
    try
    {
//        unschedule(refresh)

        setVolatileState("sessionActive", true)
        
        if(!openSocket()) {throw new Exception("failed to open socket")}
        
        syncWait([waitSetter: "auth", timeoutSec: 4])
        runInMillis(20, '_authenticate')
        doWait()
        
        syncWait([waitSetter: "refresh", timeoutSec: 4])
        runInMillis(20, '_refresh')
        doWait()
    }
    catch (Exception e)
    {
        log.debug "error: refresh(): ${e.message}"
    }
    finally
    {
        closeSocket()
        setVolatileState("sessionActive", false)
        
//        runIn(refreshInterval ?: 60, refresh)
    }
}

def apply(cmdParams)
{    
    if(getVolatileState("sessionActive"))
    {
        logDebug("apply(): socket access failed. another session is already in progress.")
        return
    }
    
    try
    {
        setVolatileState("sessionActive", true)
        
        if(!openSocket()) {throw new Exception("failed to open socket")}
        
        syncWait([waitSetter: "auth", timeoutSec: 4])
        runInMillis(20, '_authenticate')
        doWait()
        
        syncWait([waitSetter: "apply", timeoutSec: 4])
        runInMillis(20, '_apply', [data: cmdParams])
        doWait()
    }
    catch (Exception e)
    {
        log.debug "error: apply(): ${e.message}"
    }
    finally
    {
        closeSocket()
        setVolatileState("sessionActive", false)
    }
}

def logDebug(msg) 
{
    if (logEnable)
    {
        log.debug(msg)
    }
}

def logHexBytes(data)
{
    String res = ""

    data.each
    {
        res += String.format("%02X ", it & 0xFF)
    }
    
    logDebug("bytes: ${res}")
    logDebug("string: ${hubitat.helper.HexUtils.byteArrayToHexString(data as byte[])}")
}

def socketStatus(String message)
{
    logDebug("socketStatus: ${message}")
}

def parse(String message)
{
    logDebug("parse: ${message}")    
    def rdB = hubitat.helper.HexUtils.hexStringToByteArray(message)
    //logDebug("parse: ${rdB}")
    
    if(rdB.size() < 13)
    {
        logDebug("ignoring short response")
        
        // clear this syncWait, likely set by connectSocket
        // note: the Midea units appear to return one 0x00 byte on connect.
        //    if this behavior changes, we will need a different signal to clear the wait.
        clearWait([waitSetter: "open"])
        
        return
    }
    
    if(rdB.size() == 13)
    {
        // just catch 'ERROR'
        rdB = subBytes(rdB, 8, 5)
        //logDebug("rdB = ${new String(rdB)}")
        return
    }
    
    switch(getMsgType(rdB))
    {
        case MSGTYPE_HANDSHAKE_RESPONSE:
            def tcp_key = tcp_key(rdB, key)
            if(tcp_key)
            {
                setTcpKey(tcp_key)
                
                // clear this syncWait, likely set by _authenticate
                clearWait([waitSetter: "auth"])
            }
            break
        
        case MSGTYPE_ENCRYPTED_RESPONSE:
            //logDebug("parse enc resp = ${rdB}")
            //logDebug("decode_8370 = ${decode_8370(rdB)}")

            def dec_8370 = decode_8370(rdB)
            if(([] != dec_8370) && (dec_8370.size() > (40 + 16)))
            {
                def dec_resp = aes_ecb(subBytes(dec_8370, 40, dec_8370.size() - (40 + 16)), "dec")
                logDebug( "dec resp" + dec_resp )
                _process_response(dec_resp)
            }
            break
    }
}

def openSocket()
{
    logDebug("opening socket")
    interfaces.rawSocket.connect(ipAddress, port.toInteger(), byteInterface: true)
    
    // workaround: wait a little while to ensure the socket opened
    pauseExecution(20)
    
    return true
}

def closeSocket()
{
    try
    {
        logDebug("closing socket")
        interfaces.rawSocket.close()
        
        // workaround: wait a little while to ensure the socket closed
        pauseExecution(500)
    }
    catch (Exception e)
    {
        // swallow errors
    }
    
    return true
}

def _writeBytes(byte[] bytes)
{
    def wrStr = hubitat.helper.HexUtils.byteArrayToHexString(bytes)
    logDebug("writeBytes: ${wrStr}")
    
    interfaces.rawSocket.sendMessage(wrStr)  
}

def _authenticate()
{
    def tokenBytes = hubitat.helper.HexUtils.hexStringToByteArray(token)
    
    logDebug("_authenticate packet follows:")
    
    request = encode_8370(tokenBytes, MSGTYPE_HANDSHAKE_REQUEST)
    _writeBytes(request)
}

def _refresh()
{
    def packet = packet_builder(id.toLong(), request_command())
    logDebug("_refresh packet follows:")
    
    appliance_transparent_send_8370(packet, MSGTYPE_ENCRYPTED_REQUEST)
}

def _apply(cmdParams)
{
    def packet = packet_builder(id.toLong(), cmdParams)
    logDebug("_apply packet follows:")
    
    appliance_transparent_send_8370(packet, MSGTYPE_ENCRYPTED_REQUEST)    
}

def _process_response(data)
{
    def resp
    
    if(data == 'ERROR'.getBytes())
    {
        logDebug("response ERROR")
        return null
    }

    if(200 == i8Tou8(data[0xA]))
    {
        resp = appliance_response(data)
        logDebug("appliance_response: ${resp}")
        
        if(resp)
        {
            _updateAttributes(resp)
            setVolatileState("state", resp)
        }
        
        // clear this syncWait, possibly set by refresh()
        clearWait([waitSetter: "refresh"])
        // clear this syncWait, possibly set by _apply()
        clearWait([waitSetter: "apply"])
    }
    
    return resp
}

def _updateAttributes(resp)
{
    if(!resp)
    {
        return
    }
    
    def events = [[:]]

    events +=    [name: "switch", value: resp.switch? "on":"off", isStateChange: true]
    events +=    [name: "FanSpeed", value: translateFanSpeed( resp.FanSpeed, "integer" ), isStateChange: true ]
    events +=    [name: "Mode",      value: translateMode( resp.Mode, "integer" ), isStateChange: true ]
    events +=    [name: "humidity",  value: resp.humidity, unit: "%", isStateChange: true]
    events +=    [name: "TargetHumidity", value: resp.TargetHumidity, isStateChange: true]
    events.each
    {
        if( it.value != device.currentValue( it.name ) )
            sendEvent(it)
    }  
}

def translateMode(value, inputType)
{
    switch(inputType)
    {
        case "integer":
            switch(value.toInteger())
            {
                case 1:  return "Set"
                case 2:  return "Continuous"
                case 4:  return "Max"
                default: return "Set"
            }

        case "string":
            switch(value)
            {
                case "Set":        return 1
                case "Continuous": return 2
                case "Max":        return 4
                default:           return 1
            }

        default:
            return
    }
}

def translateFanSpeed(value, inputType)
{
    switch(inputType)
    {
        case "integer":
            switch(value.toInteger())
            {
                case 80: return "High"
                case 40: return "Low"
            }

        case "string":
            switch(value)
            {
                case "High": return 80
                case "Low" : return 40
            }
        
        default:
            return
    }
}

def appliance_response(data)
{
    // The response data from the appliance includes a packet header which we don't want
    data = subBytes(data, 0xA, data.size() - 0xA)
    
    def resp = [:]
    resp += [switch:          (data[1]&0x1) !=0  ]
    resp += [Mode:            data[2]&0b00001111 ]//1set, 2, cont, 3, max
    resp += [humidity:        data[16]           ]
    resp += [TargetHumidity:  data[7]            ]
    resp += [FanSpeed:        data[3]&0b01111111 ]          
    
    return resp
}

def setTcpKey(key)
{
    setVolatileState('tcp_key', key)
}

def getTcpKey()
{
    getVolatileState('tcp_key')
}

def request_command()
{
    byte[] req = 
    [
        // 0 header
        0xaa,
        // 1 command lenght: N+10
        0x20,
        // 2 device type (0xAC for air conditioner)
        0xa1,
        // 3 Frame SYN CheckSum
        0x00,
        // 4-5 Reserved
        0x00, 0x00,
        // 6 Message ID
        0x00,
        // 7 Frame Protocol Version
        0x00,
        // 8 Device Protocol Version
        0x00,
        // 9 Message Type: request is 0x03; setting is 0x02
        0x03,
        
        // Byte0 - Data request/response type: 0x41 - check status; 0x40 - Set up
        0x41,
        // Byte1
        0x81,
        // Byte2 - operational_mode
        0x00,
        // Byte3
        0xff,
        // Byte4
        0x03,
        // Byte5
        0xff,
        // Byte6
        0x00,
        // Byte7 - Room Temperature Request: 0x02 - indoor_temperature, 0x03 - outdoor_temperature
        // when set, this is swing_mode
        0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00,
        // Message ID
        (state.request_count ?: 1) & 0xFF
    ]
    
    return req
}

def ini_set_command(params)
{
    byte[] data = 
    [
        //Sync header        
        0xAA,
        // Length
        0x20,
        // Device type: Dehumidifier
        0xA1,
        // Frame synchronization check
        0x00,
        // Reserved
        0x00,
        0x00,
        // Message id
        0x00,
        // Framework protocol
        0x00,
        // Home appliance protocol
        0x03,
        // Message Type: querying is 0x03; control is 0x02
        0x02,
        // Payload
        // Data request/response type:
        // 0x41 - check status
        // 0x48 - write
        0x48,
        // Flags: On bit0 (byte 11)
        0x00,
        // Mode (byte 12)
        0x01,
        // Fan (byte 13)
        0x32,
        0x00,
        0x00,
        0x00,
        // Humidity (byte 17)
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
    ]
    
    [ "switch", "Mode", "FanSpeed", "TargetHumidity" ].each{
        if( !params.containsKey( it ) )
            params[ it ] = device.currentValue( it )
    }
    //Switch
    data[11] &= ~0b00000001  // Clear the power bit
    data[11] |= params.switch == "on"? 0b00000001:0
    //Mode
    data[12] &= ~0b00001111  // Clear the mode bits
    data[12] |= translateMode(params["Mode"], "string" )
    //Fan Speed
    data[13] &= ~0b01111111  // Clear the fan speed part
    data[13] |= translateFanSpeed(params["FanSpeed"], "string" ) & 0b01111111
    //Target Humidity
    data[17] &= ~0b01111111  // Clear the humidity part
    data[17] |= params["TargetHumidity" ]

    return data
}

import groovy.transform.Field
@Field MSGTYPE_HANDSHAKE_REQUEST = 0x0
@Field MSGTYPE_HANDSHAKE_RESPONSE = 0x1
@Field MSGTYPE_ENCRYPTED_RESPONSE = 0x3
@Field MSGTYPE_ENCRYPTED_REQUEST = 0x6
@Field MSGTYPE_TRANSPARENT = 0xf

import java.security.MessageDigest

def getMsgType(header)
{
    if(i8Tou8(bytesToInt([header[0]], "big")) != 0x83 || i8Tou8(bytesToInt([header[1]], "big")) != 0x70)
    {
        logDebug("not an 8370 message")
        return -1
    }
    
    if(header.size() < 6)
    {
        logDebug("header too short")
        return -1
    }
    
    def msgtype = i8Tou8(bytesToInt([header[5]], "big")) & 0xf
}

def encode_8370(data, msgtype)
{
    byte[] header = [i8Tou8(0x83), i8Tou8(0x70)]
    
    def size = data.size()
    def padding = 0
    
    if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST])
    {
        if((size + 2) % 16 != 0)
        {
            padding = 16 - ((size + 2) & 0xf)
            size += (padding + 32)
            
            byte[] pBytes = new byte[padding]
            new Random().nextBytes(pBytes)
            data = appendByteArr(data, pBytes)
        }
    }
    
    header = appendByteArr(header, intToBytes(size, 2, "big"))
    header = appendByteArr(header, [i8Tou8(0x20), i8Tou8(padding << 4 | msgtype)])
    
    def request_count = state.request_count ?: 0
    data = appendByteArr(intToBytes(request_count, 2, "big"), data)
    state.request_count = request_count + 1
    
    if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST])
    {
        MessageDigest digest = MessageDigest.getInstance("SHA-256")
        def sign = digest.digest(appendByteArr(header, data))
        
        // if tcp_key isn't available, just use a random key
        data = aes_cbc(data, "enc", getTcpKey() ?: '4D67055D53288313335D65FB2CBA3DDB04001F8AF6880CBDB5BC45DA67EC8A35')
        
        data = appendByteArr(data, sign)
    }
    
    return appendByteArr(header, data)
}

def decode_8370(data)
{    
    def header = subBytes(data, 0, 6)
    data = subBytes(data, 6, data.size() - 6)

    if(i8Tou8(bytesToInt([header[0]], "big")) != 0x83 || i8Tou8(bytesToInt([header[1]], "big")) != 0x70)
    {
        logDebug("not an 8370 message")
        return []
    }
    
    if(i8Tou8(bytesToInt([header[4]], "big")) != 0x20)
    {
        logDebug("missing byte 4")
        return []
    }
    
    def padding = i8Tou8(bytesToInt([header[5]], "big")) >> 4
    def msgtype = getMsgType(header)
    
    def size = i16Tou16(bytesToInt(subBytes(header, 2, 2), "big"))
    
    if(data.size() < (size + 2))
    {
        // request_count was not in size, so count 2 extra bytes here
        logDebug("data.size() = ${data.size()}, size + 2 = ${size + 2}")
        return []
    }
    
    if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST])
    {
        sign = subBytes(data, data.size() - 32, 32)
        data = subBytes(data, 0, data.size() - 32)
        
        // if tcp_key isn't available, just use a random key
        data = aes_cbc(data, "dec", getTcpKey() ?: '4D67055D53288313335D65FB2CBA3DDB04001F8AF6880CBDB5BC45DA67EC8A35')
        
        MessageDigest digest = MessageDigest.getInstance("SHA-256")
        def check = digest.digest(appendByteArr(header, data))
        
        if(check != sign)
        {
            logDebug("sign does not match")
            return []
        }
        
        if(padding)
        {
            data = subBytes(data, 0, data.size() - padding)
        }
    }    

    state.response_count = i16Tou16(bytesToInt(subBytes(data, 0, 2), "big"))
    data = subBytes(data, 2, data.size() - 2)
    
    return data
}

import javax.crypto.spec.SecretKeySpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.Cipher

@Field signKey = 'xhdiwjnchekd4d512chdjx5d8e4c394D2D7S'.getBytes()

def aes_cbc(data, op = "enc", key = key)
{
    // thanks: https://community.hubitat.com/t/groovy-aes-encryption-driver/31556
    
    def cipher = Cipher.getInstance("AES/CBC/NoPadding", "SunJCE")
    
    // note: we already have the key from midea-smart    
    byte[] keyBytes = hubitat.helper.HexUtils.hexStringToByteArray(key)
    SecretKeySpec aKey = new SecretKeySpec(keyBytes, 0, keyBytes.length, "AES")
    
    //self.iv = b'\0' * 16
    def IVKey = '\0' * 16
    IvParameterSpec iv = new IvParameterSpec(IVKey.getBytes("UTF-8"))
    
    cipher.init(op == "enc" ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, aKey, iv)
    
    return cipher.doFinal(data)
}

def aes_ecb(data, op = "enc")
{    
    def encKey = md5(signKey)
    SecretKeySpec aKey = new SecretKeySpec(encKey, 0, encKey.length, "AES")
    
    def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding", "SunJCE")
    cipher.init(op == "enc" ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, aKey)
    
    return cipher.doFinal(data)
}

def md5(data)
{
    MessageDigest digest = MessageDigest.getInstance("MD5")
    digest.update(data)
    byte[] md5sum = digest.digest()
    
    return md5sum
}

def tcp_key(response, key = key)
{
    if(subBytes(response, 8, 5) == 'ERROR'.getBytes())
    {
        logDebug("authentication failed")
        return null
    }
    
    if(response.size() != 72)
    {
        logDebug("unexpected data length")
        return null
    }

    response = subBytes(response, 8, 64)
        
    def payload = subBytes(response, 0, 32)
    def sign = subBytes(response, 32, 32)
    
    def plain = aes_cbc(payload, "dec", key)
    
    MessageDigest digest = MessageDigest.getInstance("SHA-256")
    if(sign != digest.digest(plain))
    {
        logDebug("sign does not match")
        return null
    }    
    
    byte[] keyBytes = hubitat.helper.HexUtils.hexStringToByteArray(key)
    
    if(plain.size() != keyBytes.size())
    {
        logDebug("size mismatch")
        return null
    }
    
    (0..(plain.size() - 1)).each
    {
        // tcp_key = strxor(plain, key)
        plain[it] = plain[it] ^ keyBytes[it]
    }
    
    def tcp_key = hubitat.helper.HexUtils.byteArrayToHexString(plain)
    
    state.request_count = 0
    state.response_count = 0
    
    return tcp_key
}

def appliance_transparent_send_8370(data, msgtype=MSGTYPE_ENCRYPTED_REQUEST)
{
    if(!getTcpKey())
    {
        logDebug("missing tcp_key.  need to _authenticate")
        return
    }
    
    def sData = subBytes(data, 0, data.size())
    sData = encode_8370(sData, msgtype)
    _writeBytes(sData)
}

def packet_builder(device_id, command)
{
    // Init the packet with the header data
    def packet =
        [
            // 2 bytes - StaicHeader
            0x5a, 0x5a,
            // 2 bytes - mMessageType
            0x01, 0x11,
            // 2 bytes - PacketLenght
            0x00, 0x00,
            // 2 bytes
            0x20, 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, 0x00, 0x00,
            // 12 bytes
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
        ]
    
    //self.packet[12:20] = self.packet_time()
    //'%Y%m%d%H%M%S%f'
    def dateBytes = hubitat.helper.HexUtils.hexStringToByteArray(new Date().format('yyyyMMddHHmmssSSSS'))
    packet = replaceSubArr(packet, subBytes(dateBytes, 0, 8), 12)
    
    //self.packet[20:28] = device_id.to_bytes(8, 'little')
    packet = replaceSubArr(packet, intToBytes(device_id, 8, "little"), 20)

    // base_command.finalize()
    // Add the CRC8
    def crc = crc8(subBytes(command, 10, command.size() - 10))
    command = appendByteArr(command, [i8Tou8(crc)])
    // Set the length of the command data
    // self.data[0x01] = len(self.data)
    // Add checksum
    def checksum = checksum(subBytes(command, 1, command.size() - 1))
    command = appendByteArr(command, [i8Tou8(checksum)]) 
    
    // packet_builder.finalize()
    def encCmd = aes_ecb(command, "enc")
    // Append the command data(48 bytes) to the packet
    packet = appendByteArr(packet, subBytes(encCmd, 0, 48))
    // PacketLength
    packet = replaceSubArr(packet, intToBytes(packet.size() + 16, 2, "little"), 4)    
    // Append a basic checksum data(16 bytes) to the packet
    packet = appendByteArr(packet, md5(appendByteArr(packet, signKey)))
    
    return packet
}

def appendByteArr(a, b)
{
    byte[] c = new byte[a.size() + b.size()]
    
    a.eachWithIndex()
    {
        it, i ->
        c[i] = it
    }
    
    def aSz = a.size()
    
    b.eachWithIndex()
    {
        it, i ->
        c[i + aSz] = it
    }
    
    return c
}

def replaceSubArr(orig_arr, new_arr, start)
{
    def tmp_arr = orig_arr.collect()
    new_arr.eachWithIndex
    {
        it, i ->
        tmp_arr[i + start] = it
    }
    
    return tmp_arr
}

private subBytes(arr, start, length)
{
    byte[] sub = new byte[length]
    
    for(int i = 0; i < length; i++)
    {
        sub[i] = arr[i + start]
    }
    
    return sub
}

def swapEndiannessU16(input)
{    
    return [i8Tou8(input[1]), i8Tou8(input[0])]
}

def swapEndiannessU32(input)
{
    return [input[3], input[2], input[1], input[0]]
}

def swapEndiannessU64(input)
{
    return [input[7], input[6], input[5], input[4],
            input[3], input[2], input[1], input[0]]
}

def intToBytes(input, width, endian = "little")
{
    def output = new BigInteger(input).toByteArray()
    
    if(output.size() > width)
    {
        // if we got too many bytes, lop off the MSB(s)
        output = subBytes(output, output.size() - width, width)
        output = output.collect{it & 0xFF}
    }
    
    byte[] pad    
   
    if(output.size() < width)
    {
        def padding = width - output.size()
        pad = [0] * padding
        output = appendByteArr(pad, output)        
    }
    
    if("little" == endian)
    {
        switch(width)
        {
            case 1:
                break
            case 2:
                output = swapEndiannessU16(output)
                break
            case 4:
                output = swapEndiannessU32(output)
                break
            case 8:
                output = swapEndiannessU64(output)
                break
        }
    }
    
    return output.collect{it & 0xFF}
}

def bytesToInt(input, endian = "little")
{
    def output = subBytes(input, 0, input.size())
    
    long retVal = 0
    output.eachWithIndex
    {
        it, i ->
        
        switch(endian)
        {
            case "little":
                retVal += ((it & 0xFF).toLong() << (i * 8))
                break
            case "big":
            default:
                retVal += (it & 0xFF).toLong() << ((output.size() - 1 - i) * 8)
                break
        }
    }
    
    if(input.size() == 8)
    {
        // 8 bytes is too big for integer
        return retVal
    }
    
    return retVal as Integer
}

def i8Tou8(input)
{
    return input & 0xFF
}

def i16Tou16(input)
{
    return input & 0xFFFF
}

def getBit(pByte, pIndex)
{
    return (pByte >> pIndex) & 0x01
}

def getBits(pBytes, pIndex, pStartIndex, pEndIndex)
{
    if(pStartIndex > pEndIndex)
    {
        StartIndex = pEndIndex
        EndIndex = pStartIndex
    }
    else
    {
        StartIndex = pStartIndex
        EndIndex = pEndIndex
    }
    
    tempVal = 0x00
    StartIndex.upto(EndIndex)
    {
        tempVal = tempVal | getBit(pBytes[pIndex], it) << (it - StartIndex)
    }
    
    return tempVal
}

@Field crc8_854_table =
    [
    0x00, 0x5E, 0xBC, 0xE2, 0x61, 0x3F, 0xDD, 0x83,
    0xC2, 0x9C, 0x7E, 0x20, 0xA3, 0xFD, 0x1F, 0x41,
    0x9D, 0xC3, 0x21, 0x7F, 0xFC, 0xA2, 0x40, 0x1E,
    0x5F, 0x01, 0xE3, 0xBD, 0x3E, 0x60, 0x82, 0xDC,
    0x23, 0x7D, 0x9F, 0xC1, 0x42, 0x1C, 0xFE, 0xA0,
    0xE1, 0xBF, 0x5D, 0x03, 0x80, 0xDE, 0x3C, 0x62,
    0xBE, 0xE0, 0x02, 0x5C, 0xDF, 0x81, 0x63, 0x3D,
    0x7C, 0x22, 0xC0, 0x9E, 0x1D, 0x43, 0xA1, 0xFF,
    0x46, 0x18, 0xFA, 0xA4, 0x27, 0x79, 0x9B, 0xC5,
    0x84, 0xDA, 0x38, 0x66, 0xE5, 0xBB, 0x59, 0x07,
    0xDB, 0x85, 0x67, 0x39, 0xBA, 0xE4, 0x06, 0x58,
    0x19, 0x47, 0xA5, 0xFB, 0x78, 0x26, 0xC4, 0x9A,
    0x65, 0x3B, 0xD9, 0x87, 0x04, 0x5A, 0xB8, 0xE6,
    0xA7, 0xF9, 0x1B, 0x45, 0xC6, 0x98, 0x7A, 0x24,
    0xF8, 0xA6, 0x44, 0x1A, 0x99, 0xC7, 0x25, 0x7B,
    0x3A, 0x64, 0x86, 0xD8, 0x5B, 0x05, 0xE7, 0xB9,
    0x8C, 0xD2, 0x30, 0x6E, 0xED, 0xB3, 0x51, 0x0F,
    0x4E, 0x10, 0xF2, 0xAC, 0x2F, 0x71, 0x93, 0xCD,
    0x11, 0x4F, 0xAD, 0xF3, 0x70, 0x2E, 0xCC, 0x92,
    0xD3, 0x8D, 0x6F, 0x31, 0xB2, 0xEC, 0x0E, 0x50,
    0xAF, 0xF1, 0x13, 0x4D, 0xCE, 0x90, 0x72, 0x2C,
    0x6D, 0x33, 0xD1, 0x8F, 0x0C, 0x52, 0xB0, 0xEE,
    0x32, 0x6C, 0x8E, 0xD0, 0x53, 0x0D, 0xEF, 0xB1,
    0xF0, 0xAE, 0x4C, 0x12, 0x91, 0xCF, 0x2D, 0x73,
    0xCA, 0x94, 0x76, 0x28, 0xAB, 0xF5, 0x17, 0x49,
    0x08, 0x56, 0xB4, 0xEA, 0x69, 0x37, 0xD5, 0x8B,
    0x57, 0x09, 0xEB, 0xB5, 0x36, 0x68, 0x8A, 0xD4,
    0x95, 0xCB, 0x29, 0x77, 0xF4, 0xAA, 0x48, 0x16,
    0xE9, 0xB7, 0x55, 0x0B, 0x88, 0xD6, 0x34, 0x6A,
    0x2B, 0x75, 0x97, 0xC9, 0x4A, 0x14, 0xF6, 0xA8,
    0x74, 0x2A, 0xC8, 0x96, 0x15, 0x4B, 0xA9, 0xF7,
    0xB6, 0xE8, 0x0A, 0x54, 0xD7, 0x89, 0x6B, 0x35
    ]

int crc8(value)
{
    // thanks: http://www.java2s.com/example/java-utility-method/crc-calculate/crc8-string-value-6f7a7.html
    
    int crc = 0
    for (int i = 0; i < value.size(); i++)
    {
        crc = crc8_854_table[value[i] ^ (crc & 0xFF)]
    }
    
    return crc
}

def checksum(data)
{
    def sum = data.sum()
    return (~ sum + 1) & 0xFF
}

def encode(byte[] data)
{
    data.collect { it & 0xFF }
}

//////////////////////////////////////
// volatile state and sync code below
//////////////////////////////////////

@Field static volatileState = [:].asSynchronized()

def setVolatileState(name, value)
{
    def tempState = volatileState[device.getDeviceNetworkId()] ?: [:]
    tempState.putAt(name, value)
    
    volatileState.putAt(device.getDeviceNetworkId(), tempState)
    
    return volatileState
}

def getVolatileState(name)
{
    return volatileState.getAt(device.getDeviceNetworkId())?.getAt(name) ?: null
}

def syncWait(data)
{
    // set up for checking 5x per second
    setVolatileState("syncWaitDetails", [waitSetter: data.waitSetter, retryCount: data.timeoutSec * 5])
}

def doWait()
{
    def wtDetails = getVolatileState("syncWaitDetails")
    
    // check every 200 ms whether the wait was cleared...
    if(wtDetails?.waitSetter == "")
    {
        return
    }
    
    // ...or throw an exception if we ran out of tries
    if(wtDetails?.retryCount == 0)
    {
        throw new Exception("wait timed out")
    }
    
    wtDetails.putAt("retryCount", wtDetails.getAt("retryCount") - 1)
    
    setVolatileState("syncWaitDetails", wtDetails)
    
    pauseExecution(200)
    doWait()
}

def clearWait(data)
{
    def wtDetails = getVolatileState("syncWaitDetails")
    
    if(data.waitSetter == wtDetails?.getAt("waitSetter"))
    {
        syncWait([waitSetter: "", timeoutSec: 0])
    }
}

def clearAllWaits()
{
    syncWait([waitSetter: "", timeoutSec: 0])
}

1 Like

This is awesome. I have a Danby window A/C that use an app called NetHome Plus.
Which app does the Midea use? I'm guessing if my A/C works with the Midea app, I can use it on my A/C ?

I use Midea Air app

Thanks, how did you get the token, port, key & ID? Is there a link you could share?

GitHub - tomwpublic/hubitat_midea
I followed this instruction.
you will need a machine with python3. ideally a linux machine. i used raspberry pi.
to run
pip3 install msmart
midea-discover

1 Like

If you are using this with an AC, check out my integration: Midea Mini Split Wifi Support - #11 by tomw

I'm glad you found it useful @Chen555, and I appreciate you linking to my original AC code from your dehumidifier integration.

1 Like

you've done an excellent job on the AC driver. it made things much easier for the dehumidifier.

1 Like

Sweet!

I was able to load Python onto my Windows laptop and make this work as well.

  1. Downloaded the zip file with the python (msmart) routines & extracted the zip files
  2. Downloaded the Python 3.x Windows Installer
  3. Installed Python 3.x by running the installer (MUST add the install location to the path)
  4. Open a command prompt
  5. Navigate into the zip file extraction to the directory containing the "msmart" directory
  6. pip3 install msmart
  7. Ran: midea-discover -i

Download the Hubitat app