Dahua and Amcrest integration for cameras and doorbells

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've created a PR: Update cameraDriver with enhanced event configuration by mbrownnycnyc · Pull Request #5 · tomwpublic/hubitat_dahua · GitHub

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 
}