Were you able to resolve this?
-Bill
Were you able to resolve this?
-Bill
No. I gave up. I did find lots of api info if I want to write some code but I don't have time
Alright ... if you want to get back on it .. check the log to see if there are any events logged when triggered. Is it a Amcrest Doorbell cam? This one is picky on the firmware installed .. newer firmware doesn't work well with this plugin.
Hey @tomw, I continue to get a lot of usage and value out of this driver, but it seems like it’s a bit of resource hog. There aren’t any preferences or parameters that I’m aware of that could improve my gas mileage. Any suggestions?
did you perform a PR to the github on this? GitHub - tomwpublic/hubitat_dahua
@tomw any ideas?
Also interested in this filter:
http://$ip/cgi-bin/eventManager.cgi?action=attach&codes=[SmartMotionHuman,SmartMotionVehicle]
I did not because I didn't want to change the original focus. I'd be glad to share my version if someone wanted to do that.
Hal
I am tackling this again. Getting fail to connect in logs on initialize. Does user/pass work still? Has anyone got a Amcrest Smart Home type camera to work?
So updating to 2.0.5.159 breaks my version. Results in 401 when connecting to the NVR.
I have an updated version that fixes it, in case anyone needs it.
It looks like Hubitat 2.5.x is no longer reliably honoring credentials embedded in the URI (http://user:pass@host/``...). By switching the driver to use a proper Digest Authorization header, it is now authenticating the way Dahua/Amcrest cameras expect.
I'm experiencing the same issue.
If you can share your updated version - it would be highly appreciated.
Thanks a lot !
I'm getting the same 401 with the updated version
Any thoughts? Thanks
A couple of things.
1st the NVR could have blacklisted the hub/user (IP block list), also sometimes special characters in the password can cause problems.
For mine, I wound up setting up a new user/password.
I have the same pushover error 400 issue. Did you ever solve this?
Image Server with fixes by tomw:
/*
Copyright 2020 - tomw
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-------------------------------------------
Change history:
0.x.x - tomw - Pushover notifications fix (in progress); Adding hub variables for user and api keys
0.9.2 - tomw - Name/Label display improvements
0.9.1 - tomw - Added 'notify' GET entrypoint for sending Pushover notifications on demand
0.9.0 - tomw - Compatibility for images stored in File Manager
0.1.0 - tomw - Pre-release version
*/
definition(
name: "Image Server",
namespace: "tomw",
author: "tomw",
description: "",
category: "Convenience",
iconUrl: "",
iconX2Url: "",
iconX3Url: "")
preferences
{
page(name: "entryPage")
page(name: "webServerPage")
page(name: "pushoverPage")
}
def uninstalled()
{
unsubscribe()
}
def entryPage()
{
enableOauth()
dynamicPage(name: "entryPage", title: "", install: true, uninstall: true)
{
section
{
href(page: "webServerPage", title: "<b>Select devices to serve images for dashboards and webpages.</b>")
href(page: "pushoverPage", title: "<b>Select devices to use for Pushover notifications.</b>")
}
section
{
input name: "enableLogging", type: "bool", title: "Enable Debug Logging?", defaultValue: false, required: true
}
}
}
def linkToMain()
{
return href(page: "entryPage", title: "<b>Return to main page.</b>")
}
def webServerPage()
{
dynamicPage(name: "webServerPage", title: "", install: true, uninstall: true)
{
section
{
input name: "sourceDevs", type: "capability.*", title: "Select any devices with the 'image' attribute.", multiple: true, required: false, submitOnChange: true
}
section
{
paragraph("<h2>Use these URLs to access images:</h2>")
for(dev in sourceDevs)
{
paragraph("<b>${dev.getDisplayName()}:</b>")
paragraph("<p style='margin-left:10%'>${getFullLocalApiServerUrl() + "/${imagePath}/${dev.getDeviceNetworkId()}?access_token=${token}"}</p>")
}
paragraph("<b>Single most recent image out of all of the selected devices:</b>")
paragraph("<p style='margin-left:10%'>${getFullLocalApiServerUrl() + "/${latestPath}?access_token=${token}"}</p>")
}
section
{
linkToMain()
}
}
}
def pushoverPage()
{
dynamicPage(name: "pushoverPage", title: "", install: true, uninstall: true)
{
unsubscribe()
if(pushoverDevs)
{
subscribe(pushoverDevs, "image", "notificationHandler")
}
section
{
input name: "poUserKey", type: "text", title: "Pushover User Key", required: false
input name: "poApiToken", type: "text", title: "Pushover API Token", required: false
paragraph("<br>")
input name: "pushoverDevs", type: "capability.*", title: "Select devices with 'image' to send notifications from automatically after each 'take' action.", multiple: true, required: false, submitOnChange: true
paragraph("<br>")
input name: "poUserKeyVar", type: "text", title: "Hub Variable to use for Pushover User Key (leave blank if unused)", required: false
input name: "poApiTokenVar", type: "text", title: "Hub Variable to use for Pushover API Token (leave blank if unused)", required: false
paragraph("<br>")
def queryOptions = "<b>&</b>device=required_DNI_or_name<i><b>&</b>title=optional_title<b>&</b>message=optional_message<b>&</b>doTake=optional_false</i>"
paragraph("<b>HTTP GET format for on demand notifications:</b> ${getFullLocalApiServerUrl().replace('/apps', ':8080/apps') + "/${notificationPath}?access_token=${token}" + queryOptions}")
input name: "pushoverDevsOnDemand", type: "capability.*", title: "Select devices with 'image' to send notifications from on demand via HTTP GET as shown above.", multiple: true, required: false, submitOnChange: true
}
section
{
linkToMain()
}
}
}
def logDebug(msg)
{
if(enableLogging)
{
log.debug "${msg}"
}
}
private void enableOauth()
{
// Thanks! - https://github.com/imnotbob/autoMower/blob/main/automower-connect.groovy
Map params=
[
uri: "http://localhost:8080/app/edit/update?_action_update=Update&oauthEnabled=true&id=${app.appTypeId}".toString(),
headers: ['Content-Type':'text/html;charset=utf-8']
]
try
{
httpPost(params) {}
}
catch (e) {}
}
def getToken()
{
if(!state.accessToken)
{
createAccessToken()
}
return state.accessToken
}
import groovy.transform.Field
@Field String imagePath = "image"
@Field String latestPath = "latest"
@Field String notificationPath = "notify"
mappings
{
path("/${imagePath}/:devDniOrName")
{ action: [GET: "serveImage"] }
path("/${latestPath}")
{ action: [GET: "serveLatest"] }
def notifyHandler = [GET: "notifyOnDemand"]
path("/${notificationPath}/:devDniOrName")
{ action: notifyHandler }
path("/${notificationPath}")
{ action: notifyHandler }
}
def serveImage()
{
def reqDev = params.devDniOrName
logDebug("Image Server request for ${reqDev}")
try
{
def dev = findDevByDniOrName(sourceDevs, params.devDniOrName)
if(!dev) { throw new Exception("no matching device") }
if(null == dev.currentValue('image'))
{
throw new Exception("no image attribute present")
}
logDebug("Image Server matched ${dev.getDisplayName()}")
return render(renderImageMap(dev))
}
catch (Exception e)
{
return render(contentType: "text/html", data: "Image Server: ${e.message}", status: 200)
}
}
def serveLatest()
{
try
{
def time = 0
def latestDev
if(!sourceDevs?.size()) { throw new Exception("no devices selected") }
for(dev in sourceDevs)
{
dev.events().each
{
if( invalidImageVals().contains(dev.currentValue('image')?.toString()) ) { return }
if((it.name == "image"))
{
if(it.getUnixTime() > time)
{
time = it.getUnixTime()
latestDev = dev
}
}
}
}
// Fail-safe -- if events have aged out, just use the first device.
// This feels like a bug in the Hubitat Device.events() API
if(null == latestDev) { latestDev = sourceDevs?.getAt(0) }
return render(renderImageMap(latestDev))
}
catch (Exception e)
{
return render(contentType: "text/html", data: "Image Server: ${e.message}", status: 200)
}
}
def invalidImageVals()
{
return [null, "n/a"]
}
def renderImageMap(dev)
{
def imageArr = getImageAttr(dev)
if(null == imageArr)
{
// the file was deleted, or something
throw new Exception("image missing")
}
return [contentType: "image/jpeg;base64", data: imageArr, status: 200]
}
def getImageAttr(dev)
{
def image = dev.currentValue('image')
if(invalidImageVals().contains(image.toString()))
{
throw new Exception("no image present")
}
if(image?.contains("file:"))
{
// new-style images are stored in the File Manager
// and the 'image' attribute is "file:" plus the fileName
def fileName = image.split("file:")?.getAt(1)
try
{
image = downloadHubFile(fileName)
}
catch(java.nio.file.NoSuchFileException e)
{
// file is missing, even though the 'image' attr said it was there
return null
}
catch(groovy.lang.MissingMethodException e)
{
if(e.message.contains("downloadHubFile"))
{
// file I/O APIs were added in 2.3.4.132
def errMsg = "Your camera driver indicates this image is stored in the file manager."
errMsg += " You must update your Hubitat software to at least version 2.3.4.132."
log.error errMsg
}
else { logDebug e }
return null
}
catch(e)
{
logDebug e
return null
}
}
else
{
// old-style images are stored as a hex string, so convert to byte[] before returning
image = hubitat.helper.HexUtils.hexStringToByteArray(image)
}
return image
}
def notificationHandler(evt)
{
def dev = evt.getDevice()
def title = evt.getDisplayName()
def date = evt.getDate()
if(dev.currentValue('image') != "n/a")
{
logDebug("new image on device: ${dev}")
pushoverNotification(dev, title, "New image: ${date}")
}
}
String addNotificationTerm(String body, String name, String value)
{
if("" != body) { body += "&" }
body += "${name}=${value}"
}
String getHubVarString(name)
{
return (getGlobalVar(name)?.type == "string") ? getGlobalVar(name)?.value : null
}
def pushoverNotification(dev, String title = "", String message = "New Image")
{
if([poUserKey, poApiToken, dev].contains(null))
{
logDebug("missing Pushover credentials or device")
return
}
try
{
Map terms =
[
title: title,
message: message,
]
String body = ""
terms.each
{
if(it.value) { body = addNotificationTerm(body, it.key, it.value) }
}
def imageArr = getImageAttr(dev)
// encode (first base64, then URLEncode)
String image = java.net.URLEncoder.encode(imageArr.encodeBase64().toString(), "UTF-8")
String userKey = getHubVarString(poUserKeyVar) ?: poUserKey
String apiToken = getHubVarString(poApiTokenVar) ?: poApiToken
// reference: https://pushover.net/api#attachments
def postBody = "token=${apiToken}&user=${userKey}&${body}&attachment_base64=${image}&attachment_type=image/jpeg"
def params =
[
contentType: "application/x-www-form-urlencoded",
uri: "https://api.pushover.net/1/messages.json",
body: postBody
]
httpPost(params)
{ resp ->
logDebug(resp.data)
if(resp.status != 200)
{
throw new Exception("HTTP error: ${resp.status}")
}
}
}
catch(Exception e)
{
log.error "pushoverNotification error: ${e.message}"
}
}
def findDevByDniOrName(devs, idValue)
{
if(!idValue) { return }
idValue = java.net.URLDecoder.decode(idValue)
// check for matching DNI first and return if so...
def dev = devs?.find { it.getDeviceNetworkId()?.toString() == idValue }
if(dev) { return dev }
// ...if not, then check whether either name or label matches
// note that this would be first found, since only DNI is globally unique
dev = devs?.find { [it.getName(), it.getLabel()].contains(idValue) }
return dev
}
def notifyOnDemand()
{
logDebug("notifyOnDemand(): ${params}")
// only dni/name is absolutely required
if(!(params.devDniOrName || params.device)) { throw new Exception("must specify device name or DNI") }
// remove URL decoding from these others, if they exist
if(params.title) { params.title = java.net.URLDecoder.decode(params.title) }
if(params.message) { params.message = java.net.URLDecoder.decode(params.message) }
try
{
def dev = findDevByDniOrName(pushoverDevsOnDemand, params.devDniOrName ?: params.device)
if(!dev) { throw new Exception("no matching device") }
// if the query specified anything other than 'false' for doTake, update the image
if(![false, "False", "false"].contains(params.doTake)) { dev.take() }
pushoverNotification(dev, params.title ?: getDisplayName(), params.message ?: "New image: ${new Date().toString()}")
}
catch (Exception e)
{
return render(contentType: "text/html", data: "Image Server: ${e.message}", status: 200)
}
}
Unfortunately, none of the posted updated drivers work properly for me. My password doesn't have special characters. I've AI'd it, but without much luck...code development is not a skillset I possess. Hopefully some nice person on here will be able to update this so that it works properly. Thank you in advance.
Hang on buddy
I just finished long and exhausting buttle with ChatGPT, but finally got the working camera driver.
Will post in the morning (have some chores to take care first)
EDIT: Dahua/Amcrest Camera driver with fix for 2.5.1.x
/*
Copyright 2022 - tomw
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-------------------------------------------
Change history:
0.9.x - ChatGPT - auth fix for 2.5.1.x Aug. 26
0.9.x - matthewbrown - all event types Dec. 25
0.9.3 - tomw - Don't rename button child device if it already exists.
0.9.2 - tomw - Store images from 'take' in File Manager. More connection reliability.
0.9.1 - tomw - Removed duplicate motion events. Connection reliability improvements.
0.9.0 - tomw - Initial release
*/
metadata
{
definition(name: "Dahua/Amcrest Camera - Fix2.5.1.x", namespace: "tomw", author: "tomw", importUrl: "")
{
capability "ImageCapture"
capability "Initialize"
capability "MotionSensor"
command "closeStream"
command "clearImages"
attribute "imageTimestamp", "string"
attribute "crossLineDetected", "string"
attribute "crossRegionDetected", "string"
attribute "smartDetectType", "string"
// Smart Motion Detection Attributes
attribute "smartMotionHuman", "string"
attribute "smartMotionHumanConfidence", "string"
attribute "smartMotionVehicle", "string"
attribute "smartMotionVehicleConfidence", "string"
// Video Status Attributes
attribute "videoLoss", "string"
attribute "videoBlind", "string"
attribute "videoAbnormalDetection", "string"
attribute "videoAbnormalType", "string"
attribute "videoUnFocus", "string"
attribute "videoFocusLevel", "string"
// Alarm and Detection Attributes
attribute "alarmLocal", "string"
attribute "alarmLocalType", "string"
attribute "motionDetectionResult", "string"
attribute "leftDetection", "string"
attribute "leftDetectionObject", "string"
attribute "takenAwayDetection", "string"
attribute "takenAwayDetectionObject", "string"
// Audio Detection Attributes
attribute "audioMutation", "string"
attribute "audioMutationLevel", "string"
attribute "audioAnomaly", "string"
attribute "audioAnomalyType", "string"
// AI Detection Attributes
attribute "wanderDetection", "string"
attribute "wanderDetectionRegion", "string"
attribute "rioterDetection", "string"
attribute "rioterDetectionCount", "string"
attribute "parkingDetection", "string"
attribute "parkingDetectionInfo", "string"
attribute "moveDetection", "string"
attribute "moveDetectionInfo", "string"
attribute "crowdDetection", "string"
attribute "crowdDetectionDensity", "string"
// System Event Attributes
attribute "ntpAdjustTime", "string"
attribute "timeChange", "string"
attribute "rtspSession", "string"
attribute "rtspSessionInfo", "string"
}
}
preferences {
section {
input "ipAddress", "text", title: "IP address", required: true
input "port", "text", title: "Port", defaultValue: "80", required: true
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
section {
input "username", "text", title: "Username", required: true
input "password", "password", title: "Password", required: true
}
section("Event Codes") {
input "enableAll", "bool", title: "All Events eventManager events", defaultValue: true
input "enableSmartMotionHuman", "bool", title: "SmartMotionHuman eventManager events", defaultValue: false
input "enableSmartMotionVehicle", "bool", title: "SmartMotionVehicle eventManager events", defaultValue: false
input "enableVideoMotion", "bool", title: "VideoMotion eventManager events", defaultValue: false
input "enableVideoLoss", "bool", title: "VideoLoss eventManager events", defaultValue: false
input "enableVideoBlind", "bool", title: "VideoBlind eventManager events", defaultValue: false
input "enableAlarmLocal", "bool", title: "AlarmLocal eventManager events", defaultValue: false
input "enableMDResult", "bool", title: "MDResult eventManager events", defaultValue: false
input "enableCrossLineDetection", "bool", title: "CrossLineDetection eventManager events", defaultValue: false
input "enableCrossRegionDetection", "bool", title: "CrossRegionDetection eventManager events", defaultValue: false
input "enableLeftDetection", "bool", title: "LeftDetection eventManager events", defaultValue: false
input "enableTakenAwayDetection", "bool", title: "TakenAwayDetection eventManager events", defaultValue: false
input "enableVideoAbnormalDetection", "bool", title: "VideoAbnormalDetection eventManager events", defaultValue: false
input "enableAudioMutation", "bool", title: "AudioMutation eventManager events", defaultValue: false
input "enableAudioAnomaly", "bool", title: "AudioAnomaly eventManager events", defaultValue: false
input "enableVideoUnFocus", "bool", title: "VideoUnFocus eventManager events", defaultValue: false
input "enableWanderDetection", "bool", title: "WanderDetection eventManager events", defaultValue: false
input "enableRioterDetection", "bool", title: "RioterDetection eventManager events", defaultValue: false
input "enableParkingDetection", "bool", title: "ParkingDetection eventManager events", defaultValue: false
input "enableMoveDetection", "bool", title: "MoveDetection eventManager events", defaultValue: false
input "enableCrowdDetection", "bool", title: "CrowdDetection eventManager events", defaultValue: false
input "enableNTPAdjustTime", "bool", title: "NTPAdjustTime eventManager events", defaultValue: false
input "enableTimeChange", "bool", title: "TimeChange eventManager events", defaultValue: false
input "enableRtspSession", "bool", title: "Rtsp-Session eventManager events", defaultValue: false
}
}
def logDebug(msg)
{
if (logEnable)
{
log.debug(msg)
}
}
def initialize()
{
try
{
clearAuthMap()
unschedule()
closeStream()
def devInfo = queryDeviceInfo()
if(null == devInfo) { throw new Exception("failed to connect") }
setDevInfo(devInfo)
runIn(2, openStream)
}
catch (Exception e)
{
logDebug("initialize() failed: ${e.message}")
reinitialize()
}
}
def updated()
{
initialize()
}
def uninstalled()
{
deleteImageFile()
}
def setupDevDetails(devDetails)
{
if(!devDetails) { return }
device.updateSetting("ipAddress", devDetails.ipAddress)
}
def take(channel = "1")
{
try
{
def stream = doCommand("/snapshot.cgi?channel=" + channel)?.data
if(stream)
{
def bSize = stream.available()
byte[] imageArr = new byte[bSize]
stream.read(imageArr, 0, bSize)
writeImageToFile(imageArr)
sendEvent(name: "image", value: "file:${fileName()}", isStateChange: true)
sendEvent(name: "imageTimestamp", value: now())
}
}
catch(groovy.lang.MissingMethodException e)
{
def errMsg = "take() failed: "
if(e.message.contains("uploadHubFile"))
{
errMsg += "You must update your Hubitat software to at least version 2.3.4.132."
}
else
{
errMsg += e.message
}
log.error errMsg
return
}
catch (Exception e)
{
log.debug "take() failed: ${e.message}"
}
}
def clearImages()
{
deleteImageFile()
sendEvent(name: "image", value: "n/a")
sendEvent(name: "imageTimestamp", value: "n/a")
}
def clearAuthMap()
{
state.remove("authMap")
}
def setAuthMap(Map authMap)
{
state.authMap = authMap
}
def getAuthMap()
{
return state.authMap
}
def fetchAuthHeader()
{
def targetUri = genBaseUri() + "/magicBox.cgi?action=getMachineName"
def authMap = [:]
try
{
def params =
[
uri: targetUri,
headers:
[
Authorization: "Basic " + ("${username}:${password}").bytes.encodeBase64().toString()
]
]
httpGet(params)
{
resp ->
// We do not expect this request to succeed.
// The camera should return 401 with WWW-Authenticate.
logDebug("Digest handshake status: ${resp?.status}")
if(resp?.status?.toInteger() == 401)
{
def header = resp?.getHeaders()?.getAt("www-authenticate")
if(header)
{
logDebug("WWW-Authenticate: ${header}")
authMap = parseAuthHeader(header)
}
}
}
}
catch(Exception e)
{
try
{
def resp = e.getResponse()
logDebug("Digest handshake exception status: ${resp?.status}")
if(resp?.status?.toInteger() == 401)
{
def header = resp?.getHeaders()?.getAt("www-authenticate")
if(header)
{
logDebug("WWW-Authenticate: ${header}")
authMap = parseAuthHeader(header)
}
}
}
catch(Exception ignored)
{
logDebug("Could not obtain Digest challenge: ${e.message}")
}
}
if(authMap?.realm && authMap?.nonce)
{
setAuthMap(authMap)
logDebug("Digest challenge stored: realm=${authMap.realm}, qop=${authMap.qop}, algorithm=${authMap.algorithm}")
}
else
{
setAuthMap([:])
log.error "Unable to obtain a valid Digest authentication challenge"
}
return authMap
}
def doCommand(suffix)
{
suffix = fixupUrl(suffix)
// Make sure we have a current Digest challenge.
def authMap = getAuthMap()
if(!authMap?.realm || !authMap?.nonce)
{
authMap = fetchAuthHeader()
}
if(!authMap?.realm || !authMap?.nonce)
{
throw new Exception("Unable to obtain Digest authentication challenge")
}
def params = genParamsHeaders(suffix)
if(!params)
{
throw new Exception("Unable to generate Digest authentication header")
}
try
{
return _command(params)
}
catch(Exception e)
{
def resp = null
try
{
resp = e.getResponse()
}
catch(Exception ignored)
{
}
// Dahua may issue a new nonce and mark the old one stale.
// Get the new challenge and retry this request once.
if(resp?.status?.toInteger() == 401)
{
def header = null
try
{
header = resp?.getHeaders()?.getAt("www-authenticate")
}
catch(Exception ignored)
{
}
if(header)
{
logDebug("Digest re-authentication required: ${header}")
def newAuthMap = parseAuthHeader(header)
if(newAuthMap?.realm && newAuthMap?.nonce)
{
setAuthMap(newAuthMap)
params = genParamsHeaders(suffix)
if(params)
{
return _command(params)
}
}
}
}
throw e
}
}
def parseAuthHeader(authHeader)
{
def authMap = [:]
if(!authHeader)
{
return authMap
}
// Some Hubitat versions return the header as a list.
if(authHeader instanceof List)
{
authHeader = authHeader[0]
}
authHeader = authHeader.toString()
logDebug("Parsing Digest header: ${authHeader}")
// Hubitat may return the complete header including:
// "WWW-Authenticate: Digest ..."
authHeader = authHeader.replaceFirst("(?i)^\\s*WWW-Authenticate:\\s*", "").trim()
if(!authHeader.toLowerCase().startsWith("digest"))
{
return authMap
}
authHeader = authHeader.substring(6).trim()
// Parse comma-separated key="value" or key=value members.
def matcher = authHeader =~ /([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/
matcher.each
{
full, key, quotedValue, unquotedValue ->
def value = (quotedValue != null) ? quotedValue : unquotedValue
authMap[key.toLowerCase()] = value
}
// Dahua cameras used by this driver advertise qop=auth and MD5.
authMap.nc = 1
authMap.cnonce = java.util.UUID.randomUUID().toString().replaceAll('-', '').substring(0, 8)
return authMap
}
def genAuthDigest(suffix)
{
def authMap = getAuthMap()
if(!authMap?.realm || !authMap?.nonce)
{
return
}
def qop = authMap.qop ?: "auth"
def algorithm = authMap.algorithm ?: "MD5"
if(qop.toLowerCase() != "auth")
{
log.error "Unsupported Dahua Digest qop: ${qop}"
return
}
if(algorithm.toUpperCase() != "MD5")
{
log.error "Unsupported Dahua Digest algorithm: ${algorithm}"
return
}
// Digest nonce-count MUST be eight hexadecimal digits.
def ncNumber = authMap.nc ?: 1
def nc = Integer.toHexString(ncNumber).padLeft(8, '0')
def cnonce = authMap.cnonce
def ha1Raw = [username, authMap.realm, password].join(":")
def HA1 = md5(ha1Raw)
def ha2Raw = ["GET", suffix].join(":")
def HA2 = md5(ha2Raw)
def responseRaw =
[
HA1,
authMap.nonce,
nc,
cnonce,
qop,
HA2
].join(":")
def response = md5(responseRaw)
logDebug("Digest auth generated: qop=${qop}, nc=${nc}, uri=${suffix}, response=${response}")
def digest =
"Digest " +
"username=\"${username}\", " +
"realm=\"${authMap.realm}\", " +
"nonce=\"${authMap.nonce}\", " +
"uri=\"${suffix}\", " +
"qop=${qop}, " +
"nc=${nc}, " +
"cnonce=\"${cnonce}\", " +
"response=\"${response}\""
if(authMap.opaque)
{
digest += ", opaque=\"${authMap.opaque}\""
}
// Increment nonce-count only after constructing the request.
authMap.nc = ncNumber + 1
setAuthMap(authMap)
return digest
}
def genParamsHeaders(suffix)
{
suffix = fixupUrl(suffix)
def auth = genAuthDigest(suffix)
if(!auth)
{
log.error "genParamsHeaders: unable to generate Digest authorization"
return null
}
logDebug("genParamsHeaders: suffix=${suffix}")
logDebug("genParamsHeaders: auth generated=true")
def params =
[
uri: genBaseUri() + suffix,
headers:
[
Authorization: auth
]
]
return params
}
def _command(params)
{
// inner command, assuming token is valid
return httpExec("GET", params)
}
def deviceQuery(suffix)
{
def respData = doCommand(suffix)?.data
return strReaderToString(respData)
}
def setDevInfo(devInfo)
{
state.devInfo = devInfo
def alias = devInfo?.machineName + " Camera"
//device.setLabel(alias)
//device.setName(alias)
}
def getDevInfo()
{
return state.devInfo
}
def queryDeviceInfo()
{
try
{
def machineName = deviceQuery("/magicBox.cgi?action=getMachineName")?.split("=")?.getAt(1)
def serialNo = deviceQuery("/magicBox.cgi?action=getSerialNo")?.split("=")?.getAt(1)
def deviceType = deviceQuery("/magicBox.cgi?action=getDeviceType")?.split("=")?.getAt(1)
def devInfo = [machineName: machineName, serialNo: serialNo, deviceType: deviceType]
//logDebug(devInfo)
return devInfo
}
catch(Exception e)
{
log.error "queryDeviceInfo() failed: ${e}"
}
}
def openStream()
{
// references:
//https://github.com/tchellomello/python-amcrest/issues/137#issuecomment-677930804
//https://github.com/dchesterton/amcrest2mqtt/blob/main/src/amcrest2mqtt.py#L429
//https://github.com/GeorgeIoak/Amcrest_AD110_Button/blob/main/camera-events.py#L107
//https://github.com/tchellomello/python-amcrest/issues/151#issuecomment-1057483008
//https://github.com/rroller/dahua/blob/main/custom_components/dahua/__init__.py#L343
def codes = []
if (enableAll) {
codes = ["All"]
} else {
if (enableSmartMotionHuman) codes << "SmartMotionHuman"
if (enableSmartMotionVehicle) codes << "SmartMotionVehicle"
if (enableVideoMotion) codes << "VideoMotion"
if (enableVideoLoss) codes << "VideoLoss"
if (enableVideoBlind) codes << "VideoBlind"
if (enableAlarmLocal) codes << "AlarmLocal"
if (enableMDResult) codes << "MDResult"
if (enableCrossLineDetection) codes << "CrossLineDetection"
if (enableCrossRegionDetection) codes << "CrossRegionDetection"
if (enableLeftDetection) codes << "LeftDetection"
if (enableTakenAwayDetection) codes << "TakenAwayDetection"
if (enableVideoAbnormalDetection) codes << "VideoAbnormalDetection"
if (enableAudioMutation) codes << "AudioMutation"
if (enableAudioAnomaly) codes << "AudioAnomaly"
if (enableVideoUnFocus) codes << "VideoUnFocus"
if (enableWanderDetection) codes << "WanderDetection"
if (enableRioterDetection) codes << "RioterDetection"
if (enableParkingDetection) codes << "ParkingDetection"
if (enableMoveDetection) codes << "MoveDetection"
if (enableCrowdDetection) codes << "CrowdDetection"
if (enableNTPAdjustTime) codes << "NTPAdjustTime"
if (enableTimeChange) codes << "TimeChange"
if (enableRtspSession) codes << "Rtsp-Session"
}
def eventNames = codes ? codes.join(",") : "All"
def codeParam = "${eventNames}"
def suffix = fixupUrl("/eventManager.cgi?action=attach&codes=[${codeParam}]")
fetchAuthHeader()
def params = genParamsHeaders(suffix)
if (params == null) { return }
params.rawData = true
params.pingInterval = 1
params.readTimeout = 300
clearEvent()
def targetUri = genBaseUri() + suffix
interfaces.eventStream.connect(targetUri, params)
logDebug("eventManager: attached with ${eventNames} codes")
}
def parse(String message)
{
//logDebug("parse: ${message}")
if(message.toLowerCase()?.contains("myboundary"))
{
event = getEvent()
//logDebug("event complete:\r\n ${event}")
if(event?.toLowerCase()?.contains("code"))
{
//logDebug("parse: ${message}")
processCodeMessage("Code=" + event.split("Code=")[1])
}
clearEvent()
}
else
{
addEventLine(message)
}
}
def addEventLine(line)
{
def event = getEvent()
if(null == event) { clearEvent() }
event += line
setVolatileState("event", event)
}
def getEvent()
{
return getVolatileState("event")
}
def clearEvent()
{
setVolatileState("event", "")
}
def processCodeMessage(message)
{
message = message.split(";")
def slurper = new groovy.json.JsonSlurper()
def codeMap = [:]
def member
message.each
{
try
{
member = it.split('data(:|=)', 2)
if(member.size() == 2)
{
// some firmware versions report as "data:" and some "data=",
// ...so, support both
member = it.split('(:|=)', 2)
// this is a data entry, presumably including json values
codeMap += [(member[0]): slurper.parseText(member[1])]
// we successfully processed this entry, so don't do it again
return
}
// after any special cases (above), parse what is left
member = it.split("=")
if(member.size() == 2)
{
// this is a typical entry
codeMap += [(member[0]): member[1]]
}
}
catch(Exception e)
{
log.error "processCodeMessage() error: ${e}\r\nline contents: ${it}"
}
}
logDebug(codeMap)
switch(codeMap?.Code)
{
case "_DoTalkAction_":
handleDoorBellPress(codeMap)
break
case "SmartMotionHuman":
handleSmartMotionHuman(codeMap)
break
case "SmartMotionVehicle":
handleSmartMotionVehicle(codeMap)
break
case "VideoMotion":
handleSimpleMotion(codeMap)
break
case "VideoLoss":
handleVideoLoss(codeMap)
break
case "VideoBlind":
handleVideoBlind(codeMap)
break
case "AlarmLocal":
handleAlarmLocal(codeMap)
break
case "MDResult":
handleMDResult(codeMap)
break
case "CrossRegionDetection":
handleCrossRegion(codeMap)
break
case "CrossLineDetection":
handleCrossLine(codeMap)
break
case "LeftDetection":
handleLeftDetection(codeMap)
break
case "TakenAwayDetection":
handleTakenAwayDetection(codeMap)
break
case "VideoAbnormalDetection":
handleVideoAbnormalDetection(codeMap)
break
case "AudioMutation":
handleAudioMutation(codeMap)
break
case "AudioAnomaly":
handleAudioAnomaly(codeMap)
break
case "VideoUnFocus":
handleVideoUnFocus(codeMap)
break
case "WanderDetection":
handleWanderDetection(codeMap)
break
case "RioterDetection":
handleRioterDetection(codeMap)
break
case "ParkingDetection":
handleParkingDetection(codeMap)
break
case "MoveDetection":
handleMoveDetection(codeMap)
break
case "CrowdDetection":
handleCrowdDetection(codeMap)
break
case "NTPAdjustTime":
handleNTPAdjustTime(codeMap)
break
case "TimeChange":
handleTimeChange(codeMap)
break
case "Rtsp-Session":
handleRtspSession(codeMap)
break
}
return codeMap
}
def handleDoorBellPress(codeMap)
{
def devDetails =
[
alias: "button",
devType: "doorbell",
childType: "Generic Component Button Controller"
]
if(codeMap.data?.Action == "Invite")
{
logDebug("Doorbell button pressed")
getChild(devDetails)?.push(1)
}
}
def componentPush(ch, button = 1)
{
ch.sendEvent(name: "pushed", value: 1, isStateChange: true)
}
def handleSimpleMotion(codeMap)
{
logDebug("Motion status: ${codeMap.action}")
sendEvent(name: "motion", value: (codeMap.action == "Start") ? "active" : "inactive")
}
def handleCrossRegion(codeMap)
{
def typeName = "waiting"
def regionName = "waiting"
if(codeMap.action == "Start")
{
regionName = codeMap.data?.Name
logDebug("Cross region detected: ${regionName}")
typeName = codeMap.data?.Object?.ObjectType
logDebug("Object detected: ${typeName}")
}
sendEvent(name: "crossRegionDetected", value: regionName)
sendEvent(name: "smartDetectType", value: typeName)
}
def handleCrossLine(codeMap)
{
def lineName = "waiting"
if(codeMap.action == "Start")
{
lineName = codeMap.data?.Name
logDebug("Cross line detected: ${lineName}")
}
sendEvent(name: "crossLineDetected", value: lineName)
}
def handleSmartMotionHuman(codeMap)
{
def status = "waiting"
def confidence = "unknown"
if(codeMap.action == "Start")
{
status = "detected"
confidence = codeMap.data?.Confidence ?: "unknown"
logDebug("Smart motion human detected with confidence: ${confidence}%")
}
sendEvent(name: "smartMotionHuman", value: status)
sendEvent(name: "smartMotionHumanConfidence", value: confidence)
}
def handleSmartMotionVehicle(codeMap)
{
def status = "waiting"
def confidence = "unknown"
if(codeMap.action == "Start")
{
status = "detected"
confidence = codeMap.data?.Confidence ?: "unknown"
logDebug("Smart motion vehicle detected with confidence: ${confidence}%")
}
sendEvent(name: "smartMotionVehicle", value: status)
sendEvent(name: "smartMotionVehicleConfidence", value: confidence)
}
def handleVideoLoss(codeMap)
{
def status = (codeMap.action == "Start") ? "lost" : "recovered"
logDebug("Video loss status: ${status}")
sendEvent(name: "videoLoss", value: status)
}
def handleVideoBlind(codeMap)
{
def status = (codeMap.action == "Start") ? "blinded" : "normal"
logDebug("Video blind status: ${status}")
sendEvent(name: "videoBlind", value: status)
}
def handleAlarmLocal(codeMap)
{
def alarmType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "active" : "inactive"
logDebug("Local alarm ${alarmType} status: ${status}")
sendEvent(name: "alarmLocal", value: status)
sendEvent(name: "alarmLocalType", value: alarmType)
}
def handleMDResult(codeMap)
{
def result = codeMap.data?.Result ?: "unknown"
logDebug("Motion detection result: ${result}")
sendEvent(name: "motionDetectionResult", value: result)
}
def handleLeftDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def objectInfo = codeMap.data?.Object ?: "unknown"
logDebug("Left detection status: ${status}, object: ${objectInfo}")
sendEvent(name: "leftDetection", value: status)
sendEvent(name: "leftDetectionObject", value: objectInfo)
}
def handleTakenAwayDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def objectInfo = codeMap.data?.Object ?: "unknown"
logDebug("Taken away detection status: ${status}, object: ${objectInfo}")
sendEvent(name: "takenAwayDetection", value: status)
sendEvent(name: "takenAwayDetectionObject", value: objectInfo)
}
def handleVideoAbnormalDetection(codeMap)
{
def abnormalType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "abnormal" : "normal"
logDebug("Video abnormal detection type: ${abnormalType}, status: ${status}")
sendEvent(name: "videoAbnormalDetection", value: status)
sendEvent(name: "videoAbnormalType", value: abnormalType)
}
def handleAudioMutation(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def audioLevel = codeMap.data?.Level ?: "unknown"
logDebug("Audio mutation status: ${status}, level: ${audioLevel}")
sendEvent(name: "audioMutation", value: status)
sendEvent(name: "audioMutationLevel", value: audioLevel)
}
def handleAudioAnomaly(codeMap)
{
def anomalyType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "anomaly" : "normal"
logDebug("Audio anomaly type: ${anomalyType}, status: ${status}")
sendEvent(name: "audioAnomaly", value: status)
sendEvent(name: "audioAnomalyType", value: anomalyType)
}
def handleVideoUnFocus(codeMap)
{
def status = (codeMap.action == "Start") ? "unfocused" : "focused"
def focusLevel = codeMap.data?.FocusLevel ?: "unknown"
logDebug("Video focus status: ${status}, level: ${focusLevel}")
sendEvent(name: "videoUnFocus", value: status)
sendEvent(name: "videoFocusLevel", value: focusLevel)
}
def handleWanderDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def regionInfo = codeMap.data?.Region ?: "unknown"
logDebug("Wander detection status: ${status}, region: ${regionInfo}")
sendEvent(name: "wanderDetection", value: status)
sendEvent(name: "wanderDetectionRegion", value: regionInfo)
}
def handleRioterDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def personCount = codeMap.data?.Count ?: "unknown"
logDebug("Rioter detection status: ${status}, count: ${personCount}")
sendEvent(name: "rioterDetection", value: status)
sendEvent(name: "rioterDetectionCount", value: personCount)
}
def handleParkingDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def parkingInfo = codeMap.data?.Info ?: "unknown"
logDebug("Parking detection status: ${status}, info: ${parkingInfo}")
sendEvent(name: "parkingDetection", value: status)
sendEvent(name: "parkingDetectionInfo", value: parkingInfo)
}
def handleMoveDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def moveInfo = codeMap.data?.Info ?: "unknown"
logDebug("Move detection status: ${status}, info: ${moveInfo}")
sendEvent(name: "moveDetection", value: status)
sendEvent(name: "moveDetectionInfo", value: moveInfo)
}
def handleCrowdDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def crowdDensity = codeMap.data?.Density ?: "unknown"
logDebug("Crowd detection status: ${status}, density: ${crowdDensity}")
sendEvent(name: "crowdDetection", value: status)
sendEvent(name: "crowdDetectionDensity", value: crowdDensity)
}
def handleNTPAdjustTime(codeMap)
{
def timeInfo = codeMap.data?.Time ?: "unknown"
logDebug("NTP time adjustment: ${timeInfo}")
sendEvent(name: "ntpAdjustTime", value: timeInfo)
}
def handleTimeChange(codeMap)
{
def timeInfo = codeMap.data?.Time ?: "unknown"
logDebug("Time change event: ${timeInfo}")
sendEvent(name: "timeChange", value: timeInfo)
}
def handleRtspSession(codeMap)
{
def sessionInfo = codeMap.data?.Session ?: "unknown"
def status = codeMap.action ?: "unknown"
logDebug("RTSP session ${sessionInfo}: ${status}")
sendEvent(name: "rtspSession", value: status)
sendEvent(name: "rtspSessionInfo", value: sessionInfo)
}
def getChild(Map devDetails)
{
if(!devDetails) { return }
// devDetails definition:
// alias = "friendly" name of device to append to main device name from Amcrest
// devType = type of device, like doorbell
// childType = driver name for child
// childNS = namespace for driver, or null for "hubitat"
def childDni = device.getDeviceNetworkId() + "-${devDetails.devType}"
def devLabel = device.getName() + " ${devDetails.alias}"
def props =
[
name: devLabel,
label: devLabel,
isComponent: false
]
def ch = getChildDevice(childDni)
if(!ch)
{
ch = addChildDevice(devDetails.childNS ?: "hubitat", devDetails.childType, childDni, props)
ch?.setLabel(devLabel)
ch?.setName(devLabel)
}
return ch
}
def genBaseUri()
{
return "http://" + ipAddress + ":" + port + "/cgi-bin"
}
def genAuthUri()
{
return "http://${username}:${password}@" + ipAddress + ":" + port + "/cgi-bin"
}
def fixupUrl(url)
{
return url.replace('[', "%5B").replace(']', "%5D")
}
def genParamsFull(suffix, withAuth = true)
{
suffix = fixupUrl(suffix)
def targetUri = (withAuth ? genAuthUri() : genBaseUri()) + suffix
def params = [uri: targetUri]
return params
}
def addDigestMember(digest, name, value, last = false)
{
if(null == digest)
{
digest = "Digest "
}
def eol = !last ? '", ' : '"'
def newTerm = "${name}=\"" + value + eol
if(["nc", "qop"].contains(name)) { newTerm = newTerm.replaceAll('"', '') }
digest += newTerm
return digest
}
def httpExec(operation, params)
{
def result = null
//logDebug("httpExec(${operation}, ${params})")
def httpClosure =
{ resp ->
result = resp
//logDebug("result.data = ${result.data}")
}
def httpOp
switch(operation)
{
case "POST":
httpOp = this.delegate.&httpPost
break
case "GET":
httpOp = this.delegate.&httpGet
break
}
httpOp(params, httpClosure)
return result
}
//////////////////////////////////////
// volatile state
//////////////////////////////////////
import groovy.transform.Field
@Field static volatileState = [:].asSynchronized()
def vsUid()
{
return device?.getDeviceNetworkId() ?: app.getId()
}
def setVolatileState(name, value)
{
def tempState = volatileState[vsUid()] ?: [:]
tempState.putAt(name, value)
volatileState.putAt(vsUid(), tempState)
return volatileState
}
def getVolatileState(name)
{
return volatileState.getAt(vsUid())?.getAt(name)
}
def syncWait(data)
{
// set up for checking 5x per second
setVolatileState("syncWaitDetails", [waitSetter: data.waitSetter, retryCount: data.timeoutSec * 5])
}
def doWait()
{
def wtDetails = getVolatileState("syncWaitDetails")
// check every 200 ms whether the wait was cleared...
if(wtDetails?.waitSetter == "")
{
return
}
// ...or throw an exception if we ran out of tries
if(wtDetails?.retryCount == 0)
{
throw new Exception("wait timed out: ${wtDetails?.waitSetter}")
}
wtDetails.putAt("retryCount", wtDetails.getAt("retryCount") - 1)
setVolatileState("syncWaitDetails", wtDetails)
pauseExecution(200)
doWait()
}
def clearWait(data)
{
def wtDetails = getVolatileState("syncWaitDetails")
if(data.waitSetter == wtDetails?.getAt("waitSetter"))
{
syncWait([waitSetter: "", timeoutSec: 0])
}
}
def clearAllWaits()
{
syncWait([waitSetter: "", timeoutSec: 0])
}
//////////////////////////////////////
// EventSocket handling
//////////////////////////////////////
def reinitialize()
{
unschedule(initialize)
// thanks ogiewon for the example
// first delay is 2 seconds, doubles every time
def delayCalc = (state.reconnectDelay ?: 1) * 2
// upper limit is 600s
def reconnectDelay = delayCalc <= 600 ? delayCalc : 600
state.reconnectDelay = reconnectDelay
runIn(reconnectDelay, initialize)
}
def eventStreamStatus(String message)
{
logDebug("eventStreamStatus: ${message}")
// thanks for the idea: https://community.hubitat.com/t/websocket-client/11843/15
if(message?.toLowerCase()?.contains("start"))
{
state.reconnectDelay = 1
setWasExpectedClose(false)
setStreamActive(true)
return
}
if(message?.toLowerCase()?.contains("stop"))
{
clearWait([waitSetter: "closeStream"])
setStreamActive(false)
streamStopErrorActions()
}
if(message?.toLowerCase()?.contains("error"))
{
streamStopErrorActions()
}
}
def streamStopErrorActions()
{
if(getWasExpectedClose())
{
setWasExpectedClose(false)
return
}
reinitialize()
}
def setWasExpectedClose(wasExpected)
{
//state.wasExpectedClose = wasExpected
setVolatileState("wasExpectedClose", wasExpected)
}
def getWasExpectedClose()
{
//def wec = state.wasExpectedClose
def wec = getVolatileState("wasExpectedClose")
return (null != wec) ? wec : true
}
def setStreamActive(active)
{
setVolatileState("streamActive", active)
}
def getStreamActive()
{
def active = getVolatileState("streamActive")
return (active != null) ? active : false
}
def closeStream()
{
try
{
setWasExpectedClose(true)
if(getStreamActive())
{
// anticipate that this wait will be cleared in eventStreamStatus
syncWait([waitSetter: "closeStream", timeoutSec: 60])
interfaces.eventStream.close()
doWait()
}
}
catch (Exception e)
{
log.warn e.message
}
}
//////////////////////////////////////
// File system operations
//////////////////////////////////////
def fileName()
{
return device.getDeviceNetworkId()
}
def writeImageToFile(byte[] image)
{
if(null == image) { return }
uploadHubFile(fileName(), image)
}
def deleteImageFile(fileName = fileName())
{
deleteHubFile(fileName)
}
//////////////////////////////////////
// Random helpers and completion
//////////////////////////////////////
def strReaderToString(reader)
{
if(null == reader) { return }
// https://stackoverflow.com/a/17751100
java.lang.StringBuilder builder = new java.lang.StringBuilder()
// non-zero value so we read at least once
int charsRead = 1
char[] chars = new char[1024]
while(charsRead > 0)
{
charsRead = reader.read(chars, 0, chars.length)
if(charsRead>0) { builder.append(chars, 0, charsRead) }
}
def str = builder.toString()
return str
}
String md5(String strToSign)
{
if(null == strToSign) { return }
try
{
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("MD5")
def md5_bytes = digest.digest(strToSign.getBytes("UTF-8"))
return hubitat.helper.HexUtils.byteArrayToHexString(md5_bytes)?.toLowerCase()
}
catch(Exception e)
{
log.error "md5(${strToSign}) failed: ${e}"
}
}
def componentDoubleTap(ch, button = 1)
{
//no_op
}
def componentHold(ch, button = 1)
{
//no_op
}
def componentRelease(ch, button = 1)
{
//no_op
}
Thanks for that, Juker, that really fixed alot of it! I have a crappy AD110 Doorbell Camera that has a bug that Amcrest refuses to fix, so I relied on the original eventStream logs to apply certain api calls to the camera as a workaround for said bug. It seems like the eventStreams went missing in the updated version, but no big deal as Copilot helped me add them back in. Here is the updated driver.
/*
Copyright 2022 - tomw
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-------------------------------------------
Change history:
0.9.6 - TW via Copilot - Added eventStream logging support to the 2_5_1 driver, including new attribute, preference, and full event/status logging.
0.9.5 - ChatGPT - auth fix for 2.5.1.x Aug. 26
0.9.4 - matthewbrown - all event types Dec. 25
0.9.3 - tomw - Don't rename button child device if it already exists.
0.9.2 - tomw - Store images from 'take' in File Manager. More connection reliability.
0.9.1 - tomw - Removed duplicate motion events. Connection reliability improvements.
0.9.0 - tomw - Initial release
*/
metadata
{
definition(name: "Dahua/Amcrest Camera - Fix2.5.1.x", namespace: "tomw", author: "tomw", importUrl: "")
{
capability "ImageCapture"
capability "Initialize"
capability "MotionSensor"
command "closeStream"
command "clearImages"
attribute "imageTimestamp", "string"
attribute "crossLineDetected", "string"
attribute "crossRegionDetected", "string"
attribute "smartDetectType", "string"
// Smart Motion Detection Attributes
attribute "smartMotionHuman", "string"
attribute "smartMotionHumanConfidence", "string"
attribute "smartMotionVehicle", "string"
attribute "smartMotionVehicleConfidence", "string"
// Video Status Attributes
attribute "videoLoss", "string"
attribute "videoBlind", "string"
attribute "videoAbnormalDetection", "string"
attribute "videoAbnormalType", "string"
attribute "videoUnFocus", "string"
attribute "videoFocusLevel", "string"
// Alarm and Detection Attributes
attribute "alarmLocal", "string"
attribute "alarmLocalType", "string"
attribute "motionDetectionResult", "string"
attribute "leftDetection", "string"
attribute "leftDetectionObject", "string"
attribute "takenAwayDetection", "string"
attribute "takenAwayDetectionObject", "string"
// Audio Detection Attributes
attribute "audioMutation", "string"
attribute "audioMutationLevel", "string"
attribute "audioAnomaly", "string"
attribute "audioAnomalyType", "string"
// AI Detection Attributes
attribute "wanderDetection", "string"
attribute "wanderDetectionRegion", "string"
attribute "rioterDetection", "string"
attribute "rioterDetectionCount", "string"
attribute "parkingDetection", "string"
attribute "parkingDetectionInfo", "string"
attribute "moveDetection", "string"
attribute "moveDetectionInfo", "string"
attribute "crowdDetection", "string"
attribute "crowdDetectionDensity", "string"
// System Event Attributes
attribute "ntpAdjustTime", "string"
attribute "timeChange", "string"
attribute "rtspSession", "string"
attribute "rtspSessionInfo", "string"
}
}
preferences {
section {
input "ipAddress", "text", title: "IP address", required: true
input "port", "text", title: "Port", defaultValue: "80", required: true
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
section {
input "username", "text", title: "Username", required: true
input "password", "password", title: "Password", required: true
}
section("Event Codes") {
input "enableAll", "bool", title: "All Events eventManager events", defaultValue: true
input "enableSmartMotionHuman", "bool", title: "SmartMotionHuman eventManager events", defaultValue: false
input "enableSmartMotionVehicle", "bool", title: "SmartMotionVehicle eventManager events", defaultValue: false
input "enableVideoMotion", "bool", title: "VideoMotion eventManager events", defaultValue: false
input "enableVideoLoss", "bool", title: "VideoLoss eventManager events", defaultValue: false
input "enableVideoBlind", "bool", title: "VideoBlind eventManager events", defaultValue: false
input "enableAlarmLocal", "bool", title: "AlarmLocal eventManager events", defaultValue: false
input "enableMDResult", "bool", title: "MDResult eventManager events", defaultValue: false
input "enableCrossLineDetection", "bool", title: "CrossLineDetection eventManager events", defaultValue: false
input "enableCrossRegionDetection", "bool", title: "CrossRegionDetection eventManager events", defaultValue: false
input "enableLeftDetection", "bool", title: "LeftDetection eventManager events", defaultValue: false
input "enableTakenAwayDetection", "bool", title: "TakenAwayDetection eventManager events", defaultValue: false
input "enableVideoAbnormalDetection", "bool", title: "VideoAbnormalDetection eventManager events", defaultValue: false
input "enableAudioMutation", "bool", title: "AudioMutation eventManager events", defaultValue: false
input "enableAudioAnomaly", "bool", title: "AudioAnomaly eventManager events", defaultValue: false
input "enableVideoUnFocus", "bool", title: "VideoUnFocus eventManager events", defaultValue: false
input "enableWanderDetection", "bool", title: "WanderDetection eventManager events", defaultValue: false
input "enableRioterDetection", "bool", title: "RioterDetection eventManager events", defaultValue: false
input "enableParkingDetection", "bool", title: "ParkingDetection eventManager events", defaultValue: false
input "enableMoveDetection", "bool", title: "MoveDetection eventManager events", defaultValue: false
input "enableCrowdDetection", "bool", title: "CrowdDetection eventManager events", defaultValue: false
input "enableNTPAdjustTime", "bool", title: "NTPAdjustTime eventManager events", defaultValue: false
input "enableTimeChange", "bool", title: "TimeChange eventManager events", defaultValue: false
input "enableRtspSession", "bool", title: "Rtsp-Session eventManager events", defaultValue: false
}
}
def logDebug(msg)
{
if (logEnable)
{
log.debug(msg)
}
}
def initialize()
{
try
{
clearAuthMap()
unschedule()
closeStream()
def devInfo = queryDeviceInfo()
if(null == devInfo) { throw new Exception("failed to connect") }
setDevInfo(devInfo)
runIn(2, openStream)
}
catch (Exception e)
{
logDebug("initialize() failed: ${e.message}")
reinitialize()
}
}
def updated()
{
initialize()
}
def uninstalled()
{
deleteImageFile()
}
def setupDevDetails(devDetails)
{
if(!devDetails) { return }
device.updateSetting("ipAddress", devDetails.ipAddress)
}
def take(channel = "1")
{
try
{
def stream = doCommand("/snapshot.cgi?channel=" + channel)?.data
if(stream)
{
def bSize = stream.available()
byte[] imageArr = new byte[bSize]
stream.read(imageArr, 0, bSize)
writeImageToFile(imageArr)
sendEvent(name: "image", value: "file:${fileName()}", isStateChange: true)
sendEvent(name: "imageTimestamp", value: now())
}
}
catch(groovy.lang.MissingMethodException e)
{
def errMsg = "take() failed: "
if(e.message.contains("uploadHubFile"))
{
errMsg += "You must update your Hubitat software to at least version 2.3.4.132."
}
else
{
errMsg += e.message
}
log.error errMsg
return
}
catch (Exception e)
{
log.debug "take() failed: ${e.message}"
}
}
def clearImages()
{
deleteImageFile()
sendEvent(name: "image", value: "n/a")
sendEvent(name: "imageTimestamp", value: "n/a")
}
def clearAuthMap()
{
state.remove("authMap")
}
def setAuthMap(Map authMap)
{
state.authMap = authMap
}
def getAuthMap()
{
return state.authMap
}
def fetchAuthHeader()
{
def targetUri = genBaseUri() + "/magicBox.cgi?action=getMachineName"
def authMap = [:]
try
{
def params =
[
uri: targetUri,
headers:
[
Authorization: "Basic " + ("${username}:${password}").bytes.encodeBase64().toString()
]
]
httpGet(params)
{
resp ->
// We do not expect this request to succeed.
// The camera should return 401 with WWW-Authenticate.
logDebug("Digest handshake status: ${resp?.status}")
if(resp?.status?.toInteger() == 401)
{
def header = resp?.getHeaders()?.getAt("www-authenticate")
if(header)
{
logDebug("WWW-Authenticate: ${header}")
authMap = parseAuthHeader(header)
}
}
}
}
catch(Exception e)
{
try
{
def resp = e.getResponse()
logDebug("Digest handshake exception status: ${resp?.status}")
if(resp?.status?.toInteger() == 401)
{
def header = resp?.getHeaders()?.getAt("www-authenticate")
if(header)
{
logDebug("WWW-Authenticate: ${header}")
authMap = parseAuthHeader(header)
}
}
}
catch(Exception ignored)
{
logDebug("Could not obtain Digest challenge: ${e.message}")
}
}
if(authMap?.realm && authMap?.nonce)
{
setAuthMap(authMap)
logDebug("Digest challenge stored: realm=${authMap.realm}, qop=${authMap.qop}, algorithm=${authMap.algorithm}")
}
else
{
setAuthMap([:])
log.error "Unable to obtain a valid Digest authentication challenge"
}
return authMap
}
def doCommand(suffix)
{
suffix = fixupUrl(suffix)
// Make sure we have a current Digest challenge.
def authMap = getAuthMap()
if(!authMap?.realm || !authMap?.nonce)
{
authMap = fetchAuthHeader()
}
if(!authMap?.realm || !authMap?.nonce)
{
throw new Exception("Unable to obtain Digest authentication challenge")
}
def params = genParamsHeaders(suffix)
if(!params)
{
throw new Exception("Unable to generate Digest authentication header")
}
try
{
return _command(params)
}
catch(Exception e)
{
def resp = null
try
{
resp = e.getResponse()
}
catch(Exception ignored)
{
}
// Dahua may issue a new nonce and mark the old one stale.
// Get the new challenge and retry this request once.
if(resp?.status?.toInteger() == 401)
{
def header = null
try
{
header = resp?.getHeaders()?.getAt("www-authenticate")
}
catch(Exception ignored)
{
}
if(header)
{
logDebug("Digest re-authentication required: ${header}")
def newAuthMap = parseAuthHeader(header)
if(newAuthMap?.realm && newAuthMap?.nonce)
{
setAuthMap(newAuthMap)
params = genParamsHeaders(suffix)
if(params)
{
return _command(params)
}
}
}
}
throw e
}
}
def parseAuthHeader(authHeader)
{
def authMap = [:]
if(!authHeader)
{
return authMap
}
// Some Hubitat versions return the header as a list.
if(authHeader instanceof List)
{
authHeader = authHeader[0]
}
authHeader = authHeader.toString()
logDebug("Parsing Digest header: ${authHeader}")
// Hubitat may return the complete header including:
// "WWW-Authenticate: Digest ..."
authHeader = authHeader.replaceFirst("(?i)^\\s*WWW-Authenticate:\\s*", "").trim()
if(!authHeader.toLowerCase().startsWith("digest"))
{
return authMap
}
authHeader = authHeader.substring(6).trim()
// Parse comma-separated key="value" or key=value members.
def matcher = authHeader =~ /([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/
matcher.each
{
full, key, quotedValue, unquotedValue ->
def value = (quotedValue != null) ? quotedValue : unquotedValue
authMap[key.toLowerCase()] = value
}
// Dahua cameras used by this driver advertise qop=auth and MD5.
authMap.nc = 1
authMap.cnonce = java.util.UUID.randomUUID().toString().replaceAll('-', '').substring(0, 8)
return authMap
}
def genAuthDigest(suffix)
{
def authMap = getAuthMap()
if(!authMap?.realm || !authMap?.nonce)
{
return
}
def qop = authMap.qop ?: "auth"
def algorithm = authMap.algorithm ?: "MD5"
if(qop.toLowerCase() != "auth")
{
log.error "Unsupported Dahua Digest qop: ${qop}"
return
}
if(algorithm.toUpperCase() != "MD5")
{
log.error "Unsupported Dahua Digest algorithm: ${algorithm}"
return
}
// Digest nonce-count MUST be eight hexadecimal digits.
def ncNumber = authMap.nc ?: 1
def nc = Integer.toHexString(ncNumber).padLeft(8, '0')
def cnonce = authMap.cnonce
def ha1Raw = [username, authMap.realm, password].join(":")
def HA1 = md5(ha1Raw)
def ha2Raw = ["GET", suffix].join(":")
def HA2 = md5(ha2Raw)
def responseRaw =
[
HA1,
authMap.nonce,
nc,
cnonce,
qop,
HA2
].join(":")
def response = md5(responseRaw)
logDebug("Digest auth generated: qop=${qop}, nc=${nc}, uri=${suffix}, response=${response}")
def digest =
"Digest " +
"username=\"${username}\", " +
"realm=\"${authMap.realm}\", " +
"nonce=\"${authMap.nonce}\", " +
"uri=\"${suffix}\", " +
"qop=${qop}, " +
"nc=${nc}, " +
"cnonce=\"${cnonce}\", " +
"response=\"${response}\""
if(authMap.opaque)
{
digest += ", opaque=\"${authMap.opaque}\""
}
// Increment nonce-count only after constructing the request.
authMap.nc = ncNumber + 1
setAuthMap(authMap)
return digest
}
def genParamsHeaders(suffix)
{
suffix = fixupUrl(suffix)
def auth = genAuthDigest(suffix)
if(!auth)
{
log.error "genParamsHeaders: unable to generate Digest authorization"
return null
}
logDebug("genParamsHeaders: suffix=${suffix}")
logDebug("genParamsHeaders: auth generated=true")
def params =
[
uri: genBaseUri() + suffix,
headers:
[
Authorization: auth
]
]
return params
}
def _command(params)
{
// inner command, assuming token is valid
return httpExec("GET", params)
}
def deviceQuery(suffix)
{
def respData = doCommand(suffix)?.data
return strReaderToString(respData)
}
def setDevInfo(devInfo)
{
state.devInfo = devInfo
def alias = devInfo?.machineName + " Camera"
//device.setLabel(alias)
//device.setName(alias)
}
def getDevInfo()
{
return state.devInfo
}
def queryDeviceInfo()
{
try
{
def machineName = deviceQuery("/magicBox.cgi?action=getMachineName")?.split("=")?.getAt(1)
def serialNo = deviceQuery("/magicBox.cgi?action=getSerialNo")?.split("=")?.getAt(1)
def deviceType = deviceQuery("/magicBox.cgi?action=getDeviceType")?.split("=")?.getAt(1)
def devInfo = [machineName: machineName, serialNo: serialNo, deviceType: deviceType]
//logDebug(devInfo)
return devInfo
}
catch(Exception e)
{
log.error "queryDeviceInfo() failed: ${e}"
}
}
def openStream()
{
// references:
//https://github.com/tchellomello/python-amcrest/issues/137#issuecomment-677930804
//https://github.com/dchesterton/amcrest2mqtt/blob/main/src/amcrest2mqtt.py#L429
//https://github.com/GeorgeIoak/Amcrest_AD110_Button/blob/main/camera-events.py#L107
//https://github.com/tchellomello/python-amcrest/issues/151#issuecomment-1057483008
//https://github.com/rroller/dahua/blob/main/custom_components/dahua/__init__.py#L343
def codes = []
if (enableAll) {
codes = ["All"]
} else {
if (enableSmartMotionHuman) codes << "SmartMotionHuman"
if (enableSmartMotionVehicle) codes << "SmartMotionVehicle"
if (enableVideoMotion) codes << "VideoMotion"
if (enableVideoLoss) codes << "VideoLoss"
if (enableVideoBlind) codes << "VideoBlind"
if (enableAlarmLocal) codes << "AlarmLocal"
if (enableMDResult) codes << "MDResult"
if (enableCrossLineDetection) codes << "CrossLineDetection"
if (enableCrossRegionDetection) codes << "CrossRegionDetection"
if (enableLeftDetection) codes << "LeftDetection"
if (enableTakenAwayDetection) codes << "TakenAwayDetection"
if (enableVideoAbnormalDetection) codes << "VideoAbnormalDetection"
if (enableAudioMutation) codes << "AudioMutation"
if (enableAudioAnomaly) codes << "AudioAnomaly"
if (enableVideoUnFocus) codes << "VideoUnFocus"
if (enableWanderDetection) codes << "WanderDetection"
if (enableRioterDetection) codes << "RioterDetection"
if (enableParkingDetection) codes << "ParkingDetection"
if (enableMoveDetection) codes << "MoveDetection"
if (enableCrowdDetection) codes << "CrowdDetection"
if (enableNTPAdjustTime) codes << "NTPAdjustTime"
if (enableTimeChange) codes << "TimeChange"
if (enableRtspSession) codes << "Rtsp-Session"
}
def eventNames = codes ? codes.join(",") : "All"
def codeParam = "${eventNames}"
def suffix = fixupUrl("/eventManager.cgi?action=attach&codes=[${codeParam}]")
fetchAuthHeader()
def params = genParamsHeaders(suffix)
if (params == null) { return }
params.rawData = true
params.pingInterval = 1
params.readTimeout = 300
clearEvent()
def targetUri = genBaseUri() + suffix
interfaces.eventStream.connect(targetUri, params)
logDebug("eventManager: attached with ${eventNames} codes")
}
def parse(String message)
{
//logDebug("parse: ${message}")
if(message.toLowerCase()?.contains("myboundary"))
{
event = getEvent()
//logDebug("event complete:\r\n ${event}")
if(event?.toLowerCase()?.contains("code"))
{
//logDebug("parse: ${message}")
processCodeMessage("Code=" + event.split("Code=")[1])
}
clearEvent()
}
else
{
addEventLine(message)
}
}
def addEventLine(line)
{
def event = getEvent()
if(null == event) { clearEvent() }
event += line
setVolatileState("event", event)
}
def getEvent()
{
return getVolatileState("event")
}
def clearEvent()
{
setVolatileState("event", "")
}
def processCodeMessage(message)
{
message = message.split(";")
def slurper = new groovy.json.JsonSlurper()
def codeMap = [:]
def member
message.each
{
try
{
member = it.split('data(:|=)', 2)
if(member.size() == 2)
{
// some firmware versions report as "data:" and some "data=",
// ...so, support both
member = it.split('(:|=)', 2)
// this is a data entry, presumably including json values
codeMap += [(member[0]): slurper.parseText(member[1])]
// we successfully processed this entry, so don't do it again
return
}
// after any special cases (above), parse what is left
member = it.split("=")
if(member.size() == 2)
{
// this is a typical entry
codeMap += [(member[0]): member[1]]
}
}
catch(Exception e)
{
log.error "processCodeMessage() error: ${e}\r\nline contents: ${it}"
}
}
logDebug(codeMap)
switch(codeMap?.Code)
{
case "_DoTalkAction_":
handleDoorBellPress(codeMap)
break
case "SmartMotionHuman":
handleSmartMotionHuman(codeMap)
break
case "SmartMotionVehicle":
handleSmartMotionVehicle(codeMap)
break
case "VideoMotion":
handleSimpleMotion(codeMap)
break
case "VideoLoss":
handleVideoLoss(codeMap)
break
case "VideoBlind":
handleVideoBlind(codeMap)
break
case "AlarmLocal":
handleAlarmLocal(codeMap)
break
case "MDResult":
handleMDResult(codeMap)
break
case "CrossRegionDetection":
handleCrossRegion(codeMap)
break
case "CrossLineDetection":
handleCrossLine(codeMap)
break
case "LeftDetection":
handleLeftDetection(codeMap)
break
case "TakenAwayDetection":
handleTakenAwayDetection(codeMap)
break
case "VideoAbnormalDetection":
handleVideoAbnormalDetection(codeMap)
break
case "AudioMutation":
handleAudioMutation(codeMap)
break
case "AudioAnomaly":
handleAudioAnomaly(codeMap)
break
case "VideoUnFocus":
handleVideoUnFocus(codeMap)
break
case "WanderDetection":
handleWanderDetection(codeMap)
break
case "RioterDetection":
handleRioterDetection(codeMap)
break
case "ParkingDetection":
handleParkingDetection(codeMap)
break
case "MoveDetection":
handleMoveDetection(codeMap)
break
case "CrowdDetection":
handleCrowdDetection(codeMap)
break
case "NTPAdjustTime":
handleNTPAdjustTime(codeMap)
break
case "TimeChange":
handleTimeChange(codeMap)
break
case "Rtsp-Session":
handleRtspSession(codeMap)
break
}
return codeMap
}
def handleDoorBellPress(codeMap)
{
def devDetails =
[
alias: "button",
devType: "doorbell",
childType: "Generic Component Button Controller"
]
if(codeMap.data?.Action == "Invite")
{
logDebug("Doorbell button pressed")
getChild(devDetails)?.push(1)
}
}
def componentPush(ch, button = 1)
{
ch.sendEvent(name: "pushed", value: 1, isStateChange: true)
}
def handleSimpleMotion(codeMap)
{
logDebug("Motion status: ${codeMap.action}")
sendEvent(name: "motion", value: (codeMap.action == "Start") ? "active" : "inactive")
}
def handleCrossRegion(codeMap)
{
def typeName = "waiting"
def regionName = "waiting"
if(codeMap.action == "Start")
{
regionName = codeMap.data?.Name
logDebug("Cross region detected: ${regionName}")
typeName = codeMap.data?.Object?.ObjectType
logDebug("Object detected: ${typeName}")
}
sendEvent(name: "crossRegionDetected", value: regionName)
sendEvent(name: "smartDetectType", value: typeName)
}
def handleCrossLine(codeMap)
{
def lineName = "waiting"
if(codeMap.action == "Start")
{
lineName = codeMap.data?.Name
logDebug("Cross line detected: ${lineName}")
}
sendEvent(name: "crossLineDetected", value: lineName)
}
def handleSmartMotionHuman(codeMap)
{
def status = "waiting"
def confidence = "unknown"
if(codeMap.action == "Start")
{
status = "detected"
confidence = codeMap.data?.Confidence ?: "unknown"
logDebug("Smart motion human detected with confidence: ${confidence}%")
}
sendEvent(name: "smartMotionHuman", value: status)
sendEvent(name: "smartMotionHumanConfidence", value: confidence)
}
def handleSmartMotionVehicle(codeMap)
{
def status = "waiting"
def confidence = "unknown"
if(codeMap.action == "Start")
{
status = "detected"
confidence = codeMap.data?.Confidence ?: "unknown"
logDebug("Smart motion vehicle detected with confidence: ${confidence}%")
}
sendEvent(name: "smartMotionVehicle", value: status)
sendEvent(name: "smartMotionVehicleConfidence", value: confidence)
}
def handleVideoLoss(codeMap)
{
def status = (codeMap.action == "Start") ? "lost" : "recovered"
logDebug("Video loss status: ${status}")
sendEvent(name: "videoLoss", value: status)
}
def handleVideoBlind(codeMap)
{
def status = (codeMap.action == "Start") ? "blinded" : "normal"
logDebug("Video blind status: ${status}")
sendEvent(name: "videoBlind", value: status)
}
def handleAlarmLocal(codeMap)
{
def alarmType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "active" : "inactive"
logDebug("Local alarm ${alarmType} status: ${status}")
sendEvent(name: "alarmLocal", value: status)
sendEvent(name: "alarmLocalType", value: alarmType)
}
def handleMDResult(codeMap)
{
def result = codeMap.data?.Result ?: "unknown"
logDebug("Motion detection result: ${result}")
sendEvent(name: "motionDetectionResult", value: result)
}
def handleLeftDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def objectInfo = codeMap.data?.Object ?: "unknown"
logDebug("Left detection status: ${status}, object: ${objectInfo}")
sendEvent(name: "leftDetection", value: status)
sendEvent(name: "leftDetectionObject", value: objectInfo)
}
def handleTakenAwayDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def objectInfo = codeMap.data?.Object ?: "unknown"
logDebug("Taken away detection status: ${status}, object: ${objectInfo}")
sendEvent(name: "takenAwayDetection", value: status)
sendEvent(name: "takenAwayDetectionObject", value: objectInfo)
}
def handleVideoAbnormalDetection(codeMap)
{
def abnormalType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "abnormal" : "normal"
logDebug("Video abnormal detection type: ${abnormalType}, status: ${status}")
sendEvent(name: "videoAbnormalDetection", value: status)
sendEvent(name: "videoAbnormalType", value: abnormalType)
}
def handleAudioMutation(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def audioLevel = codeMap.data?.Level ?: "unknown"
logDebug("Audio mutation status: ${status}, level: ${audioLevel}")
sendEvent(name: "audioMutation", value: status)
sendEvent(name: "audioMutationLevel", value: audioLevel)
}
def handleAudioAnomaly(codeMap)
{
def anomalyType = codeMap.data?.Type ?: "unknown"
def status = (codeMap.action == "Start") ? "anomaly" : "normal"
logDebug("Audio anomaly type: ${anomalyType}, status: ${status}")
sendEvent(name: "audioAnomaly", value: status)
sendEvent(name: "audioAnomalyType", value: anomalyType)
}
def handleVideoUnFocus(codeMap)
{
def status = (codeMap.action == "Start") ? "unfocused" : "focused"
def focusLevel = codeMap.data?.FocusLevel ?: "unknown"
logDebug("Video focus status: ${status}, level: ${focusLevel}")
sendEvent(name: "videoUnFocus", value: status)
sendEvent(name: "videoFocusLevel", value: focusLevel)
}
def handleWanderDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def regionInfo = codeMap.data?.Region ?: "unknown"
logDebug("Wander detection status: ${status}, region: ${regionInfo}")
sendEvent(name: "wanderDetection", value: status)
sendEvent(name: "wanderDetectionRegion", value: regionInfo)
}
def handleRioterDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def personCount = codeMap.data?.Count ?: "unknown"
logDebug("Rioter detection status: ${status}, count: ${personCount}")
sendEvent(name: "rioterDetection", value: status)
sendEvent(name: "rioterDetectionCount", value: personCount)
}
def handleParkingDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def parkingInfo = codeMap.data?.Info ?: "unknown"
logDebug("Parking detection status: ${status}, info: ${parkingInfo}")
sendEvent(name: "parkingDetection", value: status)
sendEvent(name: "parkingDetectionInfo", value: parkingInfo)
}
def handleMoveDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def moveInfo = codeMap.data?.Info ?: "unknown"
logDebug("Move detection status: ${status}, info: ${moveInfo}")
sendEvent(name: "moveDetection", value: status)
sendEvent(name: "moveDetectionInfo", value: moveInfo)
}
def handleCrowdDetection(codeMap)
{
def status = (codeMap.action == "Start") ? "detected" : "cleared"
def crowdDensity = codeMap.data?.Density ?: "unknown"
logDebug("Crowd detection status: ${status}, density: ${crowdDensity}")
sendEvent(name: "crowdDetection", value: status)
sendEvent(name: "crowdDetectionDensity", value: crowdDensity)
}
def handleNTPAdjustTime(codeMap)
{
def timeInfo = codeMap.data?.Time ?: "unknown"
logDebug("NTP time adjustment: ${timeInfo}")
sendEvent(name: "ntpAdjustTime", value: timeInfo)
}
def handleTimeChange(codeMap)
{
def timeInfo = codeMap.data?.Time ?: "unknown"
logDebug("Time change event: ${timeInfo}")
sendEvent(name: "timeChange", value: timeInfo)
}
def handleRtspSession(codeMap)
{
def sessionInfo = codeMap.data?.Session ?: "unknown"
def status = codeMap.action ?: "unknown"
logDebug("RTSP session ${sessionInfo}: ${status}")
sendEvent(name: "rtspSession", value: status)
sendEvent(name: "rtspSessionInfo", value: sessionInfo)
}
def getChild(Map devDetails)
{
if(!devDetails) { return }
// devDetails definition:
// alias = "friendly" name of device to append to main device name from Amcrest
// devType = type of device, like doorbell
// childType = driver name for child
// childNS = namespace for driver, or null for "hubitat"
def childDni = device.getDeviceNetworkId() + "-${devDetails.devType}"
def devLabel = device.getName() + " ${devDetails.alias}"
def props =
[
name: devLabel,
label: devLabel,
isComponent: false
]
def ch = getChildDevice(childDni)
if(!ch)
{
ch = addChildDevice(devDetails.childNS ?: "hubitat", devDetails.childType, childDni, props)
ch?.setLabel(devLabel)
ch?.setName(devLabel)
}
return ch
}
def genBaseUri()
{
return "http://" + ipAddress + ":" + port + "/cgi-bin"
}
def genAuthUri()
{
return "http://${username}:${password}@" + ipAddress + ":" + port + "/cgi-bin"
}
def fixupUrl(url)
{
return url.replace('[', "%5B").replace(']', "%5D")
}
def genParamsFull(suffix, withAuth = true)
{
suffix = fixupUrl(suffix)
def targetUri = (withAuth ? genAuthUri() : genBaseUri()) + suffix
def params = [uri: targetUri]
return params
}
def addDigestMember(digest, name, value, last = false)
{
if(null == digest)
{
digest = "Digest "
}
def eol = !last ? '", ' : '"'
def newTerm = "${name}=\"" + value + eol
if(["nc", "qop"].contains(name)) { newTerm = newTerm.replaceAll('"', '') }
digest += newTerm
return digest
}
def httpExec(operation, params)
{
def result = null
//logDebug("httpExec(${operation}, ${params})")
def httpClosure =
{ resp ->
result = resp
//logDebug("result.data = ${result.data}")
}
def httpOp
switch(operation)
{
case "POST":
httpOp = this.delegate.&httpPost
break
case "GET":
httpOp = this.delegate.&httpGet
break
}
httpOp(params, httpClosure)
return result
}
//////////////////////////////////////
// volatile state
//////////////////////////////////////
import groovy.transform.Field
@Field static volatileState = [:].asSynchronized()
def vsUid()
{
return device?.getDeviceNetworkId() ?: app.getId()
}
def setVolatileState(name, value)
{
def tempState = volatileState[vsUid()] ?: [:]
tempState.putAt(name, value)
volatileState.putAt(vsUid(), tempState)
return volatileState
}
def getVolatileState(name)
{
return volatileState.getAt(vsUid())?.getAt(name)
}
def syncWait(data)
{
// set up for checking 5x per second
setVolatileState("syncWaitDetails", [waitSetter: data.waitSetter, retryCount: data.timeoutSec * 5])
}
def doWait()
{
def wtDetails = getVolatileState("syncWaitDetails")
// check every 200 ms whether the wait was cleared...
if(wtDetails?.waitSetter == "")
{
return
}
// ...or throw an exception if we ran out of tries
if(wtDetails?.retryCount == 0)
{
throw new Exception("wait timed out: ${wtDetails?.waitSetter}")
}
wtDetails.putAt("retryCount", wtDetails.getAt("retryCount") - 1)
setVolatileState("syncWaitDetails", wtDetails)
pauseExecution(200)
doWait()
}
def clearWait(data)
{
def wtDetails = getVolatileState("syncWaitDetails")
if(data.waitSetter == wtDetails?.getAt("waitSetter"))
{
syncWait([waitSetter: "", timeoutSec: 0])
}
}
def clearAllWaits()
{
syncWait([waitSetter: "", timeoutSec: 0])
}
//////////////////////////////////////
// EventSocket handling
//////////////////////////////////////
def reinitialize()
{
unschedule(initialize)
// thanks ogiewon for the example
// first delay is 2 seconds, doubles every time
def delayCalc = (state.reconnectDelay ?: 1) * 2
// upper limit is 600s
def reconnectDelay = delayCalc <= 600 ? delayCalc : 600
state.reconnectDelay = reconnectDelay
runIn(reconnectDelay, initialize)
}
def eventStreamStatus(String message)
{
logDebug("eventStreamStatus: ${message}")
// thanks for the idea: https://community.hubitat.com/t/websocket-client/11843/15
if(message?.toLowerCase()?.contains("start"))
{
state.reconnectDelay = 1
setWasExpectedClose(false)
setStreamActive(true)
return
}
if(message?.toLowerCase()?.contains("stop"))
{
clearWait([waitSetter: "closeStream"])
setStreamActive(false)
streamStopErrorActions()
}
if(message?.toLowerCase()?.contains("error"))
{
streamStopErrorActions()
}
}
def streamStopErrorActions()
{
if(getWasExpectedClose())
{
setWasExpectedClose(false)
return
}
reinitialize()
}
def setWasExpectedClose(wasExpected)
{
//state.wasExpectedClose = wasExpected
setVolatileState("wasExpectedClose", wasExpected)
}
def getWasExpectedClose()
{
//def wec = state.wasExpectedClose
def wec = getVolatileState("wasExpectedClose")
return (null != wec) ? wec : true
}
def setStreamActive(active)
{
setVolatileState("streamActive", active)
}
def getStreamActive()
{
def active = getVolatileState("streamActive")
return (active != null) ? active : false
}
def closeStream()
{
try
{
setWasExpectedClose(true)
if(getStreamActive())
{
// anticipate that this wait will be cleared in eventStreamStatus
syncWait([waitSetter: "closeStream", timeoutSec: 60])
interfaces.eventStream.close()
doWait()
}
}
catch (Exception e)
{
log.warn e.message
}
}
//////////////////////////////////////
// File system operations
//////////////////////////////////////
def fileName()
{
return device.getDeviceNetworkId()
}
def writeImageToFile(byte[] image)
{
if(null == image) { return }
uploadHubFile(fileName(), image)
}
def deleteImageFile(fileName = fileName())
{
deleteHubFile(fileName)
}
//////////////////////////////////////
// Random helpers and completion
//////////////////////////////////////
def strReaderToString(reader)
{
if(null == reader) { return }
// https://stackoverflow.com/a/17751100
java.lang.StringBuilder builder = new java.lang.StringBuilder()
// non-zero value so we read at least once
int charsRead = 1
char[] chars = new char[1024]
while(charsRead > 0)
{
charsRead = reader.read(chars, 0, chars.length)
if(charsRead>0) { builder.append(chars, 0, charsRead) }
}
def str = builder.toString()
return str
}
String md5(String strToSign)
{
if(null == strToSign) { return }
try
{
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("MD5")
def md5_bytes = digest.digest(strToSign.getBytes("UTF-8"))
return hubitat.helper.HexUtils.byteArrayToHexString(md5_bytes)?.toLowerCase()
}
catch(Exception e)
{
log.error "md5(${strToSign}) failed: ${e}"
}
}
def componentDoubleTap(ch, button = 1)
{
//no_op
}
def componentHold(ch, button = 1)
{
//no_op
}
def componentRelease(ch, button = 1)
{
//no_op
}