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.