一键导入
tuya-local-groovy
Implement Tuya Local v3.3 Groovy drivers on Hubitat using rawSocket, AES-128-ECB, queued retries, and defensive frame parsing.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Implement Tuya Local v3.3 Groovy drivers on Hubitat using rawSocket, AES-128-ECB, queued retries, and defensive frame parsing.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Pattern for keeping a rolling temperature-trend sample buffer in Hubitat state, computing slope over a configurable window, and classifying rising/falling/steady/unknown.
Use Hubitat async HTTP plus a request queue to authenticate with Cognito, cache tokens in state, refresh proactively, and replay a single 401-failed request.
When two agents produce overlapping skills in one session, consolidate by merging unique content into the highest-confidence existing skill.
Never let cached-state dedup bypass session validity in cloud-backed Hubitat drivers.
Standard guard pattern for async HTTP response callbacks in Hubitat drivers
Correct formula for converting lat/lng degree differences to miles, with pole clamp for numerical stability
| name | tuya-local-groovy |
| description | Implement Tuya Local v3.3 Groovy drivers on Hubitat using rawSocket, AES-128-ECB, queued retries, and defensive frame parsing. |
| domain | hubitat-drivers |
| confidence | high |
| source | earned — Touchstone v0.1.2 verified the Hubitat import-allowlist CRC32 fix pattern on 2026-05-17; Touchstone v0.1.29 validated byte-helper primitive optimization on 2026-05-18; Touchstone v0.1.30 confirmed System.arraycopy is on the Hubitat sandbox blocklist (2026-05-18); PurpleAir v0.4.0 validated the single-line changelog requirement for .github/workflows/release.yml on 2026-05-18; protocol cross-checked against tinytuya XenonDevice/message_helper/header and qwerk's Hubitat Tuya RGBW driver. |
Use this skill when a Hubitat driver must talk directly to a Tuya WiFi device over the LAN using a deviceId, localKey, and raw TCP port 6668.
This skill is specifically for:
55AA framing + AES-128-ECB)interfaces.rawSocketDo not use this skill for:
6699 framing)interfaces.rawSocket.connect(settings.deviceIP, 6668, byteInterface: true, readDelay: 150)
Important: even with byteInterface: true, Hubitat commonly delivers the payload to parse(String message) as a hex string. Treat the incoming stream as hex text, not as JSON or a byte array callback.
Keep a hex buffer in state.rxBuffer, append each parse() chunk, and process complete frames only when you have enough bytes:
000055AA12..15 (length field)16 + length0000AA55Never assume one parse() call equals one Tuya frame.
[prefix 0x000055AA][seq 4B][cmd 4B][length 4B][payload][crc32 4B][suffix 0x0000AA55]
AES/ECB/PKCS5Padding using the 16-byte localKey3.3 + 12 zero bytes after encryption55AA frame3.3 headerFor v3.3, these commands should not get the protocol header prepended:
0x09)0x0a)Commands like control (0x07) and device22/new query (0x0d) do carry the 3.3 + zero-padding header.
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
private byte[] aesEncrypt(byte[] plaintext, byte[] keyBytes) {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
return cipher.doFinal(plaintext)
}
private byte[] aesDecrypt(byte[] ciphertext, byte[] keyBytes) {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES")
cipher.init(Cipher.DECRYPT_MODE, keySpec)
return cipher.doFinal(ciphertext)
}
device22 Query GotchaIf the Tuya deviceId is 22 characters long, be ready for the device22 status-query edge case:
0x0adevice22 query: command 0x0ddps map, for example:[
devId: settings.deviceId,
uid: settings.deviceId,
t: currentEpochSecondsString(),
dps: ["1": null, "2": null, "101": null]
]
If the device responds with data unvalid, switch to 0x0d mode and retry once. This mirrors tinytuya's device22 handling.
installed() → set defaults → initialize()updated() → unschedule() → reset socket/queue state → initialize()initialize() should schedule polling and queue an initial refresh()logEnable auto-off pattern (runIn(1800, "logsOff"))parse() defensive: log + bail on bad CRC / bad suffix / short frameslocalKeyTuya WiFi devices are often effectively single-client. If Smart Life or another client holds the socket, your driver may time out or drop the connection.
Recommended pattern:
state.pendingRequestsstate.awaitingResponse = trueThis is the Hubitat/Groovy equivalent of the tinytuya 901 busy/unavailable scenario.
For Touchstone-style fireplaces, DP 14 (°F setpoint) may momentarily revert to a default right after a power transition.
Safer pattern:
state.lastPowerTransitionAtrefresh() after writes (especially power on/off)Hubitat driver failures cluster into three verified sandbox families. Audit all three whenever a driver hits one sandbox-related install or runtime problem.
Hubitat rejects many JDK imports in drivers, including java.util.zip.*, ByteArrayOutputStream, and large parts of java.io.*, java.nio.*, and java.security.*.
Additionally, certain java.lang.* method calls are blocked at runtime even though java.lang is normally auto-imported. Confirmed blocked method calls:
java.lang.System.arraycopy(...) — blocked (Hubitat sandbox expression blocklist; confirmed v0.1.30 sandbox rejection)java.util.zip.CRC32 — blocked via import allowlist (confirmed v0.1.2)For Tuya v3.3 CRC32, keep the implementation in pure Groovy:
@Field static final long[] CRC32_TABLE0xFFFFFFFFL, reversed polynomial 0xEDB88320L, reflected byte updates, xor-out 0xFFFFFFFFLlong/Long, then write it big-endian into the Tuya frameReference shape:
@Field static final long[] CRC32_TABLE = (0..255).collect { int n ->
long c = n as long
8.times {
c = ((c & 1L) != 0L) ? (0xEDB88320L ^ (c >>> 1)) : (c >>> 1)
}
c & 0xFFFFFFFFL
} as long[]
private long crc32(byte[] data) {
long crc = 0xFFFFFFFFL
for (byte b : data) {
crc = CRC32_TABLE[((int) (crc ^ (b & 0xFF))) & 0xFF] ^ (crc >>> 8)
}
return (crc ^ 0xFFFFFFFFL) & 0xFFFFFFFFL
}
Hubitat's sandbox restrictions are not limited to imports. Reflection-style runtime inspection is blocked in drivers too.
Avoid patterns like:
.getClass() on exceptions or arbitrary values.class reads.metaClass, .respondsTo(), and .hasProperty()getMethods(), getFields(), getDeclaredMethods(), etc.)Class.forName() and similar runtime type discoverysomeObj.&methodName) when it depends on runtime method lookupSafer replacements:
e.message instead of trying to print the exception class nameinstanceof checks or typed catch (...) blocks when behavior truly differs by type"object" instead of probing runtime metadataHubitat driver preferences {} is much narrower than app UI.
Safe driver patterns:
input only, with Hubitat-confirmed driver types: bool, decimal, email, enum, number, password, phone, text, timeif (settings.someField == ...) { input ... } is valid in driversDo not use app-only constructs in drivers:
paragraphsectionhrefappmodepageDefaultIf you need explanatory copy for a group of driver preferences, fold it into each input's description: instead of trying to render a section header or paragraph block.
These three families travel together: if a Hubitat driver trips one sandbox rule, audit the file for the other two before shipping.
Do not assume kkossev's Zigbee Tuya drivers contain the WiFi/rawSocket protocol layer.
When only one Tuya model has been fully verified, prefer a three-tier profile strategy:
if (settings?.deviceProfile == "Custom") and let users override only the roles that vary.Hubitat-specific gotchas:
dpFor(role) at command/parse time, not once during updated(), so new preference values apply immediately.Recommended discovery commands:
discoverDPs() — run a status query and log the typed DP dump at info level.captureBaseline() + captureDiff() — snapshot current DPs, then diff after a remote/app action.setRawDP(dpId, value) — allow auditable direct writes with bool/int/string coercion.This keeps the driver honest about what is tested while still giving adjacent Tuya models a workable self-service mapping path inside Hubitat.
For drivers where real-time push updates matter (physical remotes, multi-controller setups), use a persistent socket rather than the open→send→close pattern.
def initialize() {
unschedule()
closeSocket(false) // stamps intentionalCloseAt; suppresses socketStatus callback
// ... reset state ...
runIn(1, "openSocket") // 1s gap lets OS release the port
}
def openSocket() {
try {
interfaces.rawSocket.connect(settings.deviceIP, 6668, byteInterface: true, readDelay: 150)
state.socketOpen = true
state.reconnectAttempts = 0
updateSocketState("open")
unschedule("sendHeartbeat")
runIn(HEARTBEAT_INTERVAL_SECONDS, "sendHeartbeat")
runIn(2, "refresh")
} catch (Exception e) {
state.socketOpen = false
updateSocketState("error")
scheduleReconnect()
}
}
Interval rationale: Cypher's protocol research (
.squad/agents/cypher/history.md) confirmed 20s is a verified-stable keep-alive interval for Tuya v3.3 Sideline hardware. Earlier guidance used 10s; bumping to 20s halves the scheduled-job + sendEvent load with no observed socket drops. Do not exceed 25s without hardware verification — Tuya idles connections at ~30s.
@Field static final Integer HEARTBEAT_INTERVAL_SECONDS = 20
def sendHeartbeat() {
if (!preferencesReady() || state.socketOpen != true) return
try {
byte[] frame = buildTuyaFrame(TUYA_CMD_HEARTBEAT, "")
interfaces.rawSocket.sendMessage(hubitat.helper.HexUtils.byteArrayToHexString(frame))
debugLog "Heartbeat sent"
} catch (Exception e) {
log.warn "[Driver] Heartbeat failed: ${e.message}"
state.socketOpen = false
unschedule("sendHeartbeat")
scheduleReconnect()
return
}
runIn(HEARTBEAT_INTERVAL_SECONDS, "sendHeartbeat")
}
Critical: Tuya cmd 9 heartbeat must have truly empty payload (0 bytes, no AES encryption). Add this guard to
encryptTuyaPayload():if (cmd == TUYA_CMD_HEARTBEAT) { return new byte[0] }Without it,
AES/ECB/PKCS5Paddingproduces 16 bytes of padding for empty input — an invalid Tuya heartbeat.
@Field static final List<Integer> RECONNECT_DELAYS_SECONDS = [5, 30, 60, 300]
private void scheduleReconnect() {
Integer attempt = safeInt(state.reconnectAttempts, 0)
Integer delay = RECONNECT_DELAYS_SECONDS[Math.min(attempt, RECONNECT_DELAYS_SECONDS.size() - 1)]
state.reconnectAttempts = (attempt + 1)
updateSocketState("reconnecting")
unschedule("reconnectSocket")
runIn(delay, "reconnectSocket")
}
def reconnectSocket() {
if (state.socketOpen == true) return
openSocket()
}
private void closeSocket(Boolean markOffline) {
state.intentionalCloseAt = now() // 3-second grace window
state.socketOpen = false
unschedule("sendHeartbeat")
try { interfaces.rawSocket.close() } catch (ignored) {}
updateSocketState("closed")
}
def socketStatus(String message) {
Long intentionalAt = safeLong(state.intentionalCloseAt, 0L)
if ((now() - intentionalAt) < 3000L) {
debugLog "socketStatus (intentional close, suppressed): ${message}"
return
}
// handle real disconnect → scheduleReconnect()
}
A boolean
intentionalCloseflag has a race condition:initialize()resets it beforesocketStatus()fires. The timestamp approach is safe — stale values from previous sessions have a large(now() - intentionalAt)diff and never suppress.
In the persistent model, pumpQueue() should check a flag rather than try to connect:
if (state.socketOpen != true) {
debugLog "pumpQueue: socket not open; waiting for reconnect"
return
}
After openSocket() succeeds, call runIn(2, "refresh") which triggers enqueueRequest() → pumpQueue() — any queued writes drain first (FIFO queue), then the refresh.
In the persistent model, do NOT close the socket on responseTimeout. Just requeue and retry:
def responseTimeout() {
if (state.awaitingResponse != true) return
requeueInFlight()
scheduleRetry("No response within timeout. Retrying.")
// Do NOT call closeSocket() — keep the persistent connection alive
}
Add attribute "socketState", "enum", ["open", "closed", "reconnecting", "error"] to expose connection health on dashboards. Emit at info level on state transitions only:
private void updateSocketState(String value) {
String current = safeStr(device.currentValue("socketState"))
if (current != value) {
log.info "[Driver] socketState → ${value}"
sendEvent(name: "socketState", value: value)
}
}
Existing parse() → processFrame() → applyDps() pipeline already handles spontaneous STATUS frames (cmd 8). No separate push handler needed — the only change is keeping the socket open so those frames can arrive.
This repo's .github/workflows/release.yml parses each driver's top-of-file Changelog: block with the regex ^(\d+\.\d+\.\d+)\s+[—-]\s+(\d{4}-\d{2}-\d{2})\s+[—-]\s+(.*)$ (see line ~106).
When adding or editing driver changelog entries:
version — YYYY-MM-DD — description2026-05-17T12:22:15-07:00Changelog: block even if separate prose // vX.Y.Z comments also existTuya protocol drivers produce two distinct categories of log output. Mixing them makes logEnable=true unreadable in production.
Add a traceEnable preference (bool, default false) alongside logEnable. Wire a traceLog() helper gated on traceEnable / log.trace that mirrors the existing debugLog() / log.debug helper.
input name: "traceEnable", type: "bool",
title: "Trace logging — very chatty (protocol-level wire debug only; auto-off after 30 minutes)",
defaultValue: false
private void traceLog(String message) {
if (settings.traceEnable) {
log.trace "[Driver] ${message}"
}
}
Mirror the auto-disable pattern: in updated(), schedule runIn(1800, "traceOff") if traceEnable. Provide traceOff() that calls device.updateSetting("traceEnable", [value: "false", type: "bool"]).
traceLog (firehose — off by default):
debugLog (stays at debug — readable in production):
log.warn; leave them aloneBefore calling debugLog in applyDps, compare the decoded label against device.currentValue(attributeName):
if (device.currentValue("flameColor") != label) {
debugLog "applyDps: DP ${dpId} = '${raw}' → '${label}' (changed)"
} else {
traceLog "applyDps: DP ${dpId} = '${raw}' → '${label}' (unchanged)"
}
Apply this pattern to every DP handler that has an explicit log line in applyDps.
The heartbeat received-frame log must be split: route the TUYA_CMD_HEARTBEAT path to traceLog before the normal debugLog for all other commands:
if (cmd == TUYA_CMD_HEARTBEAT) {
traceLog "Received Tuya cmd ${cmd} retcode=${retcode} payloadLen=${payload.length}"
return true
}
debugLog "Received Tuya cmd ${cmd} retcode=${retcode} payloadLen=${payload.length}"
Periodic-refresh queue/send pairs should be trace, not debug:
if (reason == "refresh") {
traceLog "Queued Tuya cmd ${cmd} for ${reason}; pending=${queue.size()}"
} else {
debugLog "Queued Tuya cmd ${cmd} for ${reason}; pending=${queue.size()}"
}
Apply the same conditional to the "Sent Tuya cmd" log in pumpQueue().
When a driver applies user-configured defaults at a lifecycle event (power-on, scene activation, schedule trigger), guard each DP write with a current-attribute check so the write is skipped if the device is already in the desired state.
String current = device.currentValue("flameColor")
if (current != null && current == configuredDefault) {
traceLog "applyOnDefaults: skipping defaultFlameColor — already '${configuredDefault}'"
} else {
debugLog "applyOnDefaults: applying defaultFlameColor = '${configuredDefault}' (was '${current}')"
// proceed with sendDpWrite(...)
}
device.currentValue() returns null when the driver has no prior observation (just installed, or no STATUS received yet). Treat null as "state unknown → apply the default". Skipping when state is unknown would silently fail to apply the user's preference.traceLog; applied paths → debugLog. Consistent with the v0.1.22 trace/debug taxonomy.if (!dpId) { log.warn } branches apply before this check — skip-if-match only executes when the DP is mapped for the active profile.Some Tuya fireplace (and heater) models visibly flicker or emit an audible click when receiving a DP write even if the value is already set. Unconditional writes on every power-on are perceptible to the user. This guard eliminates the artifact while keeping the semantics correct.
defaultFoo preference written at a lifecycle eventdeviceIP, deviceId, localKey(password), polling, logEnable, traceEnableDevice Profile when only one model is fully mapped (Tested / Generic / Custom)Switch, Refresh, InitializeTemperatureMeasurementdiscoverDPs(), captureBaseline(), captureDiff(), setRawDP()parse(String message)openSocket() + 20 s self-rescheduling heartbeat + reconnect backoff [5s, 30s, 60s, 300s]socketState attribute surfaced on dashboardintentionalCloseAt timestamp guard in closeSocket() / socketStatus() to suppress spurious reconnectsdefaultFoo DP write with a currentValue check (skip-if-match + traceLog)Apply this checklist when reviewing any Hubitat driver for redundant device writes. Earned from 2026-05-18 4-driver audit (Touchstone, Gemstone, SunStat parent+child).
Is the write lifecycle-driven or user-explicit?
Lifecycle writes (power-on defaults, timer callbacks, initialize recovery) fire without direct user intent. These are 🔴 candidates. User-explicit commands (setFlameColor, setHeatingSetpoint) are 🟡 — the user asked for it, but automation loops can still cause repetition.
Does the driver check device.currentValue() or state.* before sending?
If not: flag as redundant. The check must gate the actual device write, not just the sendEvent call.
Is emitIfChanged present but the write below it unconditional?
emitIfChanged deduplicates Hubitat events. It does NOT skip the device write. Both guards must be present independently. SunStat child setScheduleEnabled is the canonical example of the half-fix.
Does the device produce a visible or audible side effect on receiving a no-op write?
PUT /play/pattern on Gemstone restarts animation → 🔴Is the non-idempotent write intentional (state-assertion)?
"Restore after override" paths (boost cancel, reconnect recovery, asserting state after cloud drift) are BY-DESIGN. The write must happen unconditionally because the point is to defeat drift. Do not flag as a bug; document as BY-DESIGN.
Does the path fire on every poll or periodic schedule?
Periodic refresh handlers that write state (not just read it) are always 🔴 — they generate constant traffic. Refresh handlers that only call sendEvent based on read data are fine (Hubitat deduplicates events).
For composite writes (pattern, color), can you check a single attribute?
Some writes bundle multiple values (Gemstone pattern = colors + brightness + animation). A single currentValue check may not be sufficient. Options: compare effectName attribute, compare state.lastPattern.id, or accept 🟡 and skip the guard.
| Signal | Severity |
|---|---|
| Tuya DP write causes audible click or visible artifact | 🔴 |
Cloud API play/pattern restarts animation | 🔴 |
| Lifecycle-driven path (fires automatically, not on explicit user command) | 🔴 |
| Cloud REST call consumes API quota; no visible device effect | 🟡 |
| Explicit user command; user intent is clear | 🟡 |
sendEvent without emitIfChanged (Hubitat event flood only; no device write) | 🟢 |
| Write is intentional state-assertion to defeat drift or recovery | BY-DESIGN |
// Example: guard a user command before the device write
String current = safeStr(device.currentValue("flameColor"))
if (current != null && current == label) {
debugLog "setFlameColor: already '${label}' — skipping DP write"
return
}
// proceed with sendDpWrite(...)
Null-current rule: device.currentValue() returns null when the driver has no prior observation. Treat null as "state unknown → apply the write." Only skip when current matches the target.
When Tuya v3.3 drivers assemble or slice frames on every send/receive cycle, keep the helpers on plain byte[] plus primitive int math. Boxed Integer loop counters inside concatBytes(), sliceBytes(), startsWithBytes(), and protocol33HeaderBytes() add avoidable autoboxing overhead in the hottest part of the driver.
for (int i = 0; ...) loops for all byte copies (concatBytes, sliceBytes, protocol header prepend).System.arraycopy(...) — it is on the Hubitat sandbox MethodCallExpression blocklist and will cause a sandbox rejection at install time (confirmed v0.1.30).ByteArrayOutputStream, java.nio, or reflection-based helpers; the Hubitat sandbox/import allowlist makes the simple byte[] helpers the safest portable choice.private byte[] sliceBytes(byte[] source, int start, int length) {
byte[] copy = new byte[length]
for (int i = 0; i < length; i++) { copy[i] = source[start + i] }
return copy
}
private byte[] concatBytes(byte[]... arrays) {
int totalLength = 0
for (byte[] part : arrays) { totalLength += part == null ? 0 : part.length }
byte[] combined = new byte[totalLength]
int offset = 0
for (byte[] part : arrays) {
if (part == null || part.length == 0) { continue }
for (int i = 0; i < part.length; i++) { combined[offset + i] = part[i] }
offset += part.length
}
return combined
}
private Boolean startsWithBytes(byte[] data, byte[] prefix) {
if (!data || !prefix || data.length < prefix.length) {
return false
}
for (int i = 0; i < prefix.length; i++) {
if (data[i] != prefix[i]) {
return false
}
}
return true
}
System.arraycopy was introduced in v0.1.29 as a performance optimisation (perf todo #7). It was rejected by the Hubitat sandbox at install time with:
Expression [MethodCallExpression] is not allowed: java.lang.System.arraycopy(...)
This is the same class of restriction as java.util.zip.CRC32. Perf todo #7 is permanently unachievable on Hubitat. The primitive for-loop pattern above is the correct and final implementation.
v0.1.29 refactored concatBytes(), sliceBytes(), startsWithBytes(), and protocol33HeaderBytes() to replace boxed Integer i loop counters with primitive int, and changed contiguous copies to System.arraycopy. The primitive-int counter change was correct and retained; however, System.arraycopy triggered a Hubitat sandbox rejection. v0.1.30 reverted the three System.arraycopy calls (lines 1428, 1452, 1472) back to primitive for-loops while keeping the primitive int counters.
Use this section when a Tuya local driver is working and then stops responding, especially after an overnight gap.
| Log pattern | Most likely cause |
|---|---|
"No response within 5s" repeating with cmd 13 | AES key mismatch (localKey rotated) or device offline |
cmd 13 timeouts but heartbeats implicit (no socket errors) | Key mismatch — heartbeat has no AES payload, so TCP is live but encrypted queries are silently dropped |
"No response" repeating with cmd 7 (control) | Same as above, or device in bad state |
socketStatus: disconnect / scheduleReconnect logs | IP changed (DHCP) or device lost WiFi |
log.error "Cannot connect to fireplace at X.X.X.X" | IP changed — device not at that address |
| Alternating 5s / 15s retry pattern that never reaches 30s | retryIndex reset by heartbeat ACKs (see driver observation below) |
Queued Tuya cmd 13 for device22 retry on first connection | Normal — device has 22-char deviceId; driver auto-detects and switches from cmd 10 to cmd 13 |
1. Check socketState attribute on the Hubitat device page.
open → socket is live; the problem is AES-level (key) or device statereconnecting / error → IP or WiFi is the issue; skip to step 4closed → driver state issue; run Initialize2. Check healthStatus and lastActivity attributes.
healthStatus = offline and lastActivity is recent → the socket dropped; driver is recoveringlastActivity is hours old → device was unreachable before the user noticed3. Power cycle the fireplace.
Cut the power for 30 seconds. Wait for it to fully rejoin WiFi (~60s). Check if the driver recovers automatically (it should retry the socket connection on next heartbeat or sendHeartbeat() failure).
4. Ping the device IP from another machine on the same network.
ping 192.168.x.x
5. IP changed — check router DHCP table.
Compare the driver's deviceIP preference (Hubitat device page → Preferences) against the router's DHCP lease table. If they differ, either:
deviceIP in preferences and press Initialize, ORDiscover command (performs an active TCP scan to find the device)6. Re-fetch the localKey from Smart Life / Tuya Smart app.
If the device is reachable but cmd 13 (or cmd 7) gets no response, the localKey has likely rotated. Rotation causes: firmware OTA, re-pairing the device in Smart Life, Smart Life account migration.
To extract the current key, use one of:
tuya-cli wizardpython -m tinytuya scanUpdate localKey in Hubitat preferences and press Initialize. The driver will reopen the socket and attempt a fresh session.
7. Last resort — disable/re-enable the Hubitat driver.
If all else fails, open the Hubitat device page → click Delete device state → re-enter preferences → Initialize. This clears state.statusCommand, state.pendingRequests, state.retryIndex, and state.awaitingResponse, removing any potential state corruption.
These are filed in .squad/decisions/inbox/cypher-touchstone-retry-cap.md for Tank to address.
retryIndex resets on any frame including heartbeats (line 869):
state.retryIndex = 0 fires in the parse() successful-frame path, which includes heartbeat ACKs (cmd 9). Since heartbeats carry no AES payload, they succeed even when the localKey is wrong. This causes the retry backoff to oscillate between 5s and 15s indefinitely (never reaching 30s) when the device is online but the key is stale. Correct fix: only reset retryIndex on a frame that carries DP data (response?.dps instanceof Map).
No retry cap:
The retry loop runs forever (RETRY_DELAYS_SECONDS caps at 30s but never stops). After ~10 consecutive failures, the driver should surface healthStatus = offline clearly and stop retrying until Initialize is called.
Socket not reset after prolonged command failures:
responseTimeout() does not close/reopen the socket (by design for the persistent model). But if the key is wrong and the device is dropping all AES payloads, the socket should be closed and reopened as part of the retry-cap exhaustion to clear any half-open TCP state.