[RELEASE] Levolor InMotion Blind/Shade control

This driver will control your Levolor InMotion blinds/shades. This is a pure local control (not cloud based)

There are a few requirements to use this; You must have the Levolor InMotion Hub…..the usb stick. Additionally with this, you do need to know the IP Address of the hub. I will not explain how to find that as that will vary from router to router.

You also need the Levolor App installed and you must be logged in (and have the shades/blinds already installed and connected to the app via the hub).

There is a 16 digit API Key you will obtain from the app. From the Levolor InMotion app, click the 3 lines in the top left corner. Go to “About”. Then click the Levolor InMotion logo 5 times quickly. A 16 digit Key will appear.

To install on Hubitat, you first need to install both the parent and child drivers. The code for both is below. To do this from go to the “Driver Code” in Hubitat, then “Add Driver”. Highlight the entire code of one of the drivers below, Copy and then Paste it in the New Driver area. Do not change the name. Just click save. Repeat this for both drivers.

Parent Driver:

/**

Motion Blinds / Levolor InMotion Gateway Parent Driver



Protocol:

UDP 32100



Creates child devices:

Shade 1

Shade 2

...

Shade 9



Requires:

Gateway IP

AES Key



Author: Community
*/

import hubitat.device.HubAction
import hubitat.device.Protocol
import groovy.json.JsonOutput
import groovy.json.JsonSlurper

metadata {

definition(
name: "Levolor InMotion Parent Device Gateway",
namespace: "Levolor InMotion",
author: "DN"

) {

capability "Actuator"

command "discover"
command "refreshAll"

    attribute "token","string"
    attribute "status","string"

}


preferences {

    input(
        name:"gatewayIP",
        type:"text",
        title:"Motion Gateway IP Address",
        required:true
    )


    input(
        name:"aesKey",
        type:"text",
        title:"Motion AES Key (16 characters)",
        required:true
    )


    input(
        name:"debugEnable",
        type:"bool",
        title:"Enable Debug Logging",
        defaultValue:true
    )

}

}

def logDebug(msg){

if(debugEnable){
    log.debug "Motion Blinds: ${msg}"
}

}

/*

INSTALL / INITIALIZE

*/

def installed(){

logDebug "Installed"

}

def updated(){

logDebug "Updated"

}

/*

DISCOVERY

*/

def discover(){

def msg = [

    msgType:"GetDeviceList",

    msgID:getTimestamp()

]


sendUDP(msg)

}

/*

REFRESH ALL CHILDREN

*/

def refreshAll(){

logDebug "Refreshing all shade devices"


Integer count = 0


getChildDevices().each { child ->


    count++


    logDebug "Refreshing ${child.displayName}"


    child.refresh()


    // Prevent UDP flooding
    pauseExecution(500)


}


sendEvent(

    name:"status",

    value:"Refreshed ${count} shades"

)


logDebug "Refresh all complete"

}

def push(){

logDebug "Dashboard Refresh button pushed"

refreshAll()

}

/*

CREATE CHILD DEVICES

*/

def createShadeChildren(deviceList){

Integer shadeNumber = 1


deviceList.each { device ->


    if(device.deviceType == "10000000"){


        String childId =
            device.mac


        def child =
            getChildDevice(childId)



        if(!child){


            child =
                addChildDevice(

                    "Levolor InMotion",

                    "Levolor InMotion Child Device",

                    childId,

                    [

                        label:
                            "Shade ${shadeNumber}",

                        isComponent:true

                    ]

                )


            logDebug(
                "Created Shade ${shadeNumber} ${childId}"
            )

        }


        state["shade${shadeNumber}"] =
            childId



        shadeNumber++

    }

}

}

/*

SEND CHILD COMMANDS

*/

def sendShadeCommand(mac, data){

if(!state.token){

    logDebug "No token. Running discovery."

    discover()

    pauseExecution(1000)

}



def msg = [

    msgType:
        "WriteDevice",

    mac:
        mac,

    deviceType:
        "10000000",

    AccessToken:
        createAccessToken(),

    msgID:
        getTimestamp(),

    data:
        data

]


sendUDP(msg)

}

/*

UDP SEND

*/

def sendUDP(message){

String json =
    JsonOutput.toJson(message)



logDebug "UDP OUT ${json}"



try {


    def hubAction =

        new HubAction(

            json,

            Protocol.LAN,

            null,

            [

                destinationAddress:
                    "${gatewayIP}:32100",

                type:
                    HubAction.Type.LAN_TYPE_UDPCLIENT

            ]

        )


    sendHubCommand(
        hubAction
    )


}

catch(Exception e){

    log.error(
        "UDP Send Error ${e}"
    )

}

}

/*

UDP RECEIVE

*/

def parse(String description){

def msg =
    parseLanMessage(description)



if(!msg?.payload){

    return

}



String json



try {


    byte[] bytes =
        msg.payload.trim().decodeHex()


    json =
        new String(
            bytes,
            "UTF-8"
        )


}

catch(Exception e){

    log.error(
        "Decode error ${e}"
    )

    return

}



logDebug "RX ${json}"



def response


try {

    response =
        new JsonSlurper()
        .parseText(json)

}

catch(Exception e){

    log.error(
        "JSON error ${e}"
    )

    return

}



/*
-------------------------
Discovery Response
-------------------------
*/


if(response.msgType ==
    "GetDeviceListAck"){



    state.token =
        response.token



    sendEvent(

        name:"token",

        value:
            state.token

    )



    createShadeChildren(
        response.data
    )


}





/*
-------------------------
Pass shade messages
-------------------------
*/


if(response.msgType ==
    "WriteDeviceAck"){



    def child =
        getChildDevice(
            response.mac
        )



    if(child){


        child.parseMotionResponse(
            response
        )


    }


}

}

/*

AES TOKEN

*/

def createAccessToken(){

if(!state.token){

    return null

}



try {


    javax.crypto.spec.SecretKeySpec key =

        new javax.crypto.spec.SecretKeySpec(

            aesKey.getBytes("UTF-8"),

            "AES"

        )



    javax.crypto.Cipher cipher =

        javax.crypto.Cipher.getInstance(

            "AES/ECB/NoPadding"

        )



    cipher.init(

        javax.crypto.Cipher.ENCRYPT_MODE,

        key

    )



    byte[] encrypted =

        cipher.doFinal(

            state.token.getBytes("UTF-8")

        )



    return encrypted
        .encodeHex()
        .toString()
        .toUpperCase()


}

catch(Exception e){

    log.error(
        "AES Error ${e}"
    )

    return null

}

}

/*

HELPERS

*/

def getTimestamp(){

return new Date()
    .format(

        "yyyyMMddHHmmssSSS",

        TimeZone.getTimeZone("UTC")

    )

}

Child Driver

/**

Motion Blinds / Levolor InMotion Shade Child Driver



Child device driver



Names:

Shade 1

Shade 2

...



Protocol:

UDP 32100



Author: DN
*/

metadata {

definition(
    name: "Levolor InMotion Child Device",
    namespace: "Levolor InMotion",
    author: "DN"
) {


    capability "WindowShade"
    capability "Battery"


    command "stop"

    command "refresh"


    command "setPosition",
    [
        [
            name:"Position",
            type:"NUMBER",
            description:"0-100"
        ]
    ]


    attribute "position","number"
    attribute "voltage","number"
    attribute "rssi","number"
    attribute "shadeMAC","string"
    attribute "lastUpdate","string"


}



preferences {


    input(
        name:"childDebugEnable",
        type:"bool",
        title:"Enable child debug logging?",
        defaultValue:true,
        required:false
    )


}

}

/*

INSTALL

*/

def installed(){

sendEvent(

    name:"shadeMAC",

    value:
        device.deviceNetworkId

)


logDebug(

    "Installed ${device.displayName}"

)

}

/*

COMMANDS

*/

def open(){

logDebug(

    "OPEN command received"

)


parent.sendShadeCommand(

    device.deviceNetworkId,

    [

        operation:1

    ]

)


startMovementRefresh()

}

def close(){

logDebug(

    "CLOSE command received"

)


parent.sendShadeCommand(

    device.deviceNetworkId,

    [

        operation:0

    ]

)


startMovementRefresh()

}

def stop(){

logDebug(

    "STOP command received"

)


parent.sendShadeCommand(

    device.deviceNetworkId,

    [

        operation:2

    ]

)

}

/*

REFRESH

*/

def refresh(){

logDebug(

    "REFRESH command received"

)


parent.sendShadeCommand(

    device.deviceNetworkId,

    [

        operation:5

    ]

)

}

/*

SET POSITION

*/

def setPosition(position){

Integer target =

    position.toInteger()



if(target < 0){

    target = 0

}



if(target > 100){

    target = 100

}



logDebug(

    "SET POSITION ${target}%"

)



parent.sendShadeCommand(

    device.deviceNetworkId,

    [

        targetPosition:target

    ]

)


startMovementRefresh()

}

/*

MOVEMENT REFRESH CONTROL

*/

/*
After any movement command:

1 second refresh
then every 10 seconds

Total duration:
approximately 70 seconds

This allows time for long shade travel
while avoiding unnecessary polling forever.
*/

def startMovementRefresh(){

logDebug(

    "Starting movement refresh cycle"

)


state.refreshCount = 0


unschedule(movementRefresh)


runIn(

    1,

    "movementRefresh"

)

}

def movementRefresh(){

state.refreshCount =

    (state.refreshCount ?: 0) + 1



logDebug(

    "Movement refresh ${state.refreshCount}/8"

)



refresh()



/*
Refresh timing:

1 second
11 seconds
21 seconds
31 seconds
41 seconds
51 seconds
61 seconds
71 seconds

Stops after final check.
*/



if(state.refreshCount < 8){


    runIn(

        10,

        "movementRefresh"

    )


}

else{


    logDebug(

        "Movement refresh cycle complete"

    )


    state.refreshCount = 0


}

}

/*

RESPONSE FROM PARENT

*/

def parseMotionResponse(response){

logDebug(

    "RX DATA ${response.data}"

)



def data =

    response.data



if(!data){

    return

}






/*
----------------------------------------
POSITION

Motion:
0 = open
100 = closed

Hubitat:
0 = open
100 = closed

No conversion required.
----------------------------------------
*/


if(data.currentPosition != null){


    Integer position =

        data.currentPosition.toInteger()



    sendEvent(

        name:"position",

        value:position

    )



    sendEvent(

        name:"windowShade",

        value:

            position == 0 ? "open" :

            position == 100 ? "closed" :

            "partially open"

    )



    logDebug(

        "Motion ${position}% -> Hubitat ${position}%"

    )


}








/*
----------------------------------------
BATTERY

Preserve existing calculation

Example:
802 = 8.02V
----------------------------------------
*/


if(data.batteryLevel != null){


    BigDecimal volts =

        data.batteryLevel / 100.0




    sendEvent(

        name:"voltage",

        value:

            Math.round(volts * 100) / 100.0

    )





    Integer battery =

        calculateBattery(volts)





    sendEvent(

        name:"battery",

        value:battery

    )





    logDebug(

        "Battery ${volts}V ${battery}%"

    )


}






/*
----------------------------------------
RSSI
----------------------------------------
*/


if(data.RSSI != null){


    sendEvent(

        name:"rssi",

        value:data.RSSI

    )


}





sendEvent(

    name:"lastUpdate",

    value:new Date().toString()

)

}

/*

BATTERY

*/

Integer calculateBattery(BigDecimal volts){

Integer percent =


    Math.round(


        ((volts - 6.0) /

        (8.4 - 6.0))

        * 100


    )





if(percent > 100){

    percent = 100

}





if(percent < 0){

    percent = 0

}





return percent

}

/*

LOGGING

*/

def logDebug(msg){

if(childDebugEnable == true){


    log.debug(

        "${device.displayName}: ${msg}"

    )


}

}

Once both driver code are set, go to:

Device → Add Device → Virtual → Levolor InMotion Parent Device Gateway

Finish making this device like any other.

Once done, go in to that newly created device, go to the Preferences area, enter your IP address and the 16 digit key discovered from the Levolor InMotion app (include dashes). CLICK SAVE

Then go back to the commands tab. Click discover and then refresh.

You should now have child devices, one for each of your shades/blinds.

1 Like