/** * Reolink NVR (Service Manager) * Author: Chris Feduniw + Grok and Gemini AI * Namespace: gomce62 * * Centralized design: this app is the ONLY thing that holds NVR credentials, * the ONLY thing that logs in / manages the auth token, and the ONLY thing * that talks HTTP to the NVR. * * Date Who What * ---- --- ---- * 8/31/26 gomce62 Initial release */ import groovy.json.JsonSlurper import groovy.json.JsonOutput definition( name: "Reolink NVR (Service Manager)", namespace: "gomce62", author: "Chris Feduniw + Grok and Gemini AI", importUrl:"https://raw.githubusercontent.com/gomce62/Hubitat/refs/heads/apps/Reolink%20NVR%20Service%20Manager", description: "Discovers channels on a Reolink NVR, creates/manages child devices, and centrally handles login/polling/HTTP for all of them.", category: "Video", menu: "Integrations", iconUrl: "", iconX2Url: "", iconX3Url: "", singleInstance: false ) preferences { page(name: "mainPage") page(name: "discoveryPage") } // ═════════════════════════════════════════════════════════════════════════ // Pages // ═════════════════════════════════════════════════════════════════════════ def mainPage() { dynamicPage(name: "mainPage", title: "Reolink NVR", install: true, uninstall: true) { section("NVR Connection") { label title: "App Name", required: false input name: "ipAddress", type: "text", title: "NVR IP Address", required: true input name: "camUsername", type: "text", title: "Username", defaultValue: "admin", required: true input name: "camPassword", type: "password", title: "Password", required: true } section("Channels") { href name: "toDiscoveryPage", page: "discoveryPage", title: "Discover / Manage Channels", description: state.discoveredChannels ? "${state.discoveredChannels.size()} channel(s) found (last scan: ${state.lastDiscovery ?: 'never'})" : "No channels discovered yet — tap to scan" } if (getChildDevices()) { section("Installed Devices") { getChildDevices().sort { it.displayName }.each { c -> paragraph "• ${c.displayName} — ${c.typeName}" } } } section("Polling") { input name: "pollInterval", type: "enum", title: "Motion Poll Interval (applies to all channels)", options: ["2":"2 seconds","5":"5 seconds","10":"10 seconds","15":"15 seconds","30":"30 seconds","60":"1 minute"], defaultValue: "10" } section("Shared Device Settings", hideable: true, hidden: true) { input name: "motionResetTime", type: "number", title: "Motion Reset Delay (seconds)", defaultValue: 30, range: "5..300" input name: "logEnable", type: "bool", title: "Enable Debug Logging (app + all devices)", defaultValue: false input name: "txtLogEnable", type: "bool", title: "Enable Descriptive Text Logging (all devices)", defaultValue: true } section("Maintenance", hideable: true, hidden: true) { input name: "autoManage", type: "bool", title: "Automatically rediscover & sync channels every 3 hours", defaultValue: true input name: "removeUnselected", type: "bool", title: "Remove child devices for channels that go offline or are unchecked", defaultValue: false input name: "refreshAllBtn", type: "button", title: "Refresh All Devices Now" input name: "cleanupBtn", type: "button", title: "Remove Offline Channel Devices" } } } def discoveryPage() { dynamicPage(name: "discoveryPage", title: "Discover Reolink Channels", nextPage: "mainPage") { section { paragraph "Scans the NVR at the IP address entered on the previous page and lists every channel it reports. Check the ones you want as Hubitat devices." input name: "scanBtn", type: "button", title: "Scan NVR Now" } if (state.discoveredChannels) { section("Found Channels (as of ${state.lastDiscovery})") { state.discoveredChannels.each { ch -> String statusTxt = ch.online ? "Online" : "Offline" String suggested = ch.isDoorbell ? "Doorbell" : "Camera" paragraph "${ch.name} — Channel ${ch.humanChannel} — ${statusTxt} — suggested type: ${suggested}" input name: "select_${ch.channel}", type: "bool", title: "Add / keep this device", defaultValue: true input name: "type_${ch.channel}", type: "enum", title: "Device Type", options: ["auto":"Auto-detect", "camera":"IP Camera", "doorbell":"Doorbell"], defaultValue: "auto" } } } else { section { paragraph "No scan results yet. Tap 'Scan NVR Now' above." } } } } // ═════════════════════════════════════════════════════════════════════════ // Button handling // ═════════════════════════════════════════════════════════════════════════ def appButtonHandler(String btn) { switch (btn) { case "scanBtn": discoverChannelsFromNVR() break case "refreshAllBtn": refreshAllChannels() break case "cleanupBtn": cleanupOfflineChannels() break } } // ═════════════════════════════════════════════════════════════════════════ // Lifecycle // ═════════════════════════════════════════════════════════════════════════ def installed() { initialize() } def updated() { unschedule() if (logEnable) runIn(1800, "logsOff") if (state.discoveredChannels) { syncChildDevices() } initialize() } def uninstalled() { getChildDevices().each { deleteChildDevice(it.deviceNetworkId) } } def initialize() { if (autoManage != false) { runEvery3Hours("periodicMaintenance") } runEvery10Minutes("refreshAllChannels") schedulePolling() } def logsOff() { app.updateSetting("logEnable", [value: "false", type: "bool"]) } def periodicMaintenance() { discoverChannelsFromNVR() syncChildDevices() } private void schedulePolling() { int secs = (pollInterval ?: "10").toInteger() runIn(secs, "pollAllChannels") } def pollAllChannels() { try { if (ipAddress && camUsername && camPassword) { ensureToken() getChildDevices().each { cd -> try { componentPollMotion(cd) } catch (e) { log.warn "Reolink NVR App: poll failed for ${cd.displayName} — ${e}" } } } } catch (Exception topE) { log.error "Reolink NVR App: Critical failure inside pollAllChannels execution — ${topE}" } finally { schedulePolling() } } def refreshAllChannels() { if (!ipAddress || !camUsername || !camPassword) return try { ensureToken() def channelList = fetchChannelStatusList() getChildDevices().each { cd -> try { Integer ch = channelOf(cd) if (ch == null) return cd.sendEvent(name: "cameraOnline", value: (state.online == true).toString()) def entry = channelList?.find { it.channel?.toInteger() == ch } if (entry) applyChannelInfo(cd, ch, entry) refreshIrLed(cd, ch) refreshWhiteLed(cd, ch) refreshPushState(cd, ch) } catch (e) { log.warn "Reolink NVR App: refresh failed for ${cd.displayName} — ${e}" } } } catch (Exception topE) { log.error "Reolink NVR App: Global refresh error occurred — ${topE}" } } // ═════════════════════════════════════════════════════════════════════════ // Discovery // ═════════════════════════════════════════════════════════════════════════ private void discoverChannelsFromNVR() { if (!ipAddress) { log.warn "Reolink NVR App: IP address not set, cannot scan." return } def list = fetchChannelStatusList() if (list == null) return def found = [] list.each { entry -> Integer ch = entry.channel?.toInteger() Integer humanChannel = ch + 1 String name = entry.name ?: "Channel ${humanChannel}" Integer online = (entry.online ?: 0) as Integer found << [ channel: ch, humanChannel: humanChannel, name: name, online: (online == 1), isDoorbell: isDoorbellName(name) ] } state.discoveredChannels = found state.lastDiscovery = timestampNow() log.info "Reolink NVR App: discovery found ${found.size()} channel(s)." } private List fetchChannelStatusList() { def response = doAuthedPost("GetChannelStatus", null, [:]) if (!response) { log.warn "Reolink NVR App: no response from NVR for GetChannelStatus." return [] } try { def json = parseJson(response)[0] if (json?.code != 0) { log.warn "Reolink NVR App: NVR returned error for GetChannelStatus — ${json?.error ?: json}" return [] } return json.value?.status ?: [] } catch (e) { log.warn "Reolink NVR App: GetChannelStatus parse error — ${e}" return [] } } private void applyChannelInfo(cd, Integer ch, entry) { String friendly = entry.name ?: "Channel ${ch + 1}" cd.sendEvent(name: "cameraName", value: friendly) cd.sendEvent(name: "cameraChannel", value: (ch + 1).toString()) String newLabel = "${friendly} (Channel ${ch + 1})" if (cd.label != newLabel) cd.setLabel(newLabel) } // ═════════════════════════════════════════════════════════════════════════ // Child device sync // ═════════════════════════════════════════════════════════════════════════ private void syncChildDevices() { if (!state.discoveredChannels) return def keepDnis = [] state.discoveredChannels.each { ch -> boolean selected = (settings["select_${ch.channel}"] != false) if (!selected) return if (!ch.online) return String override = settings["type_${ch.channel}"] ?: "auto" boolean isDoorbell = (override == "doorbell") || (override == "auto" && ch.isDoorbell) String driverName = isDoorbell ? "Reolink Doorbell (Auto-Detect NVR Channel)" : "Reolink IP Camera (RLN8-410)" String dni = "reolink-${app.id}-${ch.channel}" keepDnis << dni def child = getChildDevice(dni) if (!child) { try { child = addChildDevice( "gomce62", driverName, dni, [ label: "${ch.name} (Channel ${ch.humanChannel})", isComponent: false ] ) log.info "Reolink NVR App: created child device '${child.displayName}' (${driverName})." } catch (e) { log.warn "Reolink NVR App: failed to create child for channel ${ch.humanChannel} — ${e}" return } } pushSettingsToChild(child, ch) } if (removeUnselected) { getChildDevices().each { c -> if (!keepDnis.contains(c.deviceNetworkId)) { log.info "Reolink NVR App: removing unselected/offline child '${c.displayName}'." deleteChildDevice(c.deviceNetworkId) } } } } private void pushSettingsToChild(child, chInfo) { child.updateDataValue("channel", chInfo.channel.toString()) child.updateSetting("motionResetTime", [value: (motionResetTime ?: 30), type: "number"]) child.updateSetting("logEnable", [value: (logEnable ?: false), type: "bool"]) child.updateSetting("txtLogEnable", [value: (txtLogEnable != false), type: "bool"]) try { child.updated() } catch (e) { log.warn "Reolink NVR App: updated() failed for ${child.displayName} — ${e}" } } private void cleanupOfflineChannels() { if (!state.discoveredChannels) { discoverChannelsFromNVR() } def onlineChannels = (state.discoveredChannels ?: []).findAll { it.online }.collect { it.channel } getChildDevices().each { child -> Integer ch = channelOf(child) if (ch == null || !onlineChannels.contains(ch)) { log.info "Reolink NVR App: removing offline child '${child.displayName}'." deleteChildDevice(child.deviceNetworkId) } } } private Integer channelOf(cd) { def v = cd.getDataValue("channel") return (v != null) ? v.toInteger() : null } // ═════════════════════════════════════════════════════════════════════════ // Component methods // ═════════════════════════════════════════════════════════════════════════ void componentRefresh(cd) { if (!ipAddress || !camUsername || !camPassword) return try { ensureToken() Integer ch = channelOf(cd) if (ch == null) { log.warn "Reolink NVR App: ${cd.displayName} has no channel assigned" return } cd.sendEvent(name: "cameraOnline", value: (state.online == true).toString()) def list = fetchChannelStatusList() def entry = list?.find { it.channel?.toInteger() == ch } if (entry) applyChannelInfo(cd, ch, entry) refreshIrLed(cd, ch) refreshWhiteLed(cd, ch) refreshPushState(cd, ch) componentPollMotion(cd) } catch (Exception e) { log.error "Reolink NVR App: componentRefresh critical error for ${cd.displayName} — ${e}" } } void componentOn(cd) { setWhiteLed(cd, true) } void componentOff(cd) { setWhiteLed(cd, false) } void componentIrEnable(cd) { setIrLed(cd, "Auto") } void componentIrDisable(cd) { setIrLed(cd, "Off") } void componentPushOn(cd) { setPushState(cd, 1) } void componentPushOff(cd) { setPushState(cd, 0) } void componentPollMotion(cd) { if (!ipAddress || !camUsername || !camPassword) return Integer ch = channelOf(cd) if (ch == null) return boolean anyAi = false boolean isPerson = false boolean isVehicle = false boolean isAnimal = false List activeTypes = [] // === AI Detection === def aiResponse = doAuthedPost("GetAiState", ch, [ channel: ch ]) logDebug "componentPollMotion: ${cd.displayName} (ch=${ch}) AI raw response: ${aiResponse}" if (aiResponse) { try { def result = parseJson(aiResponse)[0] if (result?.code == 0) { def ai = result.value ?: [:] logDebug "AI structure keys for ${cd.displayName}: ${ai.keySet()}" // Comprehensive person/human detection if (ai.people?.alarm_state == 1 || ai.person?.alarm_state == 1 || ai.human?.alarm_state == 1) { isPerson = true activeTypes << "person" } if (ai.vehicle?.alarm_state == 1) { isVehicle = true activeTypes << "vehicle" } if (ai.dog_cat?.alarm_state == 1 || ai.animal?.alarm_state == 1) { isAnimal = true activeTypes << "animal" } if (ai.face?.alarm_state == 1) activeTypes << "face" if (ai.package?.alarm_state == 1) activeTypes << "package" if (activeTypes.size() > 0) { anyAi = true } } } catch (e) { logDebug "componentPollMotion: AI parse error for ${cd.displayName} — ${e}" } } // === Basic Motion Detection === boolean mdActive = false def mdResponse = doAuthedPost("GetMdState", ch, [ channel: ch ]) logDebug "componentPollMotion: ${cd.displayName} (ch=${ch}) MD raw response: ${mdResponse}" if (mdResponse) { try { def result = parseJson(mdResponse)[0] if (result?.code == 0) { mdActive = (result.value.state == 1) } } catch (e) { logDebug "componentPollMotion: MD parse error for ${cd.displayName} — ${e}" } } // === Final Motion Event === if (anyAi || mdActive) { String finalType = "motion" if (anyAi && activeTypes) { def priority = ["person", "vehicle", "animal"] finalType = priority.find { activeTypes.contains(it) } ?: activeTypes[0] } logDebug "${cd.displayName} (ch=${ch}) → Motion ACTIVE | type=${finalType} | AI types=${activeTypes} | isPerson=${isPerson}" cd.parseMotionState([ active: true, type: finalType, aiPerson: isPerson, aiVehicle: isVehicle, aiAnimal: isAnimal ]) scheduleRapidPolls(cd) } } // BUGFIX HISTORY: // (1) runIn() schedules jobs by handler method name. Calling runIn() // repeatedly with the SAME handler name ("rapidPollWrapper") for every // delay does NOT queue five separate timers — each call cancels/replaces // the prior one unless overwrite:false is passed. We initially fixed this // with overwrite:false so all five rapid polls (1s/3s/5s/7s/9s) could // coexist. // (2) That fix exposed a second, more serious bug: componentPollMotion() is // itself called BY each of those rapid-poll jobs, and if motion is still // active when they run (which is normal — a person walking through a // yard takes several seconds, not one), componentPollMotion() calls // scheduleRapidPolls() AGAIN, scheduling 5 more jobs. Every one of those // does the same thing. This is exponential job creation: 5 → 25 → 125... // Within a few seconds this blows through Hubitat's per-app scheduled-job // ceiling (~75), which then throws IllegalStateException on every further // runIn() call, breaks componentPollMotion() mid-execution, and can // prevent motion/lastMotionTime from updating cleanly. // FIX: gate scheduleRapidPolls() with a per-device cooldown (10s) so a new // burst can only be scheduled once every 10 seconds. Sustained motion still // gets rapid polling from the burst already in flight; it just can't spawn a // fresh burst every single poll. private void scheduleRapidPolls(cd) { String dni = cd.deviceNetworkId if (state.rapidBurstStarted == null) state.rapidBurstStarted = [:] Long lastBurst = state.rapidBurstStarted[dni] Long nowMs = now() if (lastBurst != null && (nowMs - lastBurst) < 10000L) { // A burst for this device is already in flight — don't spawn another. return } state.rapidBurstStarted[dni] = nowMs [1, 3, 5, 7, 9].each { delay -> runIn(delay, "rapidPollWrapper", [data: [dni: dni], overwrite: false]) } } def rapidPollWrapper(data) { def cd = getChildDevice(data.dni) if (cd) componentPollMotion(cd) } // ═════════════════════════════════════════════════════════════════════════ // IR / White LED / Push (unchanged) // ═════════════════════════════════════════════════════════════════════════ private void refreshIrLed(cd, Integer ch) { def response = doAuthedPost("GetIrLights", ch, [ channel: ch ]) if (!response) return try { def result = parseJson(response)[0] if (result?.code == 0) { def stateVal = result.value.IrLights.state ?: "Auto" cd.sendEvent(name: "irLed", value: stateVal.toLowerCase()) } } catch (e) {} } private void setIrLed(cd, String stateStr) { Integer ch = channelOf(cd) if (ch == null) return def prev = cd.currentValue("irLed") ?: "auto" cd.sendEvent(name: "irLed", value: stateStr.toLowerCase()) def response = doAuthedPost("SetIrLights", ch, [ IrLights: [ channel: ch, state: stateStr ] ]) if (!response) { cd.sendEvent(name: "irLed", value: prev); return } try { def result = parseJson(response)[0] if (result?.code != 0) cd.sendEvent(name: "irLed", value: prev) } catch (e) { cd.sendEvent(name: "irLed", value: prev) } } private void refreshWhiteLed(cd, Integer ch) { def response = doAuthedPost("GetWhiteLed", ch, [ channel: ch ]) if (!response) return try { def result = parseJson(response)[0] if (result?.code == 0) { def wl = result.value.WhiteLed def s = (wl.state == 1) ? "on" : "off" cd.sendEvent(name: "spotlight", value: s) cd.sendEvent(name: "switch", value: s) } } catch (e) {} } private void setWhiteLed(cd, boolean enable) { Integer ch = channelOf(cd) if (ch == null) return def newState = enable ? "on" : "off" def prev = cd.currentValue("spotlight") ?: "off" cd.sendEvent(name: "spotlight", value: newState) cd.sendEvent(name: "switch", value: newState) def response = doAuthedPost("SetWhiteLed", ch, [ WhiteLed: [ channel: ch, state: enable ? 1 : 0, mode: 1, bright: 100 ] ]) if (!response) { cd.sendEvent(name: "spotlight", value: prev) cd.sendEvent(name: "switch", value: prev) return } try { def result = parseJson(response)[0] if (result?.code != 0) { cd.sendEvent(name: "spotlight", value: prev) cd.sendEvent(name: "switch", value: prev) } } catch (e) { cd.sendEvent(name: "spotlight", value: prev) cd.sendEvent(name: "switch", value: prev) } } private void refreshPushState(cd, Integer ch) { def uri = buildUriGlobal("GetPushV20") + "&channel=${ch}" try { httpGet([uri: uri, timeout: 10, contentType: "text/plain"]) { response -> if (response.status == 200) { def slurper = new JsonSlurper() def json = slurper.parseText(response.data.text) if (json && json[0]?.value?.Push) { def isEnabled = json[0].value.Push.enable String currentState = (isEnabled == 1) ? "on" : "off" cd.sendEvent(name: "pushNotifications", value: currentState) } } } } catch (Exception e) { log.error "Reolink NVR App: error fetching push state for ${cd.displayName} — ${e.message}" } } private void setPushState(cd, int stateValue) { Integer ch = channelOf(cd) if (ch == null) return def prev = cd.currentValue("pushNotifications") ?: "unknown" String targetState = (stateValue == 1) ? "on" : "off" cd.sendEvent(name: "pushNotifications", value: targetState) def response = doAuthedPost("SetPushV20", null, [ Push: [ enable: stateValue, schedule: [ channel: ch ] ] ]) if (!response) { cd.sendEvent(name: "pushNotifications", value: prev); return } try { def result = parseJson(response)[0] if (result?.code != 0) { log.warn "Reolink NVR App: NVR rejected push update for ${cd.displayName} — ${result?.error ?: result}" cd.sendEvent(name: "pushNotifications", value: prev) } else { log.info "Reolink NVR App: push notifications set to [${targetState.toUpperCase()}] for ${cd.displayName}" } } catch (e) { cd.sendEvent(name: "pushNotifications", value: prev) } } // ═════════════════════════════════════════════════════════════════════════ // Doorbell name detection // ═════════════════════════════════════════════════════════════════════════ private List doorbellPatterns() { return [ "doorbell", "front door", "frontdoor", "front-door", "bell", "door", "db1", "db1a", "rlc-db1", "rlc-db1a" ] } private boolean isDoorbellName(String nm) { def lower = (nm ?: "").toLowerCase() return doorbellPatterns().any { p -> lower.contains(p) } } // ═════════════════════════════════════════════════════════════════════════ // Authentication (unchanged) // ═════════════════════════════════════════════════════════════════════════ private void ensureToken() { if (!state.token || now() > (state.tokenExpiry - 60000L)) login() } private boolean login() { def body = toJson([ [ cmd: "Login", action: 0, param: [ User: [ userName: camUsername, password: camPassword ] ] ] ]) def response = doHttpPost(buildUriWithCreds("Login"), body) if (!response) { setOnline(false); return false } try { def result = parseJson(response)[0] if (result?.code == 0) { state.token = result.value.Token.name state.tokenExpiry = now() + ((result.value.Token.leaseTime ?: 3600) * 1000L) setOnline(true) return true } } catch (e) { log.error "Reolink NVR App: login error — ${e}" } setOnline(false) return false } private void setOnline(boolean isOnline) { if (state.online == isOnline) return state.online = isOnline getChildDevices().each { it.sendEvent(name: "cameraOnline", value: isOnline.toString()) } } private boolean isSessionError(String responseText) { if (!responseText) return false try { def parsed = parseJson(responseText) def entry = (parsed instanceof List) ? parsed[0] : parsed return (entry?.error?.rspCode == -6) } catch (e) { return false } } private String doAuthedPost(String cmd, Integer ch, Map paramMap) { ensureToken() String uri = (ch != null) ? buildUri(cmd, ch) : buildUriGlobal(cmd) String body = toJson([[ cmd: cmd, action: 0, param: paramMap ]]) String response = doHttpPost(uri, body) if (isSessionError(response)) { logDebug "doAuthedPost: session rejected for ${cmd}, forcing re-login and retrying once." state.token = null if (login()) { uri = (ch != null) ? buildUri(cmd, ch) : buildUriGlobal(cmd) response = doHttpPost(uri, body) } } return response } // ═════════════════════════════════════════════════════════════════════════ // HTTP Helpers (unchanged) // ═════════════════════════════════════════════════════════════════════════ private String buildUri(String cmd, Integer ch) { def tok = state?.token ?: "" return "http://${ipAddress}/cgi-bin/api.cgi?cmd=${cmd}&channel=${ch}&token=${tok}" } private String buildUriWithCreds(String cmd) { return "http://${ipAddress}/cgi-bin/api.cgi?cmd=${cmd}" } private String buildUriGlobal(String cmd) { def tok = state?.token ?: "" return "http://${ipAddress}/cgi-bin/api.cgi?cmd=${cmd}&token=${tok}" } private String doHttpPost(String uri, String bodyStr) { def result = null try { logDebug "HTTP POST → ${uri}" httpPost([ uri: uri, contentType: "application/json", requestContentType: "application/json", body: bodyStr, timeout: 10 ]) { resp -> if (resp.status == 200) { if (resp.data instanceof Map || resp.data instanceof List) { result = toJson(resp.data) } else { result = resp.data.toString() } } } } catch (e) { log.warn "Reolink NVR App: HTTP error — ${e}" } return result } private def parseJson(String text) { if (!text) return null return new JsonSlurper().parseText(text) } private String toJson(obj) { return JsonOutput.toJson(obj) } private String timestampNow() { try { return new Date().format("MMM dd, yyyy h:mm:ss a", location.timeZone) } catch (e) { return new Date().format("MMM dd, yyyy h:mm:ss a") } } private void logDebug(msg) { if (logEnable) log.debug "Reolink NVR App: ${msg}" }