Thank you for those logs, sorry it's taking so long it's a slow process when I don't have the device to test. It looks like the other driver was truncating the token to 32 characters and the 70002B is expecting the entire 64 characters, this version should correct that
7002B Test parent driver
/**
- Connector WLAN Integration - Full Production
- DD7006 & DD7002B Compatible ?
*/
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") {
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 P-Box", "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}"
state.commandQueue.add([command: commandName, arguments: args])
discoverDevices()
}
def processCommandQueue() {
if (state.commandQueue == null || state.commandQueue.size() == 0) {
logDebug "Command queue is empty."
return
}
logDebug "Processing ${state.commandQueue.size()} command(s) from queue."
state.commandQueue.each { cmdInfo ->
logDebug "Executing from queue: ${cmdInfo.command}"
this."${cmdInfo.command}"(cmdInfo.arguments)
}
state.commandQueue = []
}
/* --------------------------------------------------
INTERNAL COMMAND IMPLEMENTATIONS
-------------------------------------------------- */
def doSendChildCommand(Map args) {
def commandData = [
targetPosition: args.position as Integer
]
String accessToken = calculateAccessToken()
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 = calculateAccessToken()
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: state.token
])
}
/* --------------------------------------------------
ACCESS TOKEN CALCULATION
Same algorithm works for both DD7002B and DD7006A
-------------------------------------------------- */
def generateAccessToken(token) {
if (!bridgeKey || !token) return null
try {
// AES-128-ECB Encryption
SecretKeySpec skeySpec = new SecretKeySpec(bridgeKey.getBytes("UTF-8"), "AES")
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, skeySpec)
// Protocol requires the token string itself to be encrypted.
// It must be exactly 16 bytes.
byte[] tokenBytes = token.getBytes("UTF-8")
byte[] paddedToken = new byte[16]
// Use a Hubitat-safe loop for padding
for (int i = 0; i < 16; i++) {
paddedToken[i] = (i < tokenBytes.length) ? tokenBytes[i] : (byte)0
}
byte[] encrypted = cipher.doFinal(paddedToken)
String fullToken = HexUtils.byteArrayToHexString(encrypted).toUpperCase()
// The 7002B hub requires the FULL 32-byte (64 character) result.
logDebug "Calculated AccessToken: ${fullToken}"
return fullToken
} catch (e) {
log.error "Encryption Error: ${e.message}"
return null
}
}
/* --------------------------------------------------
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 debug is off, create a sanitized version of the log message
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." // Removed token from this log
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 received. Check that the Bridge Key in preferences is correct."
} else {
log.info "SUCCESS! Received WriteDeviceAck."
}
}
if (json.msgType == "ReadDeviceAck") {
def child = getChildDevice(json.mac)
if (!child) return
def reportData = json.data
log.info "Status Report for ${json.mac}: ${reportData}"
def pos = reportData?.currentPosition as Integer
child.sendEvent(name: "level", value: pos)
child.sendEvent(name: "windowShade",
value: pos == 0 ? "open" :
pos == 100 ? "closed" :
"partially open")
child.sendEvent(name: "lastSeen", value: new Date().toString())
}
if (json.msgType == "Report") {
log.info "Received unsolicited status report for ${json.mac}"
}
}
/* --------------------------------------------------
OFFLINE DETECTION
-------------------------------------------------- */
/*def checkOffline() {
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 it's been over 2 minutes AND the device isn't already marked as unknown...
if (diff > 120 && child.currentValue("windowShade") != "unknown") {
// MODIFICATION: Instead of changing the state, just log a warning.
// This preserves the last known position for the user.
log.warn "Device ${child.deviceNetworkId} has not been seen for over 2 minutes. It may be offline."
}
}
}*/
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 refresh() {
getChildDevices().each {
refreshChild(it.deviceNetworkId)
}
}
def logDebug(msg) {
if (debugLogging) log.debug "[Connector] ${msg}"
}