/**
* Reolink Integration (Parent App)
* Version: 1.4.2
*
* Architecture: a "source" is anything answering the Reolink HTTP/JSON API
* (standalone camera, PoE NVR, or Home Hub), each with its own IP + creds. A
* multi-channel source (NVR/Hub) reports channels 0..N-1; a standalone camera
* is a degenerate source with one channel: 0. Every child device is tagged
* (sourceId, channel) and talks only through parent.componentX() -- never
* HTTP directly. Poll interval is per-child (wired 2-5s tight, battery loose)
* to avoid hammering sleeping devices. Each source is fronted by a "Reolink
* Device Bridge" device (one per source) that holds the persistent real-time
* event subscription and is the real parent of Camera/Doorbell, so they nest
* under it in the Devices list. Event-driven updates are the standard path;
* a source falls back to polling automatically on connection loss and
* silently resumes event mode on reconnect.
*
* Device-specific findings/limitations/setup gotchas live in the README and
* in-app Tips page, not duplicated here. TODO markers mark spots needing
* exact command/param names verified against firmware (field names can
* drift by version). Full history prior to 1.3.6 is in GitHub commit history.
*
* BREAKING CHANGE (v1.3.8): every camera/doorbell became a child of a new
* per-source "Reolink Device Bridge" instead of a child of this app directly
* -- existing installs had to delete/recreate devices (and repoint
* dashboards/rules) via re-discovery under each source.
*
* v1.4.2 -- HOTFIX: Camera/Doorbell driver preferences used bare
* paragraph("text") calls, which is App-DSL-only and doesn't compile on a
* driver -- blocked the 1.4.1 update entirely with "No signature of method:
* Script1.paragraph()". Fixed in both driver files via input(type:
* "paragraph"); no app-side code change.
*
* v1.4.1 -- one real-hardware testing session, four fixes:
* 1. schedulerTick()'s battery-check gate required batteryMode == "battery"
* before ever checking -- a device with batteryMode ever left unset
* (exact real-world trigger not reproducible from code alone) silently
* failed this gate forever, due-time still advancing every tick with
* nothing logged and battery never updating short of a manual check.
* Now treats null as "unknown, go find out": backfills via a live
* GetBatteryInfo probe once, self-heals on the next tick after
* upgrading.
* 2. Since the old gate kept advancing the stale due-time even while
* skipping the check, that stale schedule would otherwise delay fix #1
* up to a full batteryCheckIntervalHours after upgrading. A one-time
* runMigrations() (guarded by state.lastKnownAppVersion) clears it so
* the fix takes effect within a second of updating.
* 3. A standalone source's bridge parents off "Reolink Standalone Devices"
* (not this app directly), so its parent?.logNormal/logFull() calls
* threw MissingMethodException on that driver -- meaning the socket
* connection never even started for ANY standalone source (found via a
* standalone Argus 4 Pro). Fixed by adding logNormal()/logFull()
* passthroughs to StandaloneDevices.groovy. Hub/NVR bridges (parented
* directly off the app) were never affected.
* 4. Added chargingStatus attribute (charging/not_charging/unknown) to
* both drivers from GetBatteryInfo's Battery.chargeStatus -- both
* values (1/0) confirmed against real hardware plugged in vs.
* unplugged, corroborated by current's sign flip and adapterStatus.
* 5. maybeCheckBatteryOnWake(): a real event push means a battery device
* is already awake for an unrelated reason, so opportunistically
* checking battery/charging then costs nothing extra vs. waiting for
* the next scheduled interval (up to 12h) or a manual check. OFF by
* default (checkBatteryOnEventWake), throttled
* (eventWakeBatteryThrottleSec, default 60s) so a burst of pushes
* triggers one check per wake.
* 6. The scheduled battery check is now gated by an explicit
* batteryCheckEnabled toggle (OFF by default) instead of the old
* implicit "0 hours = disabled" convention.
* 7. Tips page updated: Argus 4 Pro has NO local network API standalone
* (both HTTP CGI and the Baichuan event port refuse the connection,
* confirmed directly); a Doorbell 2K Gen 2 pairs/polls fine standalone
* but its sleep/battery behavior makes event delivery unreliable.
* Different failure modes, same conclusion: battery-class devices need
* a Home Hub or NVR.
*
* v1.3.9 -- real-world use behind a 23-channel NVR:
* 1. The discovery-time battery probe (guessIsBattery()) was marking a
* whole source "unreachable" on the EXPECTED failure of a wired camera
* (~half of all cameras) -- doReolinkApiCall() now takes a `quiet` flag
* so a probe failure logs at Full tier only, no source-health impact.
* 2. Check Battery could succeed but the attribute never updated --
* receiveBatteryInfo() was reading batteryPercent flat instead of
* nested under Battery.batteryPercent (the confirmed reolink_aio
* field). Now checks nested first.
* 3. Bridge keepalive (25s) now logs a Full-tier heartbeat so a quiet-but-
* healthy event connection doesn't look identical to a silently stuck
* one, throttled to once per connection.
* 4. Reverted Login's "action: 0" field (added 1.3.8, never confirmed for
* Login specifically) after a real rspCode:-7 login rejection --
* disproven as the cause (recurred without the field too) but reverted
* anyway since unconfirmed; every other command's action:0 is
* unaffected and separately confirmed.
* 5. ensureSourceBridge() could throw a raw DuplicateDNIException and
* crash the whole app page if an orphaned device shared its bridge's
* DNI -- now caught, logs the DNI to search/delete, returns null
* gracefully.
* 6. Added explicit uninstalled() walking removeSource() for every source
* instead of relying solely on Hubitat's automatic cascade-delete --
* likely root cause of the orphaned-bridge DNI collision in #5, since
* the 1.3.8 bridge restructuring made the device tree 2-3 levels deep.
*
* v1.3.8:
* 1. Real-time event-driven updates -- persistent per-source connection,
* falls back to polling automatically on drop/failure, resumes event
* mode silently on reconnect. Per-source toggle, defaults on.
* 2. PIR enable/disable for cameras (pirOn/pirOff, pirEnabled attribute),
* doorbells unaffected.
* 3. Fixed a login bug where "new token acquired" logged even without a
* usable Token.name (masking real auth failures) -- success now only
* logs on a real token; failure logs the raw response.
* 4. Fixed Login missing the "action" field every other command sends.
* 5. Magic-header resync logging stays at debug tier (confirmed benign,
* self-recovering Hub-side noise via soak testing); still warns after
* 20 failed resync attempts or a genuinely unrecognized message type.
* 6. Bridge device previously logged directly via log.info/log.debug,
* bypassing the app's Log level entirely -- now routed through
* logNormal()/logFull() like the rest of the app.
* 7. Standalone (non-Hub) cameras/doorbells now nest under a shared
* "Reolink Standalone Devices" entry instead of each bridge appearing
* separately -- matching how NVR/Hub channels already group. Each
* standalone camera still holds its own independent event connection
* (rawSocket is one-connection-per-driver-instance); only the nesting
* changed.
*
* v1.3.6 -- discoverPage() fixes: unchecking an existing single-channel
* device to remove it never actually fired (only checking-on did) -- now
* fires either direction. Multi-channel apply-toggle relabeled for clarity;
* each row now says Existing/New Device. Danger-zone wording clarified.
* Hub/NVR channel type detection now uses the channel's own name (API
* returns no model field), fixing a mislabeled doorbell. Added a note about
* removing devices from this page, not Hubitat's Devices page.
*/
import groovy.transform.Field
definition(
name: "Reolink Integration",
namespace: "jdthomas24",
author: "Jason",
description: "Discovers and manages Reolink cameras, doorbells, NVRs, and Home Hubs",
category: "Convenience",
menu: "Integrations", // groups this app under the "Integrations" section of Add User App
iconUrl: "",
iconX2Url: "",
singleThreaded: true,
oauth: true // required for createAccessToken()/local endpoint access used by the snapshot relay
)
@Field static final String APP_VERSION = "1.4.2"
@Field static final List LOG_LEVELS = ["Errors Only", "Normal", "Full"]
// Poll interval is a device-level setting ONLY -- these are just the one-time
// default applied to a newly created device, not user-configurable at the app
// level. To change an existing device's interval, use its own device page (or
// the Set Poll Interval / Set Snapshot Interval commands).
@Field static final Integer DEFAULT_WIRED_POLL_SEC = 3
@Field static final Integer DEFAULT_BATTERY_POLL_SEC = 30
// The real-time event-subscription protocol (Baichuan) always runs on port
// 9000, completely separate from each source's own configurable HTTPS API
// port (src.port, default 443, used for GetAiState/GetChannelstatus/etc.).
// Always use this constant for the event socket, never src.port.
@Field static final Integer BAICHUAN_PORT = 9000
preferences {
page(name: "mainPage")
page(name: "addSourcePage")
page(name: "discoverPage")
page(name: "tipsPage")
}
// Local (non-cloud) endpoint the dashboard image tile hits on every refresh.
// See componentTakeSnapshot() / handleSnapshotRequest() below.
mappings {
path("/snap/:dni") {
action: [GET: "handleSnapshotRequest"]
}
}
def mainPage() {
if (newLabel && newHost && newUser && newPass) {
addSource()
// FIXED (2026-08-17): newPort and newIsHub were never cleared here,
// unlike the other four fields -- so a value typed for one source
// (even a typo, e.g. "440" instead of "443") silently persisted
// and got reused for every SUBSEQUENT "Add a source" too, since
// Hubitat only shows an input's defaultValue when the setting has
// never been set at all. Real-world impact: a single mistyped port
// early in a rapid add/remove testing session caused every later
// source to silently connect to the wrong port and fail outright,
// with no indication anything carried over. Every field this page
// collects now resets cleanly after each add.
app.removeSetting("newLabel")
app.removeSetting("newHost")
app.removeSetting("newPort")
app.removeSetting("newUser")
app.removeSetting("newPass")
app.removeSetting("newIsHub")
}
dynamicPage(name: "mainPage", install: true, uninstall: true) {
section {
paragraph pillHeader("Sources")
paragraph "A source is one camera, one NVR, or one Home Hub -- anything with its own IP/login."
(state.sources ?: []).each { src ->
href name: "src_${src.id}", title: "${src.label} (${src.host})",
description: "${src.isHub ? 'Hub/NVR' : 'Standalone'} · ${childrenForSource(src.id).size()} device(s)",
page: "discoverPage", params: [sourceId: src.id]
}
}
section {
href name: "addSource", title: "➕ Add a source...",
description: "Standalone camera, NVR, or Home Hub", page: "addSourcePage"
}
section {
paragraph pillHeader("Logging")
input "logLevel", "enum", title: "Log level", options: LOG_LEVELS,
defaultValue: "Errors Only", submitOnChange: true
paragraph logLevelPill("Errors Only") + " Default. Warnings and errors only."
paragraph logLevelPill("Normal") + " Errors, plus meaningful one-time events and changes " +
"(logins, asleep/awake, devices created, config changes)."
paragraph logLevelPill("Full") + " Everything, including every routine poll step. " +
"Automatically reverts to Normal after 60 minutes."
}
section {
href name: "tips", title: "Tips, limitations & what works so far", page: "tipsPage",
description: "Troubleshooting, known device quirks, and confirmed capabilities"
}
section("Help & Support") {
paragraph rawHtml: true, """
"""
}
section {
paragraph "" +
"Reolink Integration v${APP_VERSION}
"
}
}
}
private String pillHeader(String text) {
"${text.toUpperCase()}
"
}
/**
* Small colored pill for a log level name, distinct from pillHeader's section-title style so
* the two don't get visually confused. Color signals severity/verbosity at a glance: red for
* the errors-only default, grey for the middle tier, dark blue for the noisiest/temporary one.
*/
private String logLevelPill(String level) {
def colors = [
"Errors Only": [bg: "#FFEBEE", fg: "#C62828"],
"Normal": [bg: "#ECEFF1", fg: "#455A64"],
"Full": [bg: "#E8EAF6", fg: "#283593"]
]
def c = colors[level] ?: [bg: "#ECEFF1", fg: "#455A64"]
"${level}"
}
def tipsPage() {
dynamicPage(name: "tipsPage", title: "Tips & Notes") {
section {
paragraph pillHeader("What a source is")
paragraph "A source is one camera, one NVR, or one Home Hub -- anything with its own IP/login."
paragraph "A standalone camera always has one channel: 0."
paragraph "An NVR/Home Hub has one channel per paired camera. Run discovery to see what it finds."
}
section {
paragraph pillHeader("Before adding any camera")
paragraph "Check the camera's own Network > Advanced (or Server) settings and make sure HTTP, " +
"HTTPS, and ONVIF are enabled."
paragraph "These are often off by default on every model tested so far -- not just Reolink's E1 " +
"line. This is the single most common reason a source fails to connect, and it's worth " +
"checking before assuming a device needs a Hub/NVR or isn't supported."
}
section {
paragraph pillHeader("Why a device's ID number looks out of order")
paragraph "Each device's internal ID (visible in Hubitat's device list as part of its DNI, e.g. " +
"'reolink-4-0') is just a counter that only ever goes up. It never reuses a number, even after " +
"you delete a source and its device."
paragraph "So gaps in the numbering (4, 5, 8, 9 instead of 1, 2, 3, 4) just mean some source got " +
"removed and re-added at some point along the way -- normal, and nothing to fix. It doesn't " +
"affect how anything works."
}
section {
paragraph pillHeader("Devices that won't work standalone")
paragraph "⚠️ Battery-class cameras/doorbells (Argus line, Doorbell Battery, Gen 2 " +
"doorbells) -- as a rule, treat these as requiring a Home Hub or NVR. Add the Hub/NVR as the " +
"source instead, and the device shows up as one of its channels -- confirmed working well " +
"across a real multi-device battery fleet as of v1.4.1."
paragraph "It does NOT depend on how the device is powered. Even a battery-class device running " +
"continuously on a DC adapter (not just trickle-charging) is affected, because this is a " +
"firmware/network-stack limitation, not a charging-mode setting. \"Wired Power Mode\" in the " +
"Reolink app only changes charging behavior, never the network API."
paragraph "The exact failure mode varies by model, confirmed via two real, different units tested " +
"standalone (no Hub):"
paragraph " • Argus 4 Pro -- no local network API at ALL. Confirmed directly " +
"against real hardware: both the HTTP CGI API (port 443, what discovery/every poll uses) AND " +
"the separate Baichuan event-subscription port (9000, what real-time push events use) " +
"actively refuse the connection. There is nothing on this unit's own IP for this app -- or " +
"any local tool -- to talk to standalone, full stop."
paragraph " • Doorbell 2K Gen 2 -- DOES have enough of a local API to pair and " +
"poll standalone (confirmed: shows up in the integration, GetDevInfo/HTTP works). The problem " +
"here is different: the device's own sleep/battery behavior makes real-time event delivery " +
"unreliable, so motion/AI state goes stale rather than never connecting at all."
paragraph "Bottom line either way: a battery-class device behind a Home Hub or NVR works " +
"reliably (the Hub does 100% of the actual network talking on the camera's behalf, so the " +
"camera's own local API story stops mattering entirely). Standalone is not supported for this " +
"device class, for one reason or the other depending on the specific model -- don't spend time " +
"chasing a standalone connection for any battery device before checking this section."
paragraph "⚠️ E1, E1 Pro, and Lumus -- Reolink's own docs on whether these support local " +
"HTTP/HTTPS are inconsistent, and don't fully agree with each other model to model."
paragraph "Don't rely on the model name to decide. Check the camera's own Network > Advanced " +
"(or Server) settings for HTTP/HTTPS/ONVIF toggles -- that tells you directly whether this " +
"specific unit can do it, regardless of what any doc claims for the line in general. That's " +
"exactly how a real E1 Pro here turned out to support HTTP fine, just off by default."
paragraph "Everything else -- PoE cameras, WiFi cameras outside the E1 line -- works standalone."
}
section {
paragraph pillHeader("Poll interval")
paragraph "Wired devices can be polled tight: a few seconds is fine."
paragraph "Battery devices should be polled loose -- they only wake for their own events or an " +
"occasional self check-in, at most once an hour or so."
paragraph "Polling a battery device harder doesn't get fresher data -- it just drains the battery " +
"for no benefit."
paragraph "This still holds once a battery device is behind a Hub: you're asking the Hub for its " +
"last-known state, not the device directly. The device's own check-in cadence is still the " +
"real limit."
paragraph "When a source's event connection is active, its children are updated in real time and " +
"polling is skipped entirely for as long as that connection stays healthy -- polling only " +
"resumes automatically if the event connection drops."
}
section {
paragraph pillHeader("Sleep status")
paragraph "Awake -- the last poll actually got a response."
paragraph "Asleep -- the last poll got no response at all."
paragraph "For a battery device, asleep is normal, not an error -- it just hasn't checked in since " +
"its last event or self-wake."
paragraph "⚠️ For a wired/PoE device, asleep is NOT normal -- it means a poll genuinely got " +
"no response, which points to a real connectivity or load issue worth investigating."
paragraph "Motion/person/vehicle/etc. keep their last-known value when this happens, rather than " +
"resetting to inactive."
}
section {
paragraph pillHeader("Known older-firmware bug: false 'asleep' from garbled responses")
paragraph "⚠️ Some E1-series cameras on ~2021-era firmware (e.g. build 21120806, v3.0.0.748) have a " +
"known bug where the camera's web server intermittently returns corrupted/garbled data instead " +
"of a real response -- not encryption, not a real connectivity problem, just bad data back from " +
"the camera itself. This app can't tell that apart from a genuinely unreachable device, so it " +
"gets reported as asleep even though the camera is actually online and responding."
paragraph "How to tell: if a wired/PoE device keeps flipping to asleep with no real pattern, and Full " +
"logging shows parse errors on GetAiState/GetMdState rather than plain timeouts, this is likely " +
"it rather than an actual network issue."
paragraph "Confirmed via a 2021-firmware E1 Outdoor -- newer firmware (e.g. 2024-era, v3.1.0.3429) on " +
"the same camera line does not show this problem."
paragraph "Two things worth trying, in order: (1) In the Reolink app, turn off HTTP/HTTPS under this " +
"camera's Network settings, reboot the camera, then turn HTTP/HTTPS back on -- this reinitializes " +
"the camera's web server and can clear it up without a firmware change. (2) If that doesn't help, " +
"check for a firmware update for this exact camera/hardware version via the Reolink desktop app's " +
"Download Center, or contact Reolink support directly with your model and firmware version. When " +
"updating, avoid any 'reset configuration' option unless you actually want to reset the camera."
}
section {
paragraph pillHeader("PTZ")
paragraph "Reolink has no 'Home' command. The real equivalent is a saved preset."
paragraph "Use savePresetHere once (commonly preset ID 1) to save wherever the camera is " +
"currently pointed."
paragraph "Use ptzGoToPreset with that same ID any time you want it to return there."
}
section {
paragraph pillHeader("PTZ calibration")
paragraph "⚠️ Only applies to PTZ-capable cameras (e.g. Trackmix, E1 Zoom). Non-PTZ cameras " +
"will just return an error if you try it -- harmless, but there's nothing to calibrate."
paragraph "Use calibratePtz if preset recall starts drifting off target over time. Check " +
"progress with checkPtzCalibrationStatus -- Required means it hasn't been calibrated, " +
"Running means it's in progress (takes a few seconds), Done means it's ready."
}
section {
paragraph pillHeader("PIR (motion trigger) on/off -- cameras only")
paragraph "Use pirOn/pirOff to enable or disable a camera's PIR motion trigger " +
"without removing the device. This does NOT stop an in-progress recording -- it removes the " +
"trigger that would have woken a battery camera to record in the first place. If anything else " +
"on that camera is separately configured for continuous/scheduled recording outside PIR " +
"triggering, that recording is unaffected."
paragraph "Manual on/off only -- there's no auto-revert timer. For something like \"turn PIR off " +
"below battery threshold X and back on above threshold Y,\" build that with Rule Machine using " +
"the existing battery attribute; no extra plumbing is needed here."
}
section {
paragraph pillHeader("Snapshot tiles on dashboards")
paragraph "Snapshot URLs point at a local relay endpoint on this app, not directly at the camera. " +
"The camera itself is only ever contacted on its own snapshot interval (device preference, " +
"separate from poll interval) -- the relay endpoint just serves whatever image was cached " +
"from that last fetch."
paragraph "That means a dashboard tile can refresh as often as you like, but the picture it shows " +
"only actually changes as often as that device's snapshot interval. A dashboard tile's own " +
"refresh setting has no effect on how often the image itself changes."
paragraph "Snapshot interval is intentionally kept separate from poll interval. Poll interval " +
"controls motion/AI state and should generally stay tight for responsive automations. " +
"Snapshot interval controls image freshness only, and can stay looser (default 30s) without " +
"affecting motion responsiveness at all."
paragraph "If a camera's tile feels slow to update, lower that device's snapshot interval (device " +
"page, or the setSnapshotInterval command) -- not the poll interval, and not the dashboard " +
"tile's own refresh setting."
}
section {
paragraph pillHeader("Log levels")
paragraph logLevelPill("Errors Only") + " Default. Warnings and errors only."
paragraph logLevelPill("Normal") + " Errors, plus meaningful one-time events and changes: " +
"a fresh login, a device flipping asleep/awake, a device created, a config change. Routine " +
"polls that succeed with no change don't log anything."
paragraph logLevelPill("Full") + " Everything, including every routine poll step. " +
"Automatically reverts to Normal after 60 minutes so it doesn't stay noisy indefinitely."
paragraph "⚠️ It's normal for Errors Only and Normal to show nothing at all for " +
"long stretches -- that means nothing worth flagging has happened, not that the app has " +
"stopped working. If you want to confirm it's actually running, switch to Full " +
"temporarily and you'll see continuous poll activity."
}
section {
paragraph pillHeader("Supported Features (new in v1.3.0)")
paragraph "Every device now has a read-only supportedFeatures attribute, populated " +
"automatically at discovery time from the camera's own reported capabilities (Reolink's " +
"GetAbility API) -- e.g. \"PTZ, Spotlight, Night Vision, Person Detection, Vehicle Detection, " +
"Pet Detection\" for a full-featured PTZ camera with a light, or \"Night Vision, Status LED, " +
"Person Detection, Vehicle Detection, Package Detection\" for a doorbell with IR night vision " +
"and a button light but no true spotlight."
paragraph "⚠️ This is informational only -- it does NOT hide or disable any commands. Hubitat " +
"has no way to remove a command from an individual device instance, so every command still " +
"appears on every device regardless of what supportedFeatures says. Trying a command the " +
"device doesn't actually support (e.g. PTZ on a fixed camera) will just harmlessly error, " +
"same as before -- check supportedFeatures first to know what's actually worth trying."
paragraph "Use the Check Abilities command any time to refresh this -- useful after a " +
"firmware update that might add a capability, or for a device created before this feature " +
"existed."
paragraph "Package detection is doorbell-specific (cameras don't have it) and its exact field " +
"name is still being confirmed against real doorbell hardware -- it may not always show up " +
"correctly on every doorbell yet. Battery-status detection isn't part of this feature yet " +
"either (still relies on the existing wired/battery detection at discovery time, unrelated " +
"to supportedFeatures)."
}
section {
paragraph pillHeader("Confidence level on newer commands")
paragraph "Confirmed working against real hardware: PtzCtrl -- move, and ToPos (preset recall)."
paragraph "Built but not yet tested against this setup's actual firmware: SetPtzPreset " +
"(save), SetWhiteLed (spotlight), SetIrLights (night vision), AudioAlarmPlay (siren), " +
"GetBatteryInfo (battery %), PtzCheck/GetPtzCheckState (calibration), SetPirInfo (PIR on/off)."
paragraph "These are built from consistent patterns across several independent Reolink API " +
"references. If one doesn't work as expected, check Logs with the log level set to Full -- " +
"the exact response usually points to which field name needs adjusting for this device."
}
}
}
def addSourcePage(params) {
if (params?.cancel) {
app.removeSetting("newLabel")
app.removeSetting("newHost")
app.removeSetting("newPort")
app.removeSetting("newUser")
app.removeSetting("newPass")
app.removeSetting("newIsHub")
return mainPage()
}
dynamicPage(name: "addSourcePage", title: "Add a Reolink Source", nextPage: "mainPage") {
section {
href name: "cancelAddSource", title: "Cancel", description: "Back to Sources without saving",
page: "addSourcePage", params: [cancel: true]
}
section {
paragraph "NOTE" +
"Before adding: check the camera's own Network > Advanced (or Server) settings and make " +
"sure HTTP, HTTPS, and ONVIF are enabled. These are often off by default on every model " +
"tested so far, not just Reolink's E1 line -- this is the single most common reason a source " +
"fails to connect."
paragraph "Battery-class cameras/doorbells and Reolink's E1 line have additional connection quirks " +
"worth knowing about -- see the Tips page (link on the Sources list) if this one still refuses " +
"to connect after enabling the ports above."
input "newLabel", "text", title: "Label (e.g. 'Front Door Hub', 'Garage Cam')"
input "newHost", "text", title: "IP address"
input "newPort", "number", title: "HTTPS port", defaultValue: 443
input "newUser", "text", title: "Username"
input "newPass", "password", title: "Password"
input "newIsHub", "bool", title: "This is an NVR or Home Hub (multiple channels)", defaultValue: false
paragraph "Fill in Label, IP address, Username, and Password, then tap Next to save. " +
"Leaving any of those blank just returns you to the Sources list without creating anything."
paragraph "ℹ️ After tapping Next, especially for an " +
"NVR/Home Hub with several channels, it can take up to 30 seconds or so before the discover " +
"page finishes loading -- it's checking each channel individually. This is normal, not stuck."
}
}
}
def discoverPage(params) {
def sourceId = params?.sourceId ?: state.currentDiscoverySourceId
state.currentDiscoverySourceId = sourceId
def src = getSource(sourceId)
// v1.3.9 FIX: this previously called ensureSourceBridge() unconditionally
// on EVERY page load, including the very first time this page is opened
// before any channel has ever been toggled on -- meaning just VIEWING
// the discover page created a real device and attempted a live socket
// connection, before the user had expressed any intent to add anything.
// Every read on this page (existing/new pill status, single-channel
// auto-apply check, connection status display) already handles a
// missing bridge safely via getSourceBridge()'s null-safe lookup, so
// nothing here actually needs the bridge to exist yet. It's created
// lazily now, at the moment real intent exists -- createSelectedChildren()
// already calls ensureSourceBridge() itself, right before creating the
// first camera/doorbell, which is the natural point for it to exist.
// This also meaningfully reduces exposure to orphaned-device risk: fewer
// needless bridge creations means less surface area for something to go
// wrong during a botched removal/reinstall (see uninstalled() above for
// the actual guarantee against that, which this doesn't replace).
// Auto-run discovery the first time this source's Discover page is opened
// (no cached results yet for this source), in addition to an explicit
// "Re-run discovery" click. Removes the old requirement to manually run
// discovery once before anything showed up after adding a source.
def alreadyCachedForThisSource = (state.lastDiscoverySourceId == sourceId)
if (src && (params?.run || !alreadyCachedForThisSource)) {
state.lastDiscovery = discoverChannels(sourceId)
state.lastDiscoverySourceId = sourceId
}
def cachedForThisSource = (state.lastDiscoverySourceId == sourceId)
def lastDiscovery = cachedForThisSource ? (state.lastDiscovery ?: []) : []
def channelCount = lastDiscovery.size()
if (confirmCreate) {
createSelectedChildren(sourceId)
app.updateSetting("confirmCreate", [type: "bool", value: false])
}
// v1.3.6 FIX: this used to only fire when the checkbox was TRUE, which
// meant unchecking an EXISTING device to remove it never triggered
// anything. Now fires on EITHER direction: checking an absent device
// (create) or unchecking a present one (remove), by comparing the
// checkbox state against whether the device currently exists.
if (channelCount == 1 && src) {
def ch = lastDiscovery[0]
def dni = childDni(sourceId, ch.channel)
def bridge0 = getSourceBridge(sourceId)
def existing = bridge0?.getChildDevice(dni) != null
def wantIt = settings["create_${sourceId}_${ch.channel}"]
if ((wantIt ?: false) != existing) {
createSelectedChildren(sourceId)
}
}
if (confirmRemoveSource && src) {
removeSource(sourceId)
app.updateSetting("confirmRemoveSource", [type: "bool", value: false])
return mainPage()
}
return dynamicPage(name: "discoverPage", title: "Discover Channels - ${src?.label ?: '(source removed)'}", nextPage: "mainPage") {
if (!src) {
section {
paragraph "This source has been removed. Go back and use Add a source... if this was a mistake."
}
} else {
section {
paragraph pillHeader("Event Connection")
def connStatus = state.sourceConnMode?.get(sourceId.toString()) ?: "not started"
def statusColor = connStatus == "connected" ? "#22c55e" : connStatus == "reconnecting" ? "#f97316" : "#94a3b8"
input "useEventSubscription_${sourceId}", "bool",
title: "Use event-driven updates for this source (falls back to polling automatically if it can't connect)",
defaultValue: true, submitOnChange: true
paragraph "Status: ${connStatus}"
}
section {
// v1.3.6: explicit warning added after a real-world case where a
// device deleted from Hubitat's Devices page (instead of this
// page) left the app's own checkbox state stale.
paragraph "HEADS UP" +
"Remove devices from THIS page, not Hubitat's Devices page. Deleting there can " +
"bring a device back on its own or leave it stuck toggled-on."
href name: "runDiscovery", title: "Re-run discovery",
description: "Discovery already ran automatically when this page opened. Use this to " +
"refresh the channel list, e.g. after pairing a new camera to an NVR/Home Hub.",
page: "discoverPage", params: [sourceId: sourceId, run: true]
paragraph "ℹ️ Discovery can take up to 30 " +
"seconds or so on a brand-new source, especially one with several channels -- it's " +
"checking each channel individually. This is normal, not stuck; already-added channels " +
"re-discover much faster on future runs."
if (state.lastDiscoveryError) {
paragraph "⚠️ ${state.lastDiscoveryError}"
}
lastDiscovery.each { ch ->
def dni = childDni(sourceId, ch.channel)
def bridgeForList = getSourceBridge(sourceId)
def exists = bridgeForList?.getChildDevice(dni) != null
def doorbellTag = ch.deviceType == "doorbell" ? " (Doorbell)" : ""
input "create_${sourceId}_${ch.channel}", "bool",
title: "Ch ${ch.channel}: ${ch.name}${doorbellTag}",
defaultValue: exists, submitOnChange: true
if (exists) {
paragraph "EXISTING DEVICE" +
"Toggle off + apply to remove it
"
} else {
paragraph "NEW DEVICE" +
"Toggle on + apply to create it
"
}
paragraph ""
}
if (channelCount > 1) {
paragraph rawHtml: true, """
Apply changes
Creates every device toggled on above and removes every device toggled off. Toggling a device by itself does not apply anything until you toggle this.
"""
input "confirmCreate", "bool", title: "Apply changes now",
defaultValue: false, submitOnChange: true
} else if (channelCount == 1) {
paragraph "Standalone source, one channel -- toggling it applies immediately (toggled on " +
"creates it, toggled off removes it), no separate apply step needed."
}
}
section {
paragraph pillHeader("Danger zone")
input "confirmRemoveSource", "bool",
title: "Remove this ENTIRE source and ALL ${childrenForSource(sourceId as Integer).size()} of its device(s) -- unrelated to the toggles above",
defaultValue: false, submitOnChange: true
}
}
}
}
// ---------- Source management ----------
def addSource() {
state.sources = state.sources ?: []
state.nextSourceId = (state.nextSourceId ?: 0) + 1
def id = state.nextSourceId
state.sources << [
id: id, label: newLabel, host: newHost, port: newPort ?: 443,
username: newUser, password: newPass, isHub: newIsHub ?: false,
token: null, tokenExpires: 0
]
logNormal "Added source ${id}: ${newLabel} (${newHost})"
}
def getSource(id) {
(state.sources ?: []).find { it.id == (id as Integer) }
}
def childrenForSource(sourceId) {
def bridge = getSourceBridge(sourceId)
// A DEVICE's getChildDevices() (unlike an app's) can return null instead
// of an empty list when it has zero children -- guard against that.
return bridge ? (bridge.getChildDevices() ?: []) : []
}
private String bridgeDni(sourceId) {
"reolink-bridge-${sourceId}"
}
@Field static final String STANDALONE_GROUP_DNI = "reolink-standalone-group"
/**
* Looks up (but does not create) the shared "Reolink Standalone Devices"
* group device -- the nesting-only parent that every standalone (non-Hub)
* source's bridge lives under, so multiple standalone cameras/doorbells
* group together in the Devices list instead of each bridge appearing as
* its own separate unnested entry. Hub/NVR sources never use this; their
* bridge always parents directly off the app. Returns null if no
* standalone source has been added yet.
*/
private getStandaloneGroupDevice() {
getChildDevice(STANDALONE_GROUP_DNI)
}
/** Looks up OR creates the shared standalone-devices group device -- lazily created the first time a standalone source needs a bridge. */
private ensureStandaloneGroupDevice() {
def group = getStandaloneGroupDevice()
if (!group) {
group = addChildDevice("jdthomas24", "Reolink Standalone Devices", STANDALONE_GROUP_DNI, [
name: "Reolink Standalone Devices",
label: "Reolink Standalone Devices",
isComponent: true
])
logNormal "Reolink Integration: standalone-devices group device created"
}
return group
}
/**
* Looks up an existing source's bridge device. A Hub/NVR source's bridge is
* app-owned directly; a standalone source's bridge instead lives under the
* shared standalone-devices group device (see ensureStandaloneGroupDevice()
* above) -- this checks both locations so every other call site can look up
* a bridge without needing to know the source type. Returns null if not yet
* created.
*/
private getSourceBridge(sourceId) {
def dni = bridgeDni(sourceId)
getChildDevice(dni) ?: getStandaloneGroupDevice()?.getChildDevice(dni)
}
/**
* Camera/Doorbell DNIs are "reolink-{sourceId}-{channel}" -- given one, finds
* the bridge that owns it without needing sourceId passed separately. Used
* anywhere only a dni string is available (e.g. runIn(...) callback data).
*/
private getSourceBridgeForChannelDni(String dni) {
def parts = dni?.tokenize("-")
if (!parts || parts.size() < 2) return null
def sourceId = parts[1] as Integer
return getSourceBridge(sourceId)
}
def removeSource(id) {
def src = getSource(id as Integer)
def bridge = getSourceBridge(id as Integer)
if (bridge) {
try { bridge.stopEventSubscription() } catch (e) { /* best effort */ }
// Deleting the bridge cascades to delete its own children
// (Camera/Doorbell) -- standard Hubitat parent/child device
// behavior, same as deleting any multi-endpoint parent removes its
// child endpoints too.
bridge.getChildDevices()?.each { forgetSchedulingState(it.deviceNetworkId) }
// Delete via whichever device actually owns this bridge -- a
// Hub/NVR bridge is the app's own direct child, a standalone
// bridge is the group device's child.
if (src?.isHub) {
deleteChildDevice(bridge.deviceNetworkId)
} else {
getStandaloneGroupDevice()?.removeBridgeDevice(bridge.deviceNetworkId)
}
}
state.sources.removeAll { it.id == (id as Integer) }
state.sourceUnreachable?.remove(id.toString())
state.sourceConnMode?.remove(id.toString())
logNormal "Removed source ${id}"
}
/** Drops a device's entries from the central scheduler's due-time maps once it's deleted, so state doesn't accumulate dead DNIs forever. */
private forgetSchedulingState(String dni) {
state.nextPollDue?.remove(dni)
state.nextSnapshotDue?.remove(dni)
state.nextBatteryCheckDue?.remove(dni)
state.lastEventBatteryCheck?.remove(dni)
}
// ---------- Auth ----------
private String reolinkLogin(sourceId) {
def src = getSource(sourceId)
if (src.token && now() < src.tokenExpires) {
logFull "Reolink source ${sourceId}: reusing cached token, expires in ${(src.tokenExpires - now()) / 1000}s"
return src.token
}
logFull "Reolink source ${sourceId}: cached token missing/expired, logging in fresh"
// v1.3.9 REVERT (2026-08-17): the "action: 0" field added here in 1.3.8
// was NEVER actually confirmed against real hardware for Login
// specifically -- it was added purely by inference/symmetry with every
// OTHER command, which all genuinely do send action:0 and have since
// been independently confirmed working across 5+ real devices. Login
// never got that same confirmation. Real-world reports on 2026-08-17
// (multiple sources, confirmed-correct credentials, a hard rspCode:-7
// "login failed" rejection -- not a timeout, not a parsing gap, an
// explicit reject) match exactly what you'd expect if some Reolink
// firmware is stricter about an unexpected field on Login than this app
// assumed. Reverted to the pre-1.3.8 body (no action field) as the
// prime regression suspect -- every OTHER command keeps action:0
// unchanged, since those are separately confirmed and unrelated.
def body = [[cmd: "Login", param: [User: [userName: src.username, password: src.password]]]]
def resp = reolinkRawPost(src, body)
if (resp == null) {
// reolinkRawPost() already logged (or suppressed, if this source is
// already known-unreachable) the underlying connection failure --
// nothing more to log here, and nothing to parse out of a response
// that never arrived.
return null
}
def first = firstResultValue(resp, src)
def token = first?.Token?.name
def leaseSec = (first?.Token?.leaseTime ?: 3600) as Integer
src.token = token
src.tokenExpires = now() + (leaseSec * 1000L) - 30000L
// v1.3.8 FIX: this success log previously fired unconditionally, as soon
// as the response parsed at all -- so a Login response that came back
// WITHOUT a usable Token.name (bad credentials, or an unexpected shape
// on some firmware) still logged "new token acquired" every time,
// masking the real failure and making every following "no token
// available" abort look inexplicable. Now only logs success when a real
// token came back; otherwise logs the raw response so the actual field
// shape/error is visible.
if (token) {
logNormal "Reolink source ${sourceId}: new token acquired, leaseTime=${leaseSec}s"
markSourceReachable(sourceId)
} else {
log.warn "Reolink source ${sourceId}: Login response parsed but no Token.name found (check " +
"credentials) -- raw: ${resp?.toString()?.take(500)}"
}
return token
}
private firstResultValue(resp, src) {
try {
return resp[0]?.value
} catch (e) {
log.warn "Reolink unexpected response shape from ${src.host} (source ${src.id}): " +
"${e.message} -- raw: ${resp?.toString()?.take(300)}"
return null
}
}
private reolinkRawPost(src, bodyList) {
def cmd = bodyList?.getAt(0)?.cmd ?: ""
def uri = "https://${src.host}:${src.port}/cgi-bin/api.cgi?cmd=${cmd}"
def params = [uri: uri, ignoreSSLIssues: true, requestContentType: "application/json",
body: groovy.json.JsonOutput.toJson(bodyList), timeout: 10]
def result = null
try {
httpPost(params) { resp -> result = parseReolinkResponse(resp) }
} catch (e) {
markSourceUnreachable(src.id, "POST failed (${src.host}): ${e.message}")
}
return result
}
private parseReolinkResponse(resp) {
def raw = resp?.data?.toString()
return raw ? new groovy.json.JsonSlurper().parseText(raw) : null
}
/**
* A source going unreachable (host down, network issue, etc.) is ONE
* condition, not a fresh event every poll cycle -- these two helpers gate
* on a per-source state flag so only the TRANSITION into/out of unreachable
* gets logged. Full-tier logging still shows every individual attempt via
* the existing logFull() calls elsewhere, for anyone actively
* troubleshooting.
*/
private void markSourceUnreachable(sourceId, String reason) {
def map = state.sourceUnreachable ?: [:]
def key = sourceId.toString()
if (map[key] != true) {
log.warn "Reolink source ${sourceId}: ${reason} -- further identical warnings for this source are " +
"suppressed until it recovers (switch to Full logging to see every attempt)"
map[key] = true
state.sourceUnreachable = map
} else {
logFull "Reolink source ${sourceId}: still unreachable -- ${reason}"
}
}
private void markSourceReachable(sourceId) {
def map = state.sourceUnreachable ?: [:]
def key = sourceId.toString()
if (map[key] == true) {
log.info "Reolink source ${sourceId}: connection restored"
map[key] = false
state.sourceUnreachable = map
}
}
def reolinkApiCall(sourceId, String cmd, Map param = [:], Integer channel = null) {
def src = getSource(sourceId)
def token = reolinkLogin(sourceId)
if (!token) {
// Login already logged (or suppressed) the actual connection failure
// above -- this is just the downstream consequence, not a new fact,
// so it only needs Full-tier visibility, not its own warning.
logFull "Reolink source ${sourceId}: no token available, aborting ${cmd}"
return null
}
def outcome = doReolinkApiCall(src, sourceId, cmd, token, param, channel)
// rspCode -6 ("please login first") means the camera invalidated our session
// before our local tokenExpires said it should -- most likely a competing
// client (Reolink app/NVR viewing this camera) forced a fresh login on the
// camera side. Don't wait for the next poll cycle to notice; force our own
// fresh login and retry once now.
if (outcome.value == null && outcome.rspCode == -6) {
logNormal "Reolink source ${sourceId}: token rejected by camera (please login first), forcing re-login"
src.token = null
src.tokenExpires = 0
def freshToken = reolinkLogin(sourceId)
if (freshToken) {
outcome = doReolinkApiCall(src, sourceId, cmd, freshToken, param, channel)
}
} else if (outcome.value == null && outcome.parseFailure) {
// Known bug on some older firmware (e.g. 2021-era E1 -- see Tips page):
// the camera's web server intermittently returns corrupted/garbled data
// instead of a real response, NOT an auth problem, so a fresh LOGIN
// wouldn't help -- confirmed via real-world testing that an immediate
// retry of the SAME call often succeeds right after a failed one.
logNormal "Reolink source ${sourceId}: ${cmd} (ch ${channel}) returned unparseable data (known older-firmware " +
"bug, see Tips page), retrying once immediately"
outcome = doReolinkApiCall(src, sourceId, cmd, token, param, channel)
}
return outcome.value
}
/**
* quiet=true suppresses the usual failure escalation (markSourceUnreachable
* warn, or the JSON-parse-failure warn) and logs at Full tier instead. Used
* by guessIsBattery() below -- see that method's comment for why a failed
* GetBatteryInfo probe must NOT be treated as evidence the whole SOURCE is
* unreachable.
*/
/**
* timeoutSec lets a caller shorten the HTTP timeout below the normal 10s --
* used by guessIsBattery() below, since a slow rejection and a fast one
* mean the same thing for that specific probe (see that method's comment
* for the full reasoning).
*/
private Map doReolinkApiCall(src, sourceId, String cmd, String token, Map param, Integer channel, boolean quiet = false, int timeoutSec = 10) {
def p = channel != null ? param + [channel: channel] : param
def uri = "https://${src.host}:${src.port}/cgi-bin/api.cgi?cmd=${cmd}&token=${token}"
def body = [[cmd: cmd, action: 0, param: p]]
def result = null
try {
httpPost([uri: uri, ignoreSSLIssues: true, requestContentType: "application/json",
body: groovy.json.JsonOutput.toJson(body), timeout: timeoutSec]) { resp -> result = parseReolinkResponse(resp) }
def value = firstResultValue(result, src)
def rspCode = result?.getAt(0)?.error?.rspCode
if (value == null) {
logFull "Reolink source ${sourceId}: ${cmd} (ch ${channel}) HTTP ok but no usable value -- raw: ${result?.toString()?.take(300)}"
} else {
logFull "Reolink source ${sourceId}: ${cmd} (ch ${channel}) succeeded"
}
markSourceReachable(sourceId)
return [value: value, rspCode: rspCode, parseFailure: false]
} catch (groovy.json.JsonException e) {
if (quiet) {
logFull "Reolink source ${sourceId} ch ${channel}: ${cmd} returned unparseable data (quiet probe) -- ${e.message}"
} else {
log.warn "Reolink cmd ${cmd} failed for source ${sourceId} ch ${channel}: ${e.message}"
}
return [value: null, rspCode: null, parseFailure: true]
} catch (e) {
if (quiet) {
logFull "Reolink source ${sourceId} ch ${channel}: ${cmd} failed (quiet probe, not treated as source-unreachable) -- ${e.message}"
} else {
markSourceUnreachable(sourceId, "cmd ${cmd} (ch ${channel}) failed: ${e.message}")
}
return [value: null, rspCode: null, parseFailure: false]
}
}
// ---------- Discovery ----------
def discoverChannels(sourceId) {
def src = getSource(sourceId)
def channels = []
state.lastDiscoveryError = null
def bridgeForDiscovery = getSourceBridge(sourceId)
// One GetAbility call per source covers ALL channels at once (the response
// includes an abilityChn[] array indexed by channel) -- confirmed against
// real hardware, so this is NOT called again per-channel below.
def abilityChnList = fetchAbilityChnList(sourceId)
if (!src.isHub) {
def info = reolinkApiCall(sourceId, "GetDevInfo")
if (info == null) {
state.lastDiscoveryError = "No response from ${src.host}. If this is a battery-class " +
"camera or doorbell (not PoE/plug-in WiFi), it may not run a local HTTP/ONVIF " +
"server at all -- those typically only become reachable once paired to a Home Hub or NVR."
return channels
}
// Skip the GetBatteryInfo round-trip entirely for a channel that
// already has a child device -- isBattery is ONLY ever read at
// device CREATION time, so recomputing it on every discovery run for
// an existing channel is a wasted HTTP round-trip.
def existing0 = bridgeForDiscovery?.getChildDevice(childDni(sourceId, 0)) != null
channels << [channel: 0, name: info?.DevInfo?.name ?: src.label, deviceType: guessDeviceType(info),
isBattery: existing0 ? null : guessIsBattery(sourceId, 0),
supportedFeatures: computeSupportedFeatures(abilityChnList?.getAt(0))]
} else {
def status = reolinkApiCall(sourceId, "GetChannelstatus")
if (status == null) {
state.lastDiscoveryError = "No response from ${src.host}. Check IP/credentials."
return channels
}
status?.status?.each { ch ->
if (ch.online) {
def existing = bridgeForDiscovery?.getChildDevice(childDni(sourceId, ch.channel)) != null
channels << [channel: ch.channel, name: ch.name ?: "Channel ${ch.channel}", deviceType: guessChannelDeviceType(ch),
isBattery: existing ? null : guessIsBattery(sourceId, ch.channel),
supportedFeatures: computeSupportedFeatures(abilityChnList?.getAt(ch.channel as Integer))]
}
}
}
return channels
}
/**
* Standalone-source detection (GetDevInfo shape, has a real model field).
* Confirmed reliable against every standalone camera/doorbell tested so far.
*/
private String guessDeviceType(info) {
def model = (info?.DevInfo?.model ?: info?.model ?: "").toLowerCase()
return model.contains("doorbell") ? "doorbell" : "camera"
}
/**
* Hub/NVR-channel detection. GetChannelstatus (the Hub/NVR API) never
* returns a model field, only GetDevInfo (the standalone API) does -- so
* this uses the channel's own name instead. Reolink's own Hub/NVR channel
* naming already reflects the device type (a paired doorbell channel is
* named "Doorbell" by default). Falls back to "camera" if the name gives no
* signal either way.
*/
private String guessChannelDeviceType(ch) {
def name = (ch?.name ?: "").toLowerCase()
return name.contains("doorbell") ? "doorbell" : "camera"
}
/**
* Battery vs wired isn't reported directly by GetDevInfo/GetChannelstatus, so
* this uses GetBatteryInfo as a signal instead: a battery-class device
* answers it with real data, a wired/PoE device returns nothing usable --
* and on some wired firmware, "nothing usable" is an outright timeout
* rather than a clean unsupported-command response.
*
* FIXED (2026-08-17): a real PoE camera timing out on this specific probe
* was marking its whole SOURCE unreachable (markSourceUnreachable() is a
* per-source flag, not per-command), which then immediately flipped back to
* "connection restored" on the very next unrelated successful call -- noisy
* and misleading, since every other command for that source was working
* fine the whole time. This probe is EXPECTED to fail for roughly half of
* all cameras (any wired one) -- that's not a source-health signal, it's
* routine. Calls doReolinkApiCall() directly with quiet=true instead of
* going through the public reolinkApiCall() wrapper, so a failure here logs
* at Full tier only and never touches source-reachable state.
*
* FIXED (2026-08-17, same day): also passes a short 3s timeout instead of
* the normal 10s. discoverChannels() calls this once per NEW channel,
* sequentially, synchronously, within a single page render -- on a large
* Hub/NVR's FIRST-EVER discovery (every channel is "new" at once), a wired
* channel timing out here is the expected, common case, not rare. At 10s
* each, a 24-channel Hub with many wired cameras could block for minutes
* inside one page load, plausibly exceeding Hubitat's own execution-time
* limit and crashing the whole page ("Unexpected Error") -- confirmed
* plausible against real logs showing frequent "Read timed out" on this
* exact probe for this exact Hub. A slow rejection and a fast one mean the
* same thing here (not battery), so shortening the timeout loses no real
* information while cutting worst-case blocking time roughly 3x.
*/
private Boolean guessIsBattery(sourceId, channel) {
def src = getSource(sourceId)
def token = reolinkLogin(sourceId)
if (!token) return false
def outcome = doReolinkApiCall(src, sourceId, "GetBatteryInfo", token, [:], channel, true, 3)
return outcome.value != null
}
/**
* Fetches GetAbility for a source and returns the abilityChn[] array (one
* entry per channel, index-aligned with the channel number). Returns null on
* failure or an unexpected response shape -- callers must handle that by
* falling back to an empty/unknown feature set, not by failing discovery
* entirely, since capability detection is informational and should never
* block a device from being creatable.
*/
private List fetchAbilityChnList(sourceId) {
def src = getSource(sourceId)
def result = reolinkApiCall(sourceId, "GetAbility", [User: [userName: src?.username]])
def abilityChn = result?.Ability?.abilityChn
if (!(abilityChn instanceof List)) {
logFull "Reolink source ${sourceId}: GetAbility did not return the expected " +
"Ability.abilityChn[] shape -- capability detection unavailable for this source"
return null
}
return abilityChn
}
/**
* Safe lookup: treats a missing key the SAME as unsupported. Checks BOTH the
* "permit" and "ver" sub-fields, not permit alone -- these move together on
* every field tested except ptzType, where permit stays 0 even on confirmed
* PTZ cameras while ver correctly shows nonzero. PTZ presence is keyed off
* ptzType specifically BECAUSE of this behavior (see computeSupportedFeatures()
* below) -- checking both here is what makes ptzType usable at all as a PTZ
* signal, and closes off the same risk for any other field.
*/
private int abilityPermit(Map abilityChn, String key) {
def entry = abilityChn?.getAt(key)
def permit = (entry?.permit ?: 0) as int
def ver = (entry?.ver ?: 0) as int
return Math.max(permit, ver)
}
/**
* Maps GetAbility data to a human-readable feature list for the
* supportedFeatures device attribute. Confirmed against real hardware across
* 8+ cameras / 6+ models / firmware 2021-2024, cross-checked against
* Reolink's own officially-backed reolink_aio library:
* - PTZ: ptzType > 0 (checked via abilityPermit()'s existing max(permit,
* ver) logic). CORRECTED 2026-08-14: previously keyed off ptzCtrl > 0,
* which turned out to report whether ANY PTZ-style command channel
* exists (apparently including basic digital zoom on some fixed
* cameras), not actual pan-tilt hardware -- confirmed via a real
* RLC-1240A (no physical PTZ) showing ptzCtrl permit:7, ver:64, both
* nonzero, a false positive. ptzType correctly read permit:0, ver:0 on
* that same camera. The E1 Pro's documented ptzType values (permit:0,
* ver nonzero) are exactly the case abilityPermit()'s check-both logic
* was built for, so switching to ptzType keeps the E1 Pro correctly
* detected while fixing the RLC-1240A false positive.
* - PTZ Calibration: supportPtzCheck > 0 OR supportPtzCalibration > 0.
* - Spotlight: supportFLswitch > 0 OR floodLight > 0 (camera-only).
* - Night Vision (IR): ledControl > 0.
* - Status LED: supportDoorbellLight > 0 (doorbell button/ring light, NOT
* a spotlight).
* - Siren: alarmAudio > 0.
* - Person / Vehicle: supportAiPeople / supportAiVehicle > 0.
* - Pet: supportAiDogCat OR supportAiAnimal > 0.
* - Package: supportAiPackage > 0 (doorbell-specific).
* - Basic/older models can be missing entire families of keys --
* abilityPermit()'s missing-key-as-0 handling covers this correctly.
*/
private List computeSupportedFeatures(Map abilityChn) {
if (abilityChn == null) return []
def features = []
if (abilityPermit(abilityChn, "ptzType") > 0) features << "PTZ"
if (abilityPermit(abilityChn, "supportPtzCheck") > 0 || abilityPermit(abilityChn, "supportPtzCalibration") > 0) {
features << "PTZ Calibration"
}
if (abilityPermit(abilityChn, "supportFLswitch") > 0 || abilityPermit(abilityChn, "floodLight") > 0) {
features << "Spotlight"
}
if (abilityPermit(abilityChn, "ledControl") > 0) features << "Night Vision"
if (abilityPermit(abilityChn, "supportDoorbellLight") > 0) features << "Status LED"
if (abilityPermit(abilityChn, "alarmAudio") > 0) features << "Siren"
if (abilityPermit(abilityChn, "supportAiPeople") > 0) features << "Person Detection"
if (abilityPermit(abilityChn, "supportAiVehicle") > 0) features << "Vehicle Detection"
if (abilityPermit(abilityChn, "supportAiDogCat") > 0 || abilityPermit(abilityChn, "supportAiAnimal") > 0) {
features << "Pet Detection"
}
def packageKey = abilityChn.keySet().find { it.toLowerCase().contains("ackage") }
if (packageKey && abilityPermit(abilityChn, packageKey) > 0) features << "Package Detection"
return features
}
// ---------- Child creation ----------
private String childDni(sourceId, channel) {
"reolink-${sourceId}-${channel}"
}
// ---------- Event connection ----------
/**
* The bridge device ALWAYS needs to exist (it's the real parent of Camera/
* Doorbell, not just an optional event-mode extra) -- this always creates
* the bridge if missing, then separately starts/stops the event
* subscription ON that bridge based on the per-source toggle. Idempotent --
* safe to call on every page load or initialize().
*
* A Hub/NVR source's bridge is created as a direct child of the app, same
* as always. A standalone source's bridge is instead created as a child of
* the shared "Reolink Standalone Devices" group device (lazily created on
* first use) -- since each standalone camera still needs its own
* independent event connection (Hubitat's rawSocket interface is one-
* connection-per-driver-instance, so that part can't be shared), but
* nesting them all under one shared parent avoids N separate unnested
* bridges cluttering the Devices list the way they would otherwise.
*/
def ensureSourceBridge(sourceId) {
def src = getSource(sourceId)
if (!src) return null
def dni = bridgeDni(sourceId)
def bridge = getSourceBridge(sourceId)
if (!bridge) {
def label = "Reolink Device Bridge (${src.label})"
try {
if (src.isHub) {
bridge = addChildDevice("jdthomas24", "Reolink Device Bridge", dni, [
name: label, label: label, isComponent: true
])
bridge.updateDataValue("sourceId", "${sourceId}")
} else {
def group = ensureStandaloneGroupDevice()
bridge = group.createBridgeDevice(dni, label, sourceId as Integer)
}
logNormal "Reolink source ${sourceId}: bridge device created"
} catch (com.hubitat.device.exception.DuplicateDNIException e) {
// FIXED (2026-08-17): Hubitat enforces device network IDs as
// GLOBALLY unique across the ENTIRE hub, not just unique among
// one parent's children -- but getSourceBridge() above only
// checks the two places a bridge is supposed to live (direct
// app child, or under the standalone group device). If a device
// with this exact DNI exists ANYWHERE else on the hub (most
// likely an orphan left behind by a partial removal, a stale
// HPM-vs-manual driver mismatch, or an interrupted
// reinstall/wipe), that lookup finds nothing, concludes no
// bridge exists, tries to create one, and Hubitat rejects it --
// which previously crashed the ENTIRE page render with a bare
// "Unexpected Error," giving no indication what actually went
// wrong or how to fix it. Now fails loudly but safely instead:
// logs a clear, actionable warning and returns null so the
// caller can handle a missing bridge gracefully rather than the
// whole page throwing.
log.warn "Reolink source ${sourceId}: a device with DNI '${dni}' already exists somewhere on " +
"this hub but isn't reachable as this source's bridge -- likely an orphaned device from an " +
"earlier partial removal or reinstall. Search your full Devices list for Device Network Id " +
"'${dni}' and delete it, then re-run discovery for this source. (${e.message})"
return null
}
}
// Unconditional -- always keeps the bridge's connection config in sync
// with state.sources, independent of whether the subscription is
// actually wanted right now.
bridge.configureConnection(src.host, BAICHUAN_PORT, src.username, src.password, sourceId as Integer)
def wantEvent = settings["useEventSubscription_${sourceId}"] != false // default true
def currentStatus = state.sourceConnMode?.get(sourceId.toString())
def currentlyRunning = currentStatus in ["connected", "connecting", "reconnecting"]
if (wantEvent && !currentlyRunning) {
bridge.startEventSubscription()
logNormal "Reolink source ${sourceId}: event subscription starting"
} else if (!wantEvent && currentlyRunning) {
try { bridge.stopEventSubscription() } catch (e) { /* best effort */ }
state.sourceConnMode?.remove(sourceId.toString())
logNormal "Reolink source ${sourceId}: event subscription stopped (polling only)"
}
return bridge
}
/** Called by the bridge whenever its event-subscription status changes. */
def componentEventConnectionStatus(child, sourceId, String status) {
def map = state.sourceConnMode ?: [:]
def key = sourceId.toString()
def prev = map[key]
map[key] = status
state.sourceConnMode = map
if (prev != status) {
logNormal "Reolink source ${sourceId}: event connection ${status}"
}
if (status != "connected") {
// Falling back to polling -- mark this source's children due
// immediately instead of waiting out whatever interval they were on,
// so there's no extra gap on top of the drop itself.
childrenForSource(sourceId as Integer).each { markPollDueNow(it.deviceNetworkId) }
}
}
/** True while a source's event connection is confirmed healthy -- schedulerTick() skips active polling for its children while this holds. */
private boolean isSourceEventConnected(sourceId) {
return state.sourceConnMode?.get(sourceId.toString()) == "connected"
}
/**
* Called by the bridge for every genuine per-channel motion/AI/visitor
* change. Routes into the SAME parseReolinkState() the polling path already
* calls -- the camera/doorbell drivers have no idea this came from a push
* instead of a poll. The target child is looked up ON THE BRIDGE (its real
* parent), not the app.
*/
def componentEventChannelUpdate(child, sourceId, channelId, String status, String aiType) {
def bridge = getSourceBridge(sourceId)
def dni = childDni(sourceId, channelId)
def target = bridge?.getChildDevice(dni)
if (!target) return // channel not added as a device, or not yet discovered -- nothing to update
def shapes = translateToLegacyShape(status, aiType)
target.parseReolinkState(shapes.aiState, shapes.mdState, "event")
logFull "Reolink source ${sourceId} ch ${channelId}: event push -- status='${status}', AItype='${aiType}'"
maybeCheckBatteryOnWake(target)
}
/**
* NEW (2026-08-19): a battery-mode device only ever answers GetBatteryInfo
* (or anything else) when it's genuinely awake -- that's the whole reason
* batteryCheckIntervalHours exists on a long, conservative interval, so the
* periodic scheduler doesn't waste battery forcing a wake just to ask.
* But a REAL event push (this method's caller) means the device is ALREADY
* awake and already talking to us right now, for a completely unrelated
* reason -- piggybacking a battery/charging check onto that costs
* essentially nothing extra, unlike the scheduler's own artificial checks.
* Not doing this was a real gap: chargingStatus (see CameraDriver.groovy)
* could only ever update on the next scheduled check (up to
* batteryCheckIntervalHours away, default 12h) or a manual Check Battery
* run, even though the device may have been awake and reachable dozens of
* times in between via real motion/AI events.
*
* OFF by default (checkBatteryOnEventWake device preference) -- even though
* the marginal cost of piggybacking is low, it's still a behavior change
* from what every existing installation has been running, and opt-in
* respects that rather than silently changing what happens on every event
* push for everyone. Throttle window is also configurable per device
* (eventWakeBatteryThrottleSec, default 60s) rather than hardcoded, so it
* can be tuned looser or tighter than the default guess.
*
* Throttled (state.lastEventBatteryCheck, keyed by DNI) so a rapid burst of
* pushes -- e.g. motion, then person, then vehicle, then motion-inactive,
* all within a few seconds, as seen in real logs -- triggers one check for
* that wake, not one per push. Wired/non-battery devices are skipped
* entirely (GetBatteryInfo is meaningless for them). Also nudges
* nextBatteryCheckDue forward by the device's own interval from now, same
* as a real scheduled check would, so schedulerTick() doesn't immediately
* re-check the same device again on its very next tick.
*/
private void maybeCheckBatteryOnWake(child) {
if (!child.hasCapability("Battery")) return
if (child.currentValue("batteryMode") != "battery") return
if (child.getSetting("checkBatteryOnEventWake") != true) return
def dni = child.deviceNetworkId
def nowMs = now()
def throttleSec = (child.getSetting("eventWakeBatteryThrottleSec") ?: 60) as Integer
def lastCheck = state.lastEventBatteryCheck ?: [:]
def last = (lastCheck[dni] ?: 0) as Long
if (nowMs - last < (Math.max(throttleSec, 1) * 1000L)) return
lastCheck[dni] = nowMs
state.lastEventBatteryCheck = lastCheck
componentCheckBattery(child)
def hours = (child.getSetting("batteryCheckIntervalHours") ?: 12) as Integer
def battDue = state.nextBatteryCheckDue ?: [:]
battDue[dni] = nowMs + (Math.max(hours, 1) * 3600L * 1000L)
state.nextBatteryCheckDue = battDue
logFull "Reolink Integration: ${child.displayName} (${dni}) checked battery/charging status opportunistically on a real event wake"
}
/** Called by the bridge for sleep-status pushes (cmd_id=145). Logged only for now -- not yet wired to markAsleep()/awake. */
def componentEventSleepUpdate(child, sourceId, channelId, String sleepState) {
logFull "Reolink source ${sourceId} ch ${channelId}: event sleep push -- '${sleepState}' (not yet acted on)"
}
/**
* Reshapes a pushed status/AItype pair into the same Map shape
* parseReolinkState() already expects from POLLING (GetAiState/GetMdState
* JSON).
*/
private Map translateToLegacyShape(String status, String aiType) {
def aiActive = aiType && aiType != "none"
def motionActive = (status == "MD") || aiActive
return [
mdState: [state: motionActive ? 1 : 0],
aiState: [
people: [alarm_state: (aiType == "people") ? 1 : 0],
vehicle: [alarm_state: (aiType == "vehicle") ? 1 : 0],
dog_cat: [alarm_state: (aiType == "dog_cat") ? 1 : 0],
package: [alarm_state: (aiType == "package") ? 1 : 0],
visitor: [alarm_state: (status == "visitor") ? 1 : 0]
]
]
}
def createSelectedChildren(sourceId) {
// The bridge -- not the app -- creates/removes Camera/Doorbell, via
// createChannelDevice()/removeChannelDevice(), so they end up as ITS
// children (nested in the Devices list).
def bridge = ensureSourceBridge(sourceId)
if (!bridge) {
log.warn "Reolink source ${sourceId}: no bridge device available, cannot create/remove children"
return
}
(state.lastDiscovery ?: []).each { ch ->
def wantIt = settings["create_${sourceId}_${ch.channel}"]
def dni = childDni(sourceId, ch.channel)
def existing = bridge.getChildDevice(dni)
if (wantIt && !existing) {
def driverName = ch.deviceType == "doorbell" ? "Reolink Doorbell" : "Reolink Camera"
def pollDefault = ch.isBattery ? DEFAULT_BATTERY_POLL_SEC : DEFAULT_WIRED_POLL_SEC
def child = bridge.createChannelDevice(driverName, dni, ch.name, pollDefault as Integer, ch.supportedFeatures ?: [])
// v1.3.9: batteryMode was declared as a device attribute but
// never actually populated anywhere -- the device had no way to
// know or expose whether it's battery-powered. This is the one
// moment ch.isBattery holds a real, freshly-probed value (it's
// null on a re-discovery of an already-existing channel, by
// design -- see discoverChannels()), so it's set here, once, at
// creation time. Set on both device types -- both now also get
// the periodic auto-check (see schedulerTick(), gated on
// hasCapability("Battery"), which both drivers declare).
// v1.4.1: even with this in place, a real device (created after
// 1.3.9 shipped, this exact call path confirmed reachable since
// its supportedFeatures WAS populated correctly) still ended up
// with batteryMode never set -- exact trigger not reproducible
// from code alone. schedulerTick() now self-heals that case
// going forward (see its v1.4.1 comment below) rather than
// relying solely on this single creation-time call succeeding.
if (child) {
child.receiveBatteryMode(ch.isBattery ? "battery" : "wired")
}
logNormal "Created child ${dni} (${driverName}) via bridge, poll interval defaulted to ${pollDefault}s (${ch.isBattery ? 'battery' : 'wired'}), features: ${ch.supportedFeatures ? ch.supportedFeatures.join(', ') : 'none detected'}"
} else if (!wantIt && existing) {
bridge.removeChannelDevice(dni)
forgetSchedulingState(dni)
logNormal "Removed child ${dni} via bridge (unchecked in discovery list)"
}
}
initializePolling()
}
// ---------- Polling ----------
def installed() { initialize() }
def updated() { initialize() }
/**
* v1.3.9 NEW: explicit teardown on full app removal. Without this,
* removing the entire app instance (via Hubitat's Apps list, NOT the
* in-app "Remove this ENTIRE source" toggle) relied purely on Hubitat's
* own built-in cascade-delete of app-owned children -- platform behavior
* this app doesn't control or fully verify, especially now that the
* v1.3.8 bridge restructuring made the device tree 2-3 levels deep for the
* first time (App -> Bridge -> Camera, or for standalone: App -> Group
* Device -> Bridge -> Camera) instead of the flat one-level tree this app
* had before. A genuine production DuplicateDNIException was traced to an
* orphaned bridge device surviving what should have been a full removal --
* unclear whether that's a platform edge case with multi-level cascade
* delete under heavy/rapid churn, or something else, but this closes the
* gap either way: every source now goes through the SAME explicit,
* already-defensive removeSource() teardown (stop subscription, delete
* children, delete bridge) that the per-source Danger Zone toggle already
* uses and has been reliable, rather than trusting an implicit mechanism
* this app can't inspect or guarantee.
*/
def uninstalled() {
// FIXED (2026-08-17, same day as introduced): removeSource() mutates
// state.sources internally (state.sources.removeAll {...}) -- iterating
// that SAME live list here while it's being mutated mid-loop is exactly
// what ConcurrentModificationException guards against. .collect() snapshots
// the list once up front, so removeSource()'s mutation of the real
// state.sources no longer affects the iteration in progress.
(state.sources ?: []).collect().each { src ->
try {
removeSource(src.id)
} catch (e) {
log.warn "Reolink Integration: cleanup failed for source ${src.id} during uninstall -- ${e.message}"
}
}
}
/**
* Ensures polling resumes automatically after a hub reboot. Hubitat does not
* guarantee runIn schedules survive a restart on their own, and nothing else
* in this app gets called on boot -- without this, a hub reboot could leave
* every camera silently un-polled until someone happened to open the app and
* hit Done/Update, with no error or indication anything was wrong.
*/
def systemStartHandler(evt) {
logNormal "Reolink Integration: hub restarted, resuming polling"
initialize()
}
def initialize() {
unschedule()
unsubscribe()
subscribe(location, "systemStart", "systemStartHandler")
if (!state.accessToken) {
try {
createAccessToken()
logNormal "Access token created for local snapshot relay endpoint"
} catch (e) {
log.warn "Reolink Integration: could not create access token (needed for dashboard snapshot tiles) -- ${e.message}. " +
"If this persists, check that OAuth is enabled for this app under Apps Code."
}
}
runMigrations()
initializePolling()
if (logLevel == "Full") {
runIn(3600, "revertToNormalLogging")
}
}
/**
* One-time upgrade migration (since v1.4.1), guarded by
* state.lastKnownAppVersion so it runs once per version transition, not on
* every Done/Update save. The old broken battery-check gate kept advancing
* nextBatteryCheckDue a full interval every tick even while silently
* skipping the check, so that stale schedule would otherwise delay
* schedulerTick()'s batteryMode backfill fix by up to a full
* batteryCheckIntervalHours after upgrading. This clears nextBatteryCheckDue
* for every device with no batteryMode set, so the backfill runs on the very
* next tick (~1s) instead. Devices with a valid batteryMode are untouched.
*/
private void runMigrations() {
if (state.lastKnownAppVersion == APP_VERSION) return
def fromVersion = state.lastKnownAppVersion ?: "(unknown/pre-migration-tracking)"
def battDue = state.nextBatteryCheckDue ?: [:]
int cleared = 0
(state.sources ?: []).each { src ->
def bridge = getSourceBridge(src.id)
(bridge?.getChildDevices() ?: []).each { child ->
if (child.hasCapability("Battery") && child.currentValue("batteryMode") == null) {
battDue.remove(child.deviceNetworkId)
cleared++
}
}
}
state.nextBatteryCheckDue = battDue
if (cleared > 0) {
logNormal "Reolink Integration: v1.4.1 migration -- cleared stale battery-check schedule for " +
"${cleared} device(s) with no batteryMode set, so the fix takes effect on the next tick " +
"instead of waiting out an old schedule"
}
logNormal "Reolink Integration: upgraded ${fromVersion} -> ${APP_VERSION}"
state.lastKnownAppVersion = APP_VERSION
}
/** Auto-reverts Full back to Normal after 60 minutes -- Full is meant for actively chasing something, not a steady state. Errors Only and Normal have no timer. */
def revertToNormalLogging() {
app.updateSetting("logLevel", [type: "enum", value: "Normal"])
log.info "Reolink Integration: log level auto-reverted from Full to Normal after 60 minutes"
}
/**
* Backward-compat stub for the pre-1.2.5 debugLogging system's scheduled
* callback name. Gives any leftover pending job from an old install
* somewhere safe to land instead of erroring; nothing schedules a job under
* this name going forward.
*/
def disableDebugLogging() {
log.info "Reolink Integration: leftover pre-1.2.5 logging job fired, no action needed (see disableDebugLogging() comment)"
}
def initializePolling() {
// Children live under each source's bridge, not the app directly --
// iterate sources -> bridge -> its real (Camera/Doorbell) children.
// Also (re)establishes each source's bridge/event-subscription state
// for sources that already have one.
def now = now()
def pollDue = state.nextPollDue ?: [:]
def snapDue = state.nextSnapshotDue ?: [:]
def battDue = state.nextBatteryCheckDue ?: [:]
(state.sources ?: []).each { src ->
// v1.3.9 FIX: this previously called ensureSourceBridge() for
// EVERY configured source unconditionally, on every Done/Update
// click -- creating a real bridge device (and attempting a live
// connection) for a source that had just been added, before the
// user had ever opened its discover page or selected a single
// channel. Same class of premature-creation bug as the one fixed
// in discoverPage() above, just triggered by "Done" instead of by
// viewing the page. Now only re-establishes a bridge that ALREADY
// exists (a source that was genuinely set up before this
// Done/Update cycle) -- a brand-new source with nothing selected
// yet stays completely untouched until real intent exists via
// createSelectedChildren(), which is the only place a bridge
// should ever get created.
if (!getSourceBridge(src.id)) return
def bridge = ensureSourceBridge(src.id)
bridge?.getChildDevices()?.each { child ->
def dni = child.deviceNetworkId
if (!pollDue.containsKey(dni)) pollDue[dni] = now
if (!snapDue.containsKey(dni)) snapDue[dni] = now
if (!battDue.containsKey(dni)) battDue[dni] = now
}
}
state.nextPollDue = pollDue
state.nextSnapshotDue = snapDue
state.nextBatteryCheckDue = battDue
runIn(1, "schedulerTick", [overwrite: true])
}
/**
* Single central scheduler -- exactly ONE recurring timer exists for the
* whole app (this method, ticking every second), and each device's own
* due-time is tracked independently in state (nextPollDue / nextSnapshotDue,
* keyed by DNI). Nothing here can ever cancel another device's schedule,
* because there is only one schedule.
*
* Two robustness measures, since this single tick is the ONE thing every
* device's polling depends on:
* 1. Each device is processed in its own try/catch. One device throwing
* logs a warning and moves on instead of aborting the whole tick.
* 2. The next tick is re-armed in a finally block, so even an unexpected
* failure outside the per-device loop still can't prevent the scheduler
* from continuing to run.
*/
def schedulerTick() {
try {
def nowMs = now()
def pollDue = state.nextPollDue ?: [:]
def snapDue = state.nextSnapshotDue ?: [:]
def battDue = state.nextBatteryCheckDue ?: [:]
(state.sources ?: []).each { src ->
def bridge = getSourceBridge(src.id)
if (!bridge) return
def sourceConnected = isSourceEventConnected(src.id)
(bridge.getChildDevices() ?: []).each { child ->
def dni = child.deviceNetworkId
try {
// v1.3.9: battery level is NEVER delivered via the
// event push path (only motion/AI is), so this check
// deliberately runs regardless of sourceConnected --
// placed before that early-return below, unlike poll/
// snapshot which correctly skip while event mode is
// healthy. hasCapability("Battery") scopes this to
// whichever devices actually declare it -- both Camera
// and Doorbell drivers do, as of v1.3.9.
if (child.hasCapability("Battery") && nowMs >= ((battDue[dni] ?: 0) as Long)) {
// v1.4.1 FIX: a device stuck with batteryMode never
// set silently and permanently skipped this gate
// (due-time still advanced, nothing logged, battery
// never updated short of a manual check). Missing
// batteryMode is now "unknown, go find out" rather
// than "not battery, skip forever" -- backfilled via
// a live probe, once. Self-heals on the next tick.
def batteryMode = child.currentValue("batteryMode")
if (batteryMode == null) {
log.warn "Reolink Integration: ${child.displayName} (${dni}) has no batteryMode set -- " +
"backfilling via a live probe (see v1.4.1 release notes)"
componentCheckBattery(child)
def backfilled = child.currentValue("battery") != null ? "battery" : "wired"
child.receiveBatteryMode(backfilled)
batteryMode = backfilled
}
// v1.4.1: replaced the old hours=0-means-disabled
// convention with an explicit batteryCheckEnabled
// toggle (OFF by default), same pattern as the new
// checkBatteryOnEventWake setting -- clearer than a
// magic number, and consistent across both battery-
// check preferences on the device page.
def checkEnabled = child.getSetting("batteryCheckEnabled") == true
def hours = (child.getSetting("batteryCheckIntervalHours") ?: 12) as Integer
if (checkEnabled && hours > 0 && batteryMode == "battery") {
componentCheckBattery(child)
}
// Re-evaluated even when skipped (disabled, or wired
// device) so a later settings change or batteryMode
// correction is picked up within an hour rather than
// never re-checked again.
battDue[dni] = nowMs + (Math.max(hours, 1) * 3600L * 1000L)
}
// While this source has a confirmed-healthy event
// connection, skip active polling for it -- the push path
// is already delivering its state via
// componentEventChannelUpdate(). nextPollDue is
// deliberately left untouched here so if the connection
// drops, componentEventConnectionStatus() marking it
// due-now takes effect immediately.
if (sourceConnected) return
if (nowMs >= ((pollDue[dni] ?: 0) as Long)) {
pollChildNow(child)
def interval = (child.getSetting("pollIntervalSec") ?: 30) as Integer
pollDue[dni] = nowMs + (interval * 1000L)
}
if (nowMs >= ((snapDue[dni] ?: 0) as Long)) {
pollChildSnapshotNow(child)
def sInterval = (child.getSetting("snapshotIntervalSec") ?: 30) as Integer
snapDue[dni] = nowMs + (sInterval * 1000L)
}
} catch (e) {
log.warn "Reolink Integration: schedulerTick() failed for device ${dni} -- ${e.message}. Skipping this device this tick, will retry next tick."
def interval = (child.getSetting("pollIntervalSec") ?: 30) as Integer
pollDue[dni] = nowMs + (interval * 1000L)
}
}
}
state.nextPollDue = pollDue
state.nextSnapshotDue = snapDue
state.nextBatteryCheckDue = battDue
} catch (e) {
log.warn "Reolink Integration: schedulerTick() failed outside the per-device loop -- ${e.message}"
} finally {
runIn(1, "schedulerTick", [overwrite: true])
}
}
/** Marks a device due on the very next tick (within ~1s) -- used after a poll-interval change so it takes effect immediately rather than waiting out the old interval. */
private markPollDueNow(String dni) {
def pollDue = state.nextPollDue ?: [:]
pollDue[dni] = now()
state.nextPollDue = pollDue
}
/** See markPollDueNow() -- same idea for the snapshot schedule. */
private markSnapshotDueNow(String dni) {
def snapDue = state.nextSnapshotDue ?: [:]
snapDue[dni] = now()
state.nextSnapshotDue = snapDue
}
def pollChild(data) {
def bridge = getSourceBridgeForChannelDni(data.dni)
def child = bridge?.getChildDevice(data.dni)
if (!child) return
pollChildNow(child)
markPollDueNow(child.deviceNetworkId)
}
/**
* Checks the child's CURRENT sleepStatus before logging, so "marking asleep"/
* "marking awake" only hits Normal-tier logging on a real transition. A
* device that's already asleep and stays asleep (or already awake and stays
* awake) only logs at Full tier, since that's routine and not worth
* surfacing by default.
*/
private void pollChildNow(child) {
def sourceId = child.getDataValue("sourceId") as Integer
def channel = child.getDataValue("channel") as Integer
def aiState = reolinkApiCall(sourceId, "GetAiState", [:], channel)
def mdState = reolinkApiCall(sourceId, "GetMdState", [:], channel)
def wasAsleep = child.currentValue("sleepStatus") == "asleep"
if (aiState == null && mdState == null) {
if (wasAsleep) {
logFull "Reolink source ${sourceId} ch ${channel}: still no response, still asleep"
} else {
logNormal "Reolink source ${sourceId} ch ${channel}: no response, marking asleep"
}
child.markAsleep()
} else {
if (wasAsleep) {
logNormal "Reolink source ${sourceId} ch ${channel}: response received, marking awake"
} else {
logFull "Reolink source ${sourceId} ch ${channel}: response received (still awake)"
}
child.parseReolinkState(aiState, mdState)
}
}
/**
* Snapshot caching runs on its OWN schedule (nextSnapshotDue, see
* schedulerTick() above), separate from AI/motion polling (nextPollDue).
* Motion detection benefits from being fast; a dashboard image does not need
* to be refreshed nearly that often, and pulling a full JPEG every few
* seconds across several cameras against this app's singleThreaded
* execution model risks a semaphore/queueing problem. Defaults to a much
* looser interval than the poll interval.
*/
def pollChildSnapshot(data) {
def bridge = getSourceBridgeForChannelDni(data.dni)
def child = bridge?.getChildDevice(data.dni)
if (!child) return
pollChildSnapshotNow(child)
markSnapshotDueNow(child.deviceNetworkId)
}
private void pollChildSnapshotNow(child) {
def sourceId = child.getDataValue("sourceId") as Integer
def channel = child.getDataValue("channel") as Integer
cacheSnapshot(child, sourceId, channel)
}
/**
* Fetches a fresh snapshot and writes it to local hub file storage, keyed by
* device DNI. This is the ONLY place that hits the camera for a snapshot --
* the dashboard-facing relay endpoint (handleSnapshotRequest) just serves
* whatever's cached here, instantly, with no camera round-trip in the
* request path.
*/
private void cacheSnapshot(child, sourceId, channel) {
def src = getSource(sourceId)
if (!src) return
def imageBytes = fetchSnapshotBytes(src, sourceId, channel)
if (imageBytes == null) {
logNormal "Reolink source ${sourceId} ch ${channel}: snapshot cache refresh failed, keeping last cached image (if any)"
return
}
try {
uploadHubFile(snapshotFileName(child.deviceNetworkId), imageBytes)
} catch (e) {
log.warn "Reolink source ${sourceId} ch ${channel}: failed to write snapshot to hub file storage -- ${e.message}"
}
}
private String snapshotFileName(dni) {
"reolink-snap-${dni}.jpg"
}
/**
* Resolves a proper child device reference given a dni passed explicitly
* from the driver (device.deviceNetworkId). Falls back to the raw passed
* reference only for callers that haven't been updated to pass dni yet.
* These calls arrive via the bridge's passthrough layer (Camera/Doorbell's
* real parent), not directly from the child -- the lookup routes through
* whichever bridge actually owns this dni.
*/
private resolveChild(child, String dni) {
def effectiveDni = dni ?: child?.deviceNetworkId
if (!effectiveDni) return child
def bridge = getSourceBridgeForChannelDni(effectiveDni)
return bridge ? (bridge.getChildDevice(effectiveDni) ?: child) : child
}
// ---------- Component callbacks (children call these via parent.X()) ----------
/** dni passed explicitly (device.deviceNetworkId from the driver) -- see resolveChild(). */
def componentRefresh(child, String dni = null) {
def effectiveDni = dni ?: child?.deviceNetworkId
if (!effectiveDni) {
log.warn "Reolink Integration: componentRefresh() called with a device that has no deviceNetworkId"
return
}
pollChild([dni: effectiveDni])
}
/**
* Builds the dashboard-facing snapshot URL and, since the person explicitly
* asked for a snapshot right now, immediately refreshes the cached image
* rather than waiting for the next poll cycle. The URL itself points at this
* app's local relay endpoint (see mappings + handleSnapshotRequest() below).
*/
def componentTakeSnapshot(child, String dni = null) {
def effectiveDni = dni ?: child?.deviceNetworkId
if (!effectiveDni) {
log.warn "Reolink Integration: componentTakeSnapshot() called with a device that has no deviceNetworkId, refusing to build a snapshot URL"
return
}
if (!state.accessToken) {
try {
createAccessToken()
} catch (e) {
log.warn "Reolink Integration: no access token available, snapshot relay endpoint will not work -- ${e.message}"
return
}
}
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
cacheSnapshot(c, sourceId, channel)
def url = "${getFullLocalApiServerUrl()}/snap/${effectiveDni}?access_token=${state.accessToken}"
logNormal "Reolink ${effectiveDni}: snapshot URL built (local relay endpoint, cache refreshed on demand)"
c.receiveSnapshotUrl(url)
}
/**
* Handler for GET /snap/:dni?access_token=... -- called by the browser every
* time a dashboard image tile refreshes. Serves whatever's currently cached
* in local hub file storage for this device. Deliberately does NOT talk to
* the camera itself on every request.
*/
def handleSnapshotRequest() {
def dni = params?.dni
if (!dni || dni == "null") {
log.warn "Reolink Integration: snapshot endpoint hit with no/null device id, this URL is stale -- run takeSnapshot again to regenerate it"
render status: 400, data: "Missing or stale device id, run takeSnapshot again to regenerate this URL", contentType: "text/plain"
return
}
def bridge = getSourceBridgeForChannelDni(dni)
def child = bridge?.getChildDevice(dni)
if (!child) {
render status: 404, data: "Unknown device: ${dni}", contentType: "text/plain"
return
}
byte[] cached = null
try {
cached = downloadHubFile(snapshotFileName(dni))
} catch (e) {
logFull "Reolink Integration: no cached snapshot yet for ${dni} -- ${e.message}"
}
if (!cached || cached.length == 0) {
render status: 404, data: "No snapshot cached yet for this device -- wait for the next poll cycle or run takeSnapshot", contentType: "text/plain"
return
}
render contentType: "image/jpeg", data: cached
}
/** Fetches a live snapshot, retrying once with a forced fresh login on auth failure. */
private byte[] fetchSnapshotBytes(src, sourceId, channel) {
def token = reolinkLogin(sourceId)
def bytes = doFetchSnapshot(src, sourceId, token, channel)
if (bytes == null) {
logFull "Reolink source ${sourceId} ch ${channel}: snapshot fetch failed, forcing re-login and retrying once"
src.token = null
src.tokenExpires = 0
def freshToken = reolinkLogin(sourceId)
if (freshToken) {
bytes = doFetchSnapshot(src, sourceId, freshToken, channel)
}
}
return bytes
}
/**
* Low-level Snap GET. On success the camera returns raw JPEG bytes as an
* InputStream on resp.data, which must be drained explicitly. On failure
* the camera returns a small JSON error payload instead, detected via
* content-type.
*/
private byte[] doFetchSnapshot(src, sourceId, token, channel) {
def uri = "https://${src.host}:${src.port}/cgi-bin/api.cgi?cmd=Snap&channel=${channel}&token=${token}"
byte[] result = null
try {
httpGet([uri: uri, ignoreSSLIssues: true, timeout: 10]) { resp ->
def ct = resp?.contentType?.toString()?.toLowerCase() ?: ""
if (ct.contains("json")) {
def raw = resp?.data?.toString()
logNormal "Reolink source ${sourceId} ch ${channel}: snapshot request returned JSON instead of an image -- ${raw?.take(300)}"
} else if (resp?.data != null) {
def bos = new ByteArrayOutputStream()
bos << resp.data
result = bos.toByteArray()
if (!result || result.length == 0) {
logNormal "Reolink source ${sourceId} ch ${channel}: snapshot stream drained to 0 bytes"
result = null
}
}
}
} catch (e) {
markSourceUnreachable(sourceId, "snapshot fetch (ch ${channel}) failed: ${e.message}")
}
return result
}
def componentPtz(child, String direction, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "PtzCtrl", [op: direction, speed: 32], channel)
}
def componentPtzGoToPreset(child, Integer presetId, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "PtzCtrl", [op: "ToPos", id: presetId, speed: 32], channel)
}
def componentSavePreset(child, Integer presetId, String name, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "SetPtzPreset",
[PtzPreset: [channel: channel, enable: 1, id: presetId, name: name ?: "Preset${presetId}"]], null)
}
def componentSetSpotlight(child, Boolean on, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "SetWhiteLed", [WhiteLed: [channel: channel, state: (on ? 1 : 0)]], null)
}
def componentSetNightVision(child, String mode, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "SetIrLights", [IrLights: [channel: channel, state: mode]], null)
}
def componentSetSiren(child, Boolean on, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "AudioAlarmPlay", [alarm_mode: "manul", manual_switch: (on ? 1 : 0), times: 2], channel)
}
/**
* v1.3.8 NEW: PIR enable/disable, cameras only. Field names unconfirmed
* against real hardware -- built following the same naming convention as
* GetIrLights/SetIrLights, see the Tips page's "built but not tested" list.
*/
def componentSetPir(child, Boolean on, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "SetPirInfo", [PirInfo: [channel: channel, enable: (on ? 1 : 0)]], null)
}
def componentCheckBattery(child, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
def battInfo = reolinkApiCall(sourceId, "GetBatteryInfo", [:], channel)
c.receiveBatteryInfo(battInfo)
}
/**
* Manual recheck for a single device's supportedFeatures attribute -- useful
* after a firmware update that might add capabilities, or if the device was
* created before this feature existed. Re-fetches GetAbility fresh rather
* than relying on anything cached from the original discovery.
*/
def componentCheckAbilities(child, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
def abilityChnList = fetchAbilityChnList(sourceId)
def features = computeSupportedFeatures(abilityChnList?.getAt(channel))
c.receiveSupportedFeatures(features)
logNormal "Reolink source ${sourceId} ch ${channel}: capabilities rechecked -- ${features ? features.join(', ') : 'none detected'}"
}
def componentCalibratePtz(child, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
reolinkApiCall(sourceId, "PtzCheck", [:], channel)
logNormal "Reolink source ${sourceId} ch ${channel}: PTZ calibration triggered"
}
def componentCheckPtzCalibrationStatus(child, String dni = null) {
def c = resolveChild(child, dni)
def sourceId = c.getDataValue("sourceId") as Integer
def channel = c.getDataValue("channel") as Integer
def result = reolinkApiCall(sourceId, "GetPtzCheckState", [:], channel)
def state = result?.PtzCheckState
logNormal "Reolink source ${sourceId} ch ${channel}: PTZ calibration state = ${state}"
c.receivePtzCalibrationState(state)
}
/** dni resolved via resolveChild() -- see that method's doc comment for why. */
def componentSetPollInterval(child, Integer seconds, String dni = null) {
def c = resolveChild(child, dni)
if (!c) {
log.warn "Reolink Integration: componentSetPollInterval() could not resolve a device"
return
}
c.updateSetting("pollIntervalSec", [type: "number", value: seconds])
logNormal "Set poll interval for ${c.deviceNetworkId ?: dni} to ${seconds}s"
if (c.deviceNetworkId) markPollDueNow(c.deviceNetworkId)
}
/** dni resolved via resolveChild() -- see that method's doc comment for why. */
def componentSetSnapshotInterval(child, Integer seconds, String dni = null) {
def c = resolveChild(child, dni)
if (!c) {
log.warn "Reolink Integration: componentSetSnapshotInterval() could not resolve a device"
return
}
c.updateSetting("snapshotIntervalSec", [type: "number", value: seconds])
logNormal "Set snapshot interval for ${c.deviceNetworkId ?: dni} to ${seconds}s"
if (c.deviceNetworkId) markSnapshotDueNow(c.deviceNetworkId)
}
// ---------- Logging ----------
/** Rank of the current logLevel setting within LOG_LEVELS (0=Errors Only, 1=Normal, 2=Full). Defaults to Normal if unset/unrecognized. */
private int logLevelRank() {
def idx = LOG_LEVELS.indexOf(logLevel ?: "Errors Only")
return idx < 0 ? 0 : idx
}
/**
* Logs at Normal tier and above (Normal, Full). Meaningful one-time events
* and state transitions -- not routine unchanged polls.
*
* NOT private -- the Reolink Device Bridge device calls this via
* parent?.logNormal(...) so its own connection-status logging (starting,
* connected, reconnecting) obeys the app's Log level setting instead of
* writing to the hub log unconditionally, same as everything else in this
* app. (v1.3.8 FIX: the bridge previously used raw log.info/log.debug
* throughout, bypassing this tiering entirely -- at Full this reproduced
* the exact log-flooding pattern from BETA testing, and switching the app
* back to Errors Only did nothing to quiet it, since the bridge never
* checked that setting at all.)
*/
void logNormal(msg) {
if (logLevelRank() >= 1) log.debug msg
}
/**
* Logs only at Full tier. Routine poll-by-poll / push-by-push detail --
* token reuse, individual API calls succeeding, unchanged state repeats,
* and (via the bridge) every routine event push and corruption-resync
* detail. NOT private -- see logNormal()'s note above; same reasoning
* applies here, and this is the tier that actually floods if left
* unconditional.
*/
void logFull(msg) {
if (logLevelRank() >= 2) log.debug msg
}