/** * Connector WLAN Integration - Full Production * DD7006 & DD7002B Compatible ? * Thank you Jcongdon01 for working with and Testing the DD7002B Hub *Trial Parent Driver v5 */ import groovy.json.JsonSlurper import groovy.json.JsonOutput import hubitat.helper.HexUtils import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec // THIS LINE WAS MISSING metadata { definition(name: "Connector WLAN Integration (Full)", namespace: "connector", author: "Scubamikejax904 & Manus", importUrl: "https://raw.githubusercontent.com/scubamikejax904/Connector-Bridge-Hubitat-direct/refs/heads/main/Driver%20-%20Parent") { capability "Initialize" capability "Refresh" command "discoverDevices" } preferences { input name: "bridgeIp", type: "text", title: "Bridge IP", required: true input name: "bridgeKey", type: "text", title: "Bridge Key (16-char string from app, with hyphens)", required: true input name: "hubType", type: "enum", title: "Hub Type", options: ["DD7002B", "DD7006", "Auto-Detect"], defaultValue: "Auto-Detect", required: true input name: "offlineTimeout", type: "number", title: "Offline Detection Timeout (minutes, 0=disabled)", defaultValue: 2, required: true input name: "debugLogging", type: "bool", title: "Enable Debug Logging", defaultValue: true } } /* -------------------------------------------------- */ def installed() { initialize() } def updated() { unschedule(); initialize() } def initialize() { logDebug "Initializing Connector WLAN Integration..." state.token = null state.commandQueue = [] state.detectedHubType = null runIn(3, "discoverDevices") // Only schedule offline check if timeout > 0 if (offlineTimeout != null && offlineTimeout > 0) { runEvery1Minute("checkOffline") logDebug "Offline detection enabled with ${offlineTimeout} minute timeout." } else { logDebug "Offline detection disabled." } } /* -------------------------------------------------- DISCOVERY - Works for both DD7002B and DD7006A -------------------------------------------------- */ def discoverDevices() { def payload = [ msgType: "GetDeviceList", msgID : now().toString() ] sendUdp(payload) } /* -------------------------------------------------- HUB TYPE DETECTION -------------------------------------------------- */ def detectHubType(Map json) { // Auto-detect based on response characteristics if (hubType != "Auto-Detect") { state.detectedHubType = hubType log.info "Hub type set from preferences: ${hubType}" return } // Both hubs use same protocol, detection is for logging purposes // Protocol version can indicate hub generation def protocol = json.ProtocolVersion ?: "unknown" if (protocol.startsWith("0.")) { state.detectedHubType = "DD7002B or DD7006A" } else { state.detectedHubType = "DD7006A (newer protocol)" } log.info "Auto-detected hub type: ${state.detectedHubType} (Protocol: ${protocol})" } /* -------------------------------------------------- CHILD MANAGEMENT -------------------------------------------------- */ def createChild(mac) { if (getChildDevice(mac)) return addChildDevice( "connector", "Connector Screen Child", mac, [ label: "Screen ${mac[-4..-1]}", isComponent: false ] ) log.info "Created child ${mac}" } /* -------------------------------------------------- CHILD COMMANDS -------------------------------------------------- */ def sendChildCommand(String mac, Integer position) { queueCommand("doSendChildCommand", [mac: mac, position: position]) } def sendChildStop(String mac) { queueCommand("doSendChildStop", [mac: mac]) } def refreshChild(String mac) { queueCommand("doRefreshChild", [mac: mac]) } /* -------------------------------------------------- TOKEN & QUEUE MANAGEMENT -------------------------------------------------- */ def queueCommand(String commandName, Map args) { logDebug "Queueing command: ${commandName} with args: ${args}" def queue = state.commandQueue ?: [] queue << [command: commandName, arguments: args] state.commandQueue = queue if (!state.token) { discoverDevices() } else { processCommandQueue() } } def processCommandQueue() { def queue = state.commandQueue ?: [] if (queue.size() == 0) { logDebug "Command queue is empty." return } logDebug "Processing ${queue.size()} command(s) from queue." state.commandQueue = [] queue.each { cmdInfo -> logDebug "Executing from queue: ${cmdInfo.command}" this."${cmdInfo.command}"(cmdInfo.arguments) } } /* -------------------------------------------------- INTERNAL COMMAND IMPLEMENTATIONS -------------------------------------------------- */ def doSendChildCommand(Map args) { def commandData = [ targetPosition: args.position as Integer ] String accessToken = generateAccessToken(state.token) if (!accessToken) { log.error "Cannot send command, failed to calculate AccessToken." return } def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: "10000000", msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildStop(Map args) { def commandData = [ operation: 2 ] String accessToken = generateAccessToken(state.token) if (!accessToken) { log.error "Cannot send command, failed to calculate AccessToken." return } def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: "10000000", msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doRefreshChild(Map args) { sendUdp([ msgType: "ReadDevice", mac: args.mac, deviceType: "10000000", msgID: now().toString(), AccessToken: generateAccessToken(state.token) ]) } /* -------------------------------------------------- ACCESS TOKEN CALCULATION Same algorithm works for both DD7002B and DD7006A -------------------------------------------------- */ def generateAccessToken(token) { if (!bridgeKey || !token) return null try { SecretKeySpec skeySpec = new SecretKeySpec(bridgeKey.getBytes("UTF-8"), "AES") Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, skeySpec) byte[] tokenBytes = token.getBytes("UTF-8") boolean fullMode = (hubType == "DD7002B" || state.detectedHubType?.contains("7002B")) int blockSize = fullMode ? 32 : 16 byte[] paddedToken = new byte[blockSize] // copy token and ensure remaining bytes are zero for (int i = 0; i < blockSize; i++) { if (i < tokenBytes.length) { paddedToken[i] = tokenBytes[i] } else { paddedToken[i] = 0 } } byte[] encrypted = cipher.doFinal(paddedToken) String hex = HexUtils.byteArrayToHexString(encrypted).toUpperCase() if (!fullMode) { hex = hex.substring(0,32) } logDebug "Generated AccessToken (${fullMode ? "FULL" : "TRUNCATED"}): ${hex}" return hex } catch (Exception e) { log.error "Encryption Error: ${e.message}" return null } logDebug "HubType: ${hubType} Detected: ${state.detectedHubType} TokenLen: ${hex.length()}" } /* -------------------------------------------------- UDP SEND -------------------------------------------------- */ def sendUdp(Map message) { String json = JsonOutput.toJson(message) // If debug is off, create a sanitized version of the log message if (!debugLogging) { Map sanitizedMessage = new HashMap(message) if (sanitizedMessage.containsKey("AccessToken")) { sanitizedMessage.AccessToken = "[REDACTED]" } logDebug "UDP -> ${JsonOutput.toJson(sanitizedMessage)}" } else { logDebug "UDP -> ${json}" } def action = new hubitat.device.HubAction( HexUtils.byteArrayToHexString(json.getBytes("UTF-8")), hubitat.device.Protocol.LAN, [ type: hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, destinationAddress: "${bridgeIp}:32100", encoding: hubitat.device.HubAction.Encoding.HEX_STRING ] ) sendHubCommand(action) } /* -------------------------------------------------- PARSE -------------------------------------------------- */ def parse(String description) { def msg = parseLanMessage(description) if (!msg?.payload) return String jsonString = new String(HexUtils.hexStringToByteArray(msg.payload), "UTF-8") if (!debugLogging) { def json = new JsonSlurper().parseText(jsonString) Map sanitizedJson = new HashMap(json) if (sanitizedJson.containsKey("token")) sanitizedJson.token = "[REDACTED]" logDebug "UDP Received: ${JsonOutput.toJson(sanitizedJson)}" } else { logDebug "UDP Received: ${jsonString}" } def json = new JsonSlurper().parseText(jsonString) if (json.msgType == "GetDeviceListAck") { state.token = json.token log.info "Session token acquired/refreshed." processCommandQueue() json.data.each { if (it.deviceType == "10000000") { createChild(it.mac) } } } if (json.msgType == "Heartbeat") { log.info "Heartbeat received." if (state.token != json.token) { log.warn "Heartbeat token is different. Updating session token." state.token = json.token } } if (json.msgType == "WriteDeviceAck") { if (json.actionResult == "AccessToken error") { log.warn "AccessToken error — refreshing token" state.token = null discoverDevices() } else { log.info "SUCCESS! Received WriteDeviceAck." } } // Combine ReadDeviceAck and Report processing if (json.msgType in ["ReadDeviceAck", "Report"]) { def child = getChildDevice(json.mac) if (!child) return def pos = json.data?.currentPosition as Integer updateChildEvents(child, pos) } } /* -------------------------------------------------- OFFLINE DETECTION -------------------------------------------------- */ def checkOffline() { // Skip if offline detection is disabled (timeout = 0 or null) if (offlineTimeout == null || offlineTimeout == 0) { return } def timeoutSeconds = offlineTimeout * 60 getChildDevices().each { child -> def last = child.currentValue("lastSeen") if (!last) return def diff = (now() - Date.parse("EEE MMM dd HH:mm:ss z yyyy", last).time) / 1000 if (diff > timeoutSeconds && child.currentValue("windowShade") != "unknown") { log.warn "Device ${child.deviceNetworkId} has not been seen for over ${offlineTimeout} minute(s). It may be offline." } } } /* -------------------------------------------------- */ def updateChildEvents(child, pos) { def currentLevel = child.currentValue("level") def currentShade = child.currentValue("windowShade") // Update level only if changed if (currentLevel != pos) { child.sendEvent(name: "level", value: pos) } // Determine windowShade def shadeValue = (pos == 0) ? "open" : (pos == 100) ? "closed" : "partially open" // Update windowShade only if changed if (currentShade != shadeValue) { child.sendEvent(name: "windowShade", value: shadeValue) } // Always update lastSeen (optional: throttle if needed) child.sendEvent(name: "lastSeen", value: new Date().toString()) } def refresh() { getChildDevices().each { refreshChild(it.deviceNetworkId) } } def logDebug(msg) { if (debugLogging) log.debug "[Connector] ${msg}" }