/** * Connector WLAN Integration - Full Production (New Protocol Only) * DD7006 & DD7002B Compatible - Protocol v1.03+ * Complete implementation of Connector WLAN Integration Protocol v1.03 * * Thank you Jcongdon01 for testing with DD7002B Hub * * Test v32 - 04-16-2026 - Fixed Multicast Heartbeat * *Copyright 2026 Michael Gartner (scubamikejax904) */ import groovy.json.JsonSlurper import groovy.json.JsonOutput import hubitat.helper.HexUtils import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec metadata { definition(name: "Connector WLAN Integration (Full)", namespace: "connector", author: "Scubamikejax904 & NinjaAI & Qwen", //importUrl: "https://raw.githubusercontent.com/scubamikejax904/Connector-Bridge-Hubitat-direct/refs/heads/main/Driver%20-%20Parent" ) { capability "Initialize" capability "Refresh" command "discoverDevices" command "getBridgeStatus" command "logBridgeInfo" command "listOrphanedDevices" command "removeUnusedDevices" command "refreshBridgeData" // Add this line alongside your other commands } 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: "offlineTimeout", type: "number", title: "Offline Detection Timeout (minutes, 0=disabled)", defaultValue: 0, required: true input name: "autoRefreshInterval", type: "number", title: "Auto Refresh Interval (minutes, 0=disabled)", defaultValue: 0, required: true input name: "debugLogging", type: "bool", title: "Enable Debug Logging", defaultValue: true input name: "bridgePort", type: "number", title: "Bridge UDP Port", defaultValue: 32100, required: true // FIX: Add inversion preference for flexibility input name: "invertPosition", type: "bool", title: "⚙️ Invert Position (Bridge 0=Open ↔ Hubitat 100=Open)", defaultValue: true, description: "Enable if bridge reports 0=fully open, 100=fully closed" } } /* -------------------------------------------------- DEVICE TYPE LOOKUP METHODS -------------------------------------------------- */ def getDeviceTypes() { return [ "02000001": "Wi-Fi Bridge", "10000000": "433MHz Radio Motor", "10000001": "433MHz Radio Motor (TDBU)", "22000000": "Wi-Fi Curtain", "22000002": "Wi-Fi Tubular Motor", "22000005": "Wi-Fi Receiver" ] } def getBlindTypes() { return [ 1: "Roller Blinds", 2: "Venetian Blinds", 3: "Roman Blinds", 4: "Honeycomb Blinds", 5: "Shangri-La Blinds", 6: "Roller Shutter", 7: "Roller Gate", 8: "Awning", 9: "TDBU", 10: "Day&Night Blinds", 11: "Dimming Blinds", 12: "Curtain", 13: "Curtain (Open Left)", 14: "Curtain (Open Right)" ] } def getBlindTypeCategory(Integer blindType) { if (blindType == 9) { return "tdbu" } else if (blindType in [2, 14]) { return "venetian" } else { return "roller" } } def getChildDriverName(String category) { switch(category) { case "tdbu": return "Connector TDBU Child" case "venetian": return "Connector Venetian Child" default: return "Connector Roller Blind Child" } } def getOperationTypes() { return [ 0: "Close/Down", 1: "Open/Up", 2: "Stop", 3: "Set Position", 5: "Status Query" ] } def getCurrentStates() { return [ 0: "No Limits", 1: "Top-Limit Detected", 2: "Bottom-Limit Detected", 3: "Limits Detected", 4: "3rd-Limit Detected" ] } def getWirelessModes() { return [ 0: "Uni-direction", 1: "Bi-direction", 2: "Bi-direction (mechanical limits)", 3: "Wi-Fi", 4: "Bi-direction (virtual percentage)", 5: "Others" ] } def getVoltageModes() { return [ 0: "AC Motor", 1: "DC Motor" ] } def getBridgeStates() { return [ 1: "Working", 2: "Pairing", 3: "Updating" ] } /* -------------------------------------------------- */ def installed() { initialize() } def updated() { unschedule(); initialize() } def initialize() { logInfo "Initializing Connector WLAN Integration (New Protocol)..." state.token = null state.tokenTimestamp = null state.commandQueue = [] state.pendingMsgIDs = [] state.bridgeInfo = [:] state.deviceInfo = [:] state.queuePaused = false state.orphanedDevices = [] // ✅ NEW MULTICAST INIT (Hubitat 2.5.0.115+ API) try { // Get multicast socket for the bridge's heartbeat address/port def heartbeatSocket = interfaces.getMulticastSocket("238.0.0.18", 32101) // Connect if not already connected if (heartbeatSocket && !heartbeatSocket.connected) { heartbeatSocket.connect() logInfo "✓ Joined multicast group 238.0.0.18:32101 for heartbeats" } else if (heartbeatSocket?.connected) { logDebug "Multicast socket already connected" } else { logWarn "⚠ Could not obtain multicast socket" } } catch (Exception e) { logWarn "⚠ Multicast initialization failed: ${e.message}. Falling back to polling." } runIn(3, "discoverDevices") if (offlineTimeout != null && offlineTimeout > 0) { runEvery1Minute("checkOffline") logInfo "Offline detection enabled with ${offlineTimeout} minute timeout." } else { logInfo "Offline detection disabled." } if (autoRefreshInterval != null && autoRefreshInterval > 0) { schedule("0 */${autoRefreshInterval} * * * ?", "refresh") logInfo "Auto-refresh enabled every ${autoRefreshInterval} minutes." } sendEvent(name: "orphanedDevices", value: 0) // ✅ Restore & persist bridge status safely state.bridgeStatus = state.bridgeStatus ?: "checking" sendEvent(name: "bridgeStatus", value: state.bridgeStatus) // ✅ Schedule UI/data updates after device is fully registered runIn(1, "updateBridgeDataValues") runEvery5Minutes("checkBridgeStatus") } /* -------------------------------------------------- DISCOVERY -------------------------------------------------- */ def discoverDevices() { def payload = [ msgType: "GetDeviceList", msgID: now().toString() ] sendUdp(payload) } /* -------------------------------------------------- BRIDGE STATUS -------------------------------------------------- */ def getBridgeStatus() { def info = state.bridgeInfo ?: [:] logInfo "Bridge Status: ${info}" return info } def logBridgeInfo() { def info = state.bridgeInfo ?: [:] def deviceTypes = getDeviceTypes() def bridgeStatus = device.currentValue("bridgeStatus") ?: "checking" logInfo "=== Bridge Information ===" logInfo "MAC: ${info.mac ?: 'Unknown'}" logInfo "Device Type: ${deviceTypes[info.deviceType] ?: info.deviceType}" logInfo "Protocol Version: ${info.protocolVersion ?: 'Unknown'}" logInfo "Number of Devices: ${info.numberOfDevices != null ? info.numberOfDevices : 'Waiting for discovery...'}" logInfo "Bridge Status: ${bridgeStatus.capitalize()}" logInfo "==========================" } /* -------------------------------------------------- CHILD MANAGEMENT -------------------------------------------------- */ def createChild(Map deviceData) { def mac = deviceData.mac def deviceType = deviceData.deviceType def deviceTypes = getDeviceTypes() if (getChildDevice(mac)) { def child = getChildDevice(mac) child.updateDataValue("deviceType", deviceType) return } def deviceTypeName = deviceTypes[deviceType] ?: "Unknown Device" def isTDBU = (deviceType == "10000001") def childDriverName = isTDBU ? "Connector TDBU Child" : "Connector Roller Blind Child" addChildDevice("connector", childDriverName, mac, [ label: "${deviceTypeName} ${mac[-4..-1]}", isComponent: false ]) def child = getChildDevice(mac) child.updateDataValue("deviceType", deviceType) child.updateDataValue("isTDBU", isTDBU ? "true" : "false") logInfo "Created child ${mac} (${deviceTypeName}${isTDBU ? ', TDBU' : ''}) using ${childDriverName}" } def upgradeChildDriver(String mac, Integer blindType) { def child = getChildDevice(mac) if (!child) return def currentDriver = child.getTypeName() def category = getBlindTypeCategory(blindType) def correctDriver = getChildDriverName(category) if (currentDriver != correctDriver) { logInfo "Upgrading ${mac} from ${currentDriver} to ${correctDriver} (blind type ${blindType})" def label = child.getLabel() try { deleteChildDevice(mac) } catch (Exception e) { logError "Failed to delete old child ${mac}: ${e.message}" return } addChildDevice("connector", correctDriver, mac, [ label: label, isComponent: false ]) def newChild = getChildDevice(mac) newChild.updateDataValue("deviceType", child.getDataValue("deviceType")) newChild.updateDataValue("blindType", blindType.toString()) newChild.updateDataValue("isTDBU", (blindType == 9) ? "true" : "false") logInfo "Child ${mac} upgraded to ${correctDriver}" } } def trackOrphanedChildren(List discoveredMacs) { def orphaned = [] getChildDevices().each { child -> if (!discoveredMacs.contains(child.deviceNetworkId)) { logWarn "Device ${child.deviceNetworkId} not reported by bridge (orphaned)" orphaned << child.deviceNetworkId } } state.orphanedDevices = orphaned sendEvent(name: "orphanedDevices", value: orphaned.size()) if (orphaned.size() > 0) { logWarn "Found ${orphaned.size()} orphaned device(s). Run 'removeUnusedDevices' to delete them." } return orphaned } def removeUnusedDevices() { logInfo "Starting manual cleanup of orphaned devices..." def orphaned = state.orphanedDevices ?: [] if (orphaned.isEmpty()) { logInfo "No orphaned devices found. Nothing to remove." return } orphaned.each { mac -> def child = getChildDevice(mac) if (child) { logWarn "Deleting orphaned device: ${mac} (${child.getLabel()})" try { deleteChildDevice(mac) logInfo "✓ Successfully deleted ${mac}" } catch (Exception e) { logError "✗ Failed to delete ${mac}: ${e.message}" } } } state.orphanedDevices = [] sendEvent(name: "orphanedDevices", value: 0) logInfo "Cleanup complete. Removed ${orphaned.size()} device(s)." } def listOrphanedDevices() { def orphaned = state.orphanedDevices ?: [] if (orphaned.isEmpty()) { logInfo "✓ No orphaned devices found. All child devices match bridge." return } logWarn "=== Orphaned Devices (not in bridge) ===" orphaned.each { mac -> def child = getChildDevice(mac) def label = child?.getLabel() ?: "Unknown" logWarn "• ${mac} - ${label}" } logWarn "=========================================" logWarn "Run 'removeUnusedDevices' to delete these ${orphaned.size()} device(s)" } /* -------------------------------------------------- POSITION INVERSION HELPER -------------------------------------------------- */ private Integer invertIfNeeded(Integer position) { if (position == null) return null return invertPosition ? (100 - position) : position } /* -------------------------------------------------- CHILD COMMANDS - Standard -------------------------------------------------- */ def sendChildCommand(String mac, Integer position) { // FIX: Invert Hubitat position to Bridge convention before sending def bridgePos = invertIfNeeded(position) queueCommand("doSendChildCommand", [mac: mac, position: bridgePos]) } def sendChildAngleCommand(String mac, Integer angle) { queueCommand("doSendChildAngleCommand", [mac: mac, angle: angle]) } def sendChildStop(String mac) { queueCommand("doSendChildStop", [mac: mac]) } def sendChildOpen(String mac) { queueCommand("doSendChildOpen", [mac: mac]) } def sendChildClose(String mac) { queueCommand("doSendChildClose", [mac: mac]) } def refreshChild(String mac) { queueCommand("doRefreshChild", [mac: mac]) } /* -------------------------------------------------- CHILD COMMANDS - TDBU -------------------------------------------------- */ def sendChildTDBUPosition(String mac, Integer positionTop, Integer positionBottom) { // FIX: Invert Hubitat positions to Bridge convention def posT = invertIfNeeded(positionTop) def posB = invertIfNeeded(positionBottom) queueCommand("doSendChildTDBUPosition", [mac: mac, positionTop: posT, positionBottom: posB]) } def sendChildTDBUOperation(String mac, Integer operationTop, Integer operationBottom, Boolean top, Boolean bottom) { queueCommand("doSendChildTDBUOperation", [mac: mac, operationTop: operationTop, operationBottom: operationBottom, top: top, bottom: bottom]) } /* -------------------------------------------------- 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() { if (state.queuePaused) { logDebug "Queue paused during token refresh, skipping" return } 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 - Standard -------------------------------------------------- */ def doSendChildCommand(Map args) { def commandData = [ targetPosition: args.position as Integer ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildAngleCommand(Map args) { def commandData = [ targetAngle: args.angle as Integer ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildStop(Map args) { def commandData = [ operation: 2 ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildOpen(Map args) { def commandData = [ operation: 1 ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildClose(Map args) { def commandData = [ operation: 0 ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doRefreshChild(Map args) { def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000000" sendUdp([ msgType: "ReadDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: generateAccessToken(state.token) ]) } /* -------------------------------------------------- INTERNAL COMMAND IMPLEMENTATIONS - TDBU -------------------------------------------------- */ def doSendChildTDBUPosition(Map args) { def commandData = [ targetPosition_T: args.positionTop as Integer, targetPosition_B: args.positionBottom as Integer ] String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000001" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } def doSendChildTDBUOperation(Map args) { def commandData = [:] if (args.top) { commandData.operation_T = args.operationTop as Integer } if (args.bottom) { commandData.operation_B = args.operationBottom as Integer } String accessToken = generateAccessToken(state.token) if (!accessToken) { logError "Cannot send command, failed to calculate AccessToken." return } def child = getChildDevice(args.mac) def deviceType = child?.getDataValue("deviceType") ?: "10000001" def packet = [ msgType: "WriteDevice", mac: args.mac, deviceType: deviceType, msgID: now().toString(), AccessToken: accessToken, data: commandData ] sendUdp(packet) } /* -------------------------------------------------- ACCESS TOKEN CALCULATION -------------------------------------------------- */ def generateAccessToken(token) { if (!bridgeKey || !token) { logError "Cannot generate AccessToken: missing key or token" return null } try { SecretKeySpec skeySpec = new SecretKeySpec(bridgeKey.getBytes("UTF-8"), "AES") Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") cipher.init(Cipher.ENCRYPT_MODE, skeySpec) byte[] tokenBytes = token.getBytes("UTF-8") byte[] encrypted = cipher.doFinal(tokenBytes) String hex = HexUtils.byteArrayToHexString(encrypted).toUpperCase() // AccessToken must always be 32 hex chars (16 bytes) per API spec v1.03 String finalAccessToken = hex.substring(0, 32) logDebug "Generated AccessToken: ${finalAccessToken}" return finalAccessToken } catch (Exception e) { logError "Encryption Error: ${e.message}" return null } } /* -------------------------------------------------- UDP SEND -------------------------------------------------- */ def sendUdp(Map message) { // 🔍 TEMP DIAGNOSTIC: Log EVERY raw input logDebug "🔍 sendUdp() called with msgType: ${message?.msgType}, mac: ${message?.mac}" cleanupPendingMsgIDs() // Try to parse as Hubitat LAN message (unicast responses) def lanMsg = null try { lanMsg = parseLanMessage(message) } catch (Exception e) { lanMsg = null } String json = JsonOutput.toJson(message) if (message.msgID) { def pending = state.pendingMsgIDs ?: [] pending << [id: message.msgID, sent: now()] if (pending.size() > 10) { pending = pending[-10..-1] } state.pendingMsgIDs = pending } 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}:${bridgePort ?: 32100}", encoding: hubitat.device.HubAction.Encoding.HEX_STRING ] ) sendHubCommand(action) } /* -------------------------------------------------- PARSE - Main message handler -------------------------------------------------- */ def parse(message) { try { if (message == null) return cleanupPendingMsgIDs() String raw = message.toString().trim() log.debug '[Connector] parse() input: ' + message?.class?.simpleName String jsonString = raw // 1️⃣ Hubitat Unicast LAN Reply: "index:00, ..., payload:" if (raw.contains('LAN_TYPE_UDPCLIENT')) { def match = raw =~ /payload:([0-9A-Fa-f]+)/ if (match) { jsonString = new String(hubitat.helper.HexUtils.hexStringToByteArray(match[0][1]), 'UTF-8') log.debug '[Connector] Decoded from LAN metadata' } } // 2️⃣ Multicast JSON Wrapper: {"payload":""} else if (raw.startsWith('{"payload":"')) { try { def wrapper = new groovy.json.JsonSlurper().parseText(raw) String inner = wrapper?.payload?.toString()?.trim() if (inner && inner.matches('^[0-9A-Fa-f]+$')) { jsonString = new String(hubitat.helper.HexUtils.hexStringToByteArray(inner), 'UTF-8') log.debug '[Connector] Decoded from multicast wrapper' } else { jsonString = inner ?: raw } } catch (e) { log.warn '[Connector] Multicast wrapper parse failed: ' + e.message } } // 3️⃣ Raw Hex String else if (raw.matches('^[0-9A-Fa-f]+$') && raw.length() % 2 == 0) { jsonString = new String(hubitat.helper.HexUtils.hexStringToByteArray(raw), 'UTF-8') log.debug '[Connector] Decoded from raw hex' } // ✅ Mark bridge online on ANY valid response state.lastBridgeResponseTime = now() if (device.currentValue('bridgeStatus') != 'online') { state.bridgeStatus = 'online' sendEvent(name: 'bridgeStatus', value: 'online') } // 4️⃣ Parse & Route def json = new groovy.json.JsonSlurper().parseText(jsonString) def msgType = json?.msgType if (!msgType) { log.warn '[Connector] msgType is null' return } switch (msgType) { case 'GetDeviceListAck': handleGetDeviceListAck(json); break case 'Heartbeat': log.info '[Connector] ❤️ HEARTBEAT RECEIVED! State: ' + json.data?.currentState + ', Devices: ' + json.data?.numberOfDevices + ', RSSI: ' + json.data?.RSSI handleHeartbeat(json) break case 'ReadDeviceAck': handleReadDeviceAck(json); break case 'WriteDeviceAck': handleWriteDeviceAck(json); break case 'Report': handleReport(json); break default: log.debug '[Connector] Unknown msgType: ' + msgType } } catch (Exception e) { log.error '[Connector] parse() fatal: ' + e.message } } /* -------------------------------------------------- MESSAGE HANDLERS -------------------------------------------------- */ def handleGetDeviceListAck(Map json) { state.token = json.token state.tokenTimestamp = now() state.queuePaused = false logInfo "Session token acquired/refreshed." // FIX: Filter out the bridge itself (02000001) when counting child devices def childCount = json.data?.count { it?.deviceType != "02000001" } ?: 0 state.bridgeInfo = [ mac: json.mac, deviceType: json.deviceType, protocolVersion: json.ProtocolVersion, numberOfDevices: childCount ] // ✅ NEW: Update Device Data tab immediately updateBridgeDataValues() processCommandQueue() def discoveredMacs = [] json.data?.each { device -> // Only create children for actual motor/receiver devices if (device.deviceType in ["10000000", "10000001", "22000000", "22000002", "22000005"]) { createChild(device) discoveredMacs << device.mac } } trackOrphanedChildren(discoveredMacs) } def handleHeartbeat(Map json) { logInfo "Heartbeat received." if (json.token) { state.token = json.token state.tokenTimestamp = now() } if (json.data) { def bridgeStates = getBridgeStates() state.bridgeInfo = state.bridgeInfo ?: [:] state.bridgeInfo.currentState = json.data.currentState state.bridgeInfo.numberOfDevices = json.data.numberOfDevices state.bridgeInfo.RSSI = json.data.RSSI sendEvent(name: "bridgeState", value: bridgeStates[json.data.currentState] ?: "Unknown") sendEvent(name: "deviceCount", value: json.data.numberOfDevices) sendEvent(name: "bridgeRSSI", value: json.data.RSSI) // ✅ NEW: Update Device Data tab with fresh heartbeat values updateBridgeDataValues() logDebug "Bridge state: ${bridgeStates[json.data.currentState]}, Devices: ${json.data.numberOfDevices}, RSSI: ${json.data.RSSI}" } } def handleWriteDeviceAck(Map json) { if (json.actionResult == "AccessToken error") { logWarn "AccessToken error — refreshing token, pausing queue" state.token = null state.tokenTimestamp = null state.queuePaused = true discoverDevices() return } logInfo "WriteDeviceAck received for ${json.mac}" if (json.data) { updateDeviceStatus(json.mac, json.data) } } def handleReadDeviceAck(Map json) { logDebug "ReadDeviceAck received for ${json.mac}" if (json.data) { updateDeviceStatus(json.mac, json.data) } } def handleReport(Map json) { logDebug "Report received for ${json.mac}" if (json.data) { updateDeviceStatus(json.mac, json.data) } } /* -------------------------------------------------- DEVICE STATUS UPDATE -------------------------------------------------- */ def updateDeviceStatus(String mac, Map data) { def child = getChildDevice(mac) if (!child) { logDebug "No child device found for ${mac}" return } def deviceInfo = state.deviceInfo ?: [:] deviceInfo[mac] = data state.deviceInfo = deviceInfo if (data.type != null) { upgradeChildDriver(mac, data.type as Integer) } def isTDBU = (data.type == 9) if (isTDBU) { updateTDBUDeviceStatus(child, data) } else { updateStandardDeviceStatus(child, data) } child.sendEvent(name: "lastSeen", value: new Date().toString()) } def updateStandardDeviceStatus(child, Map data) { def blindTypes = getBlindTypes() def operationTypes = getOperationTypes() def currentStates = getCurrentStates() def voltageModes = getVoltageModes() def wirelessModes = getWirelessModes() // --- POSITION --- (Null-safe processing) if (data.currentPosition != null) { def bridgePos = data.currentPosition as Integer def hubitatLevel = invertIfNeeded(bridgePos) def currentLevel = child.currentValue("level") if (currentLevel != hubitatLevel) { child.sendEvent(name: "level", value: hubitatLevel as Integer) } // FIX: Use Hubitat logic for shade value (100=open, 0=closed) def currentShade = child.currentValue("windowShade") def shadeValue = (hubitatLevel == 100) ? "open" : (hubitatLevel == 0) ? "closed" : "partially open" if (currentShade != shadeValue) { child.sendEvent(name: "windowShade", value: shadeValue) } } // --- ANGLE --- if (data.currentAngle != null) { child.sendEvent(name: "angle", value: data.currentAngle as Integer) } // --- BLIND TYPE --- if (data.type != null) { def blindName = blindTypes[data.type] ?: "Unknown" child.updateDataValue("blindType", data.type.toString()) child.updateDataValue("blindTypeName", blindName) } // --- OPERATION --- if (data.operation != null) { child.sendEvent(name: "operation", value: operationTypes[data.operation] ?: data.operation) } // --- LIMIT STATE --- if (data.currentState != null) { child.sendEvent(name: "limitState", value: currentStates[data.currentState] ?: data.currentState) } // --- VOLTAGE MODE --- if (data.voltageMode != null) { def voltageModeName = voltageModes[data.voltageMode] ?: "Unknown" child.updateDataValue("voltageMode", data.voltageMode.toString()) child.updateDataValue("voltageModeName", voltageModeName) } // --- BATTERY --- (Null-safe + DD7002B compatible) if (data.batteryLevel != null) { def batteryLevelVal = data.batteryLevel as Integer child.sendEvent(name: "batteryLevel", value: batteryLevelVal) // Use 150.0 for floating-point division, explicit casts to avoid Groovy ambiguity def batteryPercent = Math.min(100, Math.max(0, ((((batteryLevelVal - 700) as double) / 150.0 * 100) as Integer))) child.sendEvent(name: "battery", value: batteryPercent) } // --- CHARGING --- if (data.chargingState != null) { def isCharging = (data.chargingState == 1) child.sendEvent(name: "chargingState", value: isCharging ? "charging" : "not charging") } // --- WIRELESS MODE --- if (data.wirelessMode != null) { def wirelessModeName = wirelessModes[data.wirelessMode] ?: "Unknown" child.updateDataValue("wirelessMode", data.wirelessMode.toString()) child.updateDataValue("wirelessModeName", wirelessModeName) child.updateDataValue("isBiDirectional", (data.wirelessMode == 1) ? "true" : "false") } // --- RSSI --- if (data.RSSI != null) { child.sendEvent(name: "rssi", value: data.RSSI) } } def updateTDBUDeviceStatus(child, Map data) { def operationTypes = getOperationTypes() def currentStates = getCurrentStates() // --- TOP POSITION --- (Null-safe) if (data.currentPosition_T != null) { def bridgePosTop = data.currentPosition_T as Integer def hubitatTop = invertIfNeeded(bridgePosTop) child.sendEvent(name: "levelTop", value: hubitatTop as Integer) } // --- BOTTOM POSITION --- (Null-safe) if (data.currentPosition_B != null) { def bridgePosBottom = data.currentPosition_B as Integer def hubitatBottom = invertIfNeeded(bridgePosBottom) child.sendEvent(name: "levelBottom", value: hubitatBottom as Integer) // FIX: Use Hubitat logic for shade value based on bottom position def shadeValue = (hubitatBottom == 100) ? "open" : (hubitatBottom == 0) ? "closed" : "partially open" child.sendEvent(name: "windowShade", value: shadeValue) } // --- AVERAGE LEVEL --- (Only if both positions available) if (data.currentPosition_T != null && data.currentPosition_B != null) { def hubitatTop = invertIfNeeded(data.currentPosition_T as Integer) def hubitatBottom = invertIfNeeded(data.currentPosition_B as Integer) def avgPos = ((hubitatTop + hubitatBottom) / 2) as Integer child.sendEvent(name: "level", value: avgPos) } // --- OPERATIONS --- if (data.operation_T != null) { child.sendEvent(name: "operationTop", value: operationTypes[data.operation_T] ?: data.operation_T) } if (data.operation_B != null) { child.sendEvent(name: "operationBottom", value: operationTypes[data.operation_B] ?: data.operation_B) } // --- LIMIT STATES --- if (data.currentState_T != null) { child.sendEvent(name: "limitStateTop", value: currentStates[data.currentState_T] ?: data.currentState_T) } if (data.currentState_B != null) { child.sendEvent(name: "limitStateBottom", value: currentStates[data.currentState_B] ?: data.currentState_B) } // --- BATTERY --- (Null-safe + DD7002B compatible) if (data.batteryLevel_T != null) { def batTop = data.batteryLevel_T as Integer child.sendEvent(name: "batteryLevelTop", value: batTop) } if (data.batteryLevel_B != null) { def batBottom = data.batteryLevel_B as Integer child.sendEvent(name: "batteryLevelBottom", value: batBottom) } // --- RSSI --- if (data.RSSI != null) { child.sendEvent(name: "rssi", value: data.RSSI) } child.updateDataValue("isTDBU", "true") child.updateDataValue("blindType", "9") child.updateDataValue("blindTypeName", "TDBU") } def checkBridgeStatus() { def lastTime = state.lastBridgeResponseTime ?: 0 def thresholdMs = 5 * 60 * 1000 // 5 minutes def currentStatus = device.currentValue("bridgeStatus") ?: "checking" def isOnline = (now() - lastTime) < thresholdMs def newStatus = isOnline ? "online" : "offline" // Only update if status actually changed if (currentStatus != newStatus) { state.bridgeStatus = newStatus // ✅ PERSIST TO STORAGE sendEvent(name: "bridgeStatus", value: newStatus) // ✅ UPDATE UI logInfo "🌐 Bridge status changed to: ${newStatus.toUpperCase()}" updateBridgeDataValues() // ✅ REFRESH DEVICE DATA TAB } } // Updates the parent device's "Device Data" tab with bridge info def updateBridgeDataValues() { // Safety check: ensure device is ready if (!device) return def info = state.bridgeInfo ?: [:] def currentStatus = device.currentValue("bridgeStatus") if (!currentStatus) currentStatus = state.bridgeStatus ?: "checking" // Core bridge identity device.updateDataValue("bridgeMac", info.mac ?: "Unknown") device.updateDataValue("deviceType", getDeviceTypes()[info.deviceType] ?: info.deviceType) device.updateDataValue("protocolVersion", info.protocolVersion ?: "Unknown") device.updateDataValue("numberOfDevices", info.numberOfDevices?.toString() ?: "0") // Connectivity status (capitalize safely) device.updateDataValue("bridgeStatus", currentStatus.toString().capitalize()) // Protocol state (only if heartbeat data exists) if (info.currentState != null) { device.updateDataValue("protocolState", getBridgeStates()[info.currentState] ?: "Unknown") } if (info.RSSI != null) { device.updateDataValue("bridgeRSSI", "${info.RSSI} dBm") } // Timestamp device.updateDataValue("lastUpdated", new Date().format("yyyy-MM-dd HH:mm:ss")) } def refreshBridgeData() { logInfo "Manually refreshing bridge data display..." updateBridgeDataValues() logInfo "✓ Bridge Device Data tab updated" } /* -------------------------------------------------- OFFLINE DETECTION -------------------------------------------------- */ def checkOffline() { if (offlineTimeout == null || offlineTimeout == 0) return def timeoutSeconds = offlineTimeout * 60 getChildDevices().each { child -> def last = child.currentValue("lastSeen") if (!last) return try { def lastDate = Date.parse("EEE MMM dd HH:mm:ss z yyyy", last) def diff = (now() - lastDate.time) / 1000 if (diff > timeoutSeconds && child.currentValue("windowShade") != "unknown") { logWarn "Device ${child.deviceNetworkId} offline for ${offlineTimeout}+ minutes" child.sendEvent(name: "deviceStatus", value: "offline") } } catch (Exception e) { logWarn "Could not parse lastSeen for ${child.deviceNetworkId}: ${e.message}" } } } /* -------------------------------------------------- PENDING MSGID CLEANUP -------------------------------------------------- */ def cleanupPendingMsgIDs() { def pending = state.pendingMsgIDs ?: [] def cutoff = now() - 30000 def cleaned = pending.findAll { it.sent > cutoff } if (cleaned.size() != pending.size()) { state.pendingMsgIDs = cleaned logDebug "Cleaned ${pending.size() - cleaned.size()} stale pending msgIDs" } } /* -------------------------------------------------- */ def refresh() { getChildDevices().each { refreshChild(it.deviceNetworkId) } } /* -------------------------------------------------- LOGGING HELPERS -------------------------------------------------- */ private logInfo(msg) { log.info "[Connector] ${device?.displayName ?: 'Parent'} - ${msg}" } private logDebug(msg) { if (debugLogging) log.debug "[Connector] ${device?.displayName ?: 'Parent'} - ${msg}" } private logWarn(msg) { log.warn "[Connector] ${device?.displayName ?: 'Parent'} - ${msg}" } private logError(msg, ex = null) { log.error "[Connector] ${device?.displayName ?: 'Parent'} - ${msg}" if (ex) log.exception("Error details", ex) }