In my last tutorial, Unifi Protect Camera API I showed how to create a proxy server to enable a Snapshot URL for Unifi Protect cameras using the Unifi Protect API.
Also, in my previous Unifi Protect Camera API video, I also showed an application for the use of those snapshots with a custom web server that stores consecutive snapshots from a camera as a timestamped motion event managed by a Hubitat Elevation hub. Note that all of the camera snapshot events are stored on the web server and not on the Hubitat Elevation hub. In this tutorial we are going to learn how to configure this application.
Here is an example of the application web page listing my FrontDoor camera and several timestamped motion events. Each motion event can be defined to capture a user defined number of still images in succession. In my example below, I am capturing 8 frames as the result of motion at my front door.
I will be using a Philips Hue Outside Motion Detector as my device to detect motion events. More on that later.
We are going to begin by configuring the Camera Motion Capture (CMC) web server. This web server can be configured as a container, a virtual machine or on a physical machine such as a raspberry Pi.
I am going to create an Incus container and I am projecting that Incus container on to my IoT VLAN because that is where my cameras are located. If you have been watching my channel, you might want to use the “bridgeprofile”. My instructions rely on an Ubuntu 24.04 operating system instance using php v8.3.
incus launch images:ubuntu/24.04 CMC -p default -p vlan30 -c boot.autostart=true
Connect to the console of the new container:
incus shell CMC
Accept all of the updates on the new OS instance.
apt update && apt upgrade -y
As a best practice, add yourself a user account.
adduser scott
Put the new user in the sudo group.
usermod -aG sudo scott
Switch to the new user account.
su - scott
Install the apache web server, php and the php library for Apache since the Github project we are using requires php.
sudo apt install apache2 git php libapache2-mod-php net-tools openssh-server -y
Ubuntu uses php v8.3. To verify:
php -v
Enable the php module in case it is not enabled.
sudo a2enmod php8.3
Move to the Apache web server root folder.
cd /var/www/html
Clone Michael Barones CMC project into the web root.
sudo git clone https://github.com/michaelbarone/CameraMotionCapture.git
Change the ownership and file protections of the files to be owned properly by www-data:
sudo chown -R www-data:www-data /var/www/html/CameraMotionCapture
sudo find /var/www/html/CameraMotionCapture -type d -exec chmod 755 {} \;
sudo find /var/www/html/CameraMotionCapture -type f -exec chmod 644 {} \;
Restart the Apache web server.
sudo systemctl restart apache2
Find out the address of device eth0 in your incus container.
ifconfig
To access your shiny new web server, enter the address your just found with the “CameraMotionCapture” root at the end as follows (using your address).
The page appears to be blank because we have not configured anything to store any snapshot events on the new server.
On your Hubitat Elevation web management interface head over to the “Drivers Code” section.
Choose the “+ Add driver” option.
Copy and paste the following code for the CMC Parent Driver into the new driver editor.
/**
* Camera Motion Capture
*
* 2020 mbarone
*
* 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.
*
* To use the video camera motion service, you must install and configure this on a local webserver
* https://github.com/michaelbarone/CameraMotionCapture
*
* Change History:
*
* Date Who What
* ---- --- ----
* 20-11-4 mbarone initial release
* 20-11-6 mbarone added lastMotion attributes and versionCheck for webserver version
* 20-11-25 mbarone added 1s timeout to httpPost so hubitat doesnt hang while processing all camera snapshots
*/
def setVersion(){
state.name = "Camera Motion Capture"
state.version = "0.0.3"
}
metadata {
definition (name: "Camera Motion Capture", namespace: "mbarone", author: "mbarone", importUrl: "https://raw.githubusercontent.com/michaelbarone/hubitat/master/drivers/cameraMotionCapture.groovy") {
capability "Actuator"
}
preferences {
input name: "webServerURL", type: "text", title: "Web Server URL", description: "Full path to the CameraMotionCapture webapp. ie: http://webserverIP/CameraMotionCapture/", required: true
input name: "captureCount", type: "number", title: "Capture Frame Count", defaultValue: 5, description: "How many images do you want to save for each motion event"
input name: "captureDelay", type: "number", title: "Delay Between Frames", defaultValue: 2, description: "How many seconds between image captures for each motion event"
input name: "username", type: "text", title: "Username", description: "Username if cameras require authentication"
input name: "password", type: "text", title: "Password", description: "Password if cameras require authentication"
input name: "daysToKeepEvents", type: "number", title: "Days to Keep Events", defaultValue: 10, description: "How many days do you want events to be saved for. To disable, set to 0"
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
attribute "Details","string"
attribute "lastMotionDevice","string"
attribute "lastMotionTime","string"
attribute "iFrame", "text"
command "addCamera", [[name:"Camera Name*",type:"STRING",description:"This Cannot Be changed without removing and re-adding the camera"]]
command "clearOldEvents", [[name:"daysToKeep*",type:"NUMBER"]]
command "checkForWebserverUpdates"
}
def logsOff(){
log.warn "debug logging disabled..."
device.updateSetting("logEnable",[value:"false",type:"bool"])
}
def installed() {
}
def updated() {
// cancel schedules
unschedule()
// set schedule to clearOldEvents
if(daysToKeepEvents>0){
schedule("0 5 0 1/1 * ? *", clearOldEvents)
}
// set schedule to check webserver for updates
schedule("0 22 1/12 ? * * *", checkForWebserverUpdates)
// run check now for webserver updates
checkForWebserverUpdates()
sendEvent(name: "iFrame", value: "<div style='height: 100%; width: 100%'><iframe src='${webServerURL}' style='height: 100%; width:100%; border: none;'></iframe><div>")
clearDetails()
if (logEnable) {
log.warn "debug logging enabled..."
runIn(1800,logsOff)
}
}
def clearDetails(){
sendEvent(name:"Details", value:"Running Normally.")
}
def getWebServerURL(){
return "${webServerURL}"
}
def addCamera(camera){
if (logEnable) log.debug "Creating Child Device "+camera
foundChildDevice = null
foundChildDevice = getChildDevice("${device.deviceNetworkId}-${camera}")
if(foundChildDevice=="" || foundChildDevice==null){
if (logEnable) log.debug "createChildDevice: Creating Child Device 'CMC - ${camera}'"
try {
def deviceHandlerName = "Camera Motion Capture Child"
addChildDevice(deviceHandlerName,
"${device.deviceNetworkId}-${camera}",
[
completedSetup: true,
label: "CMC - ${camera}",
isComponent: false,
name: "CMC - ${camera}",
]
)
sendEvent(name:"Details", value:"Child device created! Refresh this page.")
unschedule(clearDetails)
runIn(300,clearDetails)
getChildDevice("${device.deviceNetworkId}-${camera}").updateDataValue("cameraName",camera);
}
catch (e) {
log.error "Child device creation failed with error = ${e}"
sendEvent(name:"Details", value:"Child device creation failed. Please make sure that the '${deviceHandlerName}' is installed and published.", displayed: true)
}
} else {
if (logEnable) log.debug "createChildDevice: Child Device 'CMC - ${camera}' found! Skipping"
}
}
def captureEvent(cameraName,cameraURL,cCount=null,cDelay=null,uname=null,pword=null){
sendEvent(name:"lastMotionDevice", value:cameraName)
sendEvent(name:"lastMotionTime", value:new Date())
def str = webServerURL
if (str != null && str.length() > 0 && str.charAt(str.length() - 1) == '/') {
str = str.substring(0, str.length() - 1);
}
def params = [uri: "${str}/data/motionCapture.php",
contentType: "application/x-www-form-urlencoded",
timeout: 1
]
params['body'] = ["cameraName":cameraName,
"cameraUrl":cameraURL
]
if(cCount && cCount != "" && cCount != null){
params['body'].put("captureCount", cCount)
}
if(cCount == null && (captureCount != null && captureCount != "")){
params['body'].put("captureCount", captureCount)
}
if(cDelay && cDelay != "" && cDelay != null){
params['body'].put("captureDelay", cDelay)
}
if(cDelay == null && (captureDelay != null && captureDelay != "")){
params['body'].put("captureDelay", captureDelay)
}
if(uname && uname != "" && uname != null){
params['body'].put("username", uname)
}
if(uname == null && (username != null && username != "")){
params['body'].put("username", username)
}
if(pword && pword != "" && pword != null){
params['body'].put("password", pword)
}
if(pword == null && (password != null && password != "")){
params['body'].put("password", password)
}
if (logEnable) log.debug "attempting post:"
if (logEnable) log.debug params
try {
httpPost(params) {}
//asynchttpPost("postCallback", params)
} catch (e) {
// due to long execution time of motionCapture, this post will timeout and always give an error. to troubleshoot, uncomment the below log.error
//log.error "something went wrong on captureEvent: $e"
}
}
def clearOldEvents(daysToKeep=daysToKeepEvents, camera=null){
def str = webServerURL
if (str != null && str.length() > 0 && str.charAt(str.length() - 1) == '/') {
str = str.substring(0, str.length() - 1);
}
def params = [uri: "${str}/data/clearOldEvents.php",
contentType: "application/x-www-form-urlencoded",
timeout: 1
]
params['body'] = ["daysToKeep":daysToKeep]
if(camera!=null && camera!=""){
params['body'].put("cameraName", camera)
}
if (logEnable) log.debug "attempting post:"
if (logEnable) log.debug params
try {
httpPost(params) {}
//asynchttpPost('postCallback', params)
} catch (e) {
//log.error "something went wrong on clearOldEvents: $e"
}
}
def postCallback(response, data) {
if (logEnable) log.debug "status of post call is: ${response.status}"
}
def checkForWebserverUpdates(){
def str = webServerURL
if (str != null && str.length() > 0 && str.charAt(str.length() - 1) == '/') {
str = str.substring(0, str.length() - 1);
}
if (logEnable) log.debug "attempting Version Get request:"
def url = "${str}/data/versionCheck.php";
try {
httpGet(url) { resp ->
if (resp.data){
if (logEnable) log.debug "Version Check = ${resp.data} - last check: "+new Date()
state.webserver = "${resp.data} - last check: "+new Date()
} else {
state.webserver = "Could not check webserver version"
}
}
} catch (e) {
log.error "something went wrong on checkForWebserverUpdates: $e"
}
}
After you have pasted the code, hit the “Save” button. Return back to the “Drivers Code” screen and choose the “+ Add driver” option again. Copy and paste the code for the CMC Child driver below into the editor.
/**
* Camera Motion Capture Child
*
*
* 2020 mbarone
*
* 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.
*
* To use the video camera motion service, you must install and configure this on a local webserver
* https://github.com/michaelbarone/CameraMotionCapture
*
* Change History:
*
* Date Who What
* ---- --- ----
* 20-11-4 mbarone initial release
* 20-11-25 mbarone added additional logging
*/
def setVersion(){
state.name = "Camera Motion Capture Child"
state.version = "0.0.2"
}
metadata {
definition (name: "Camera Motion Capture Child", namespace: "mbarone", author: "mbarone", importUrl: "https://raw.githubusercontent.com/michaelbarone/hubitat/master/drivers/cameraMotionCaptureChild.groovy") {
capability "Actuator"
}
preferences {
input name: "cameraURL", type: "text", title: "Camera Image URL", description: "The direct URL to get a snapshot from this camera", required: true
input name: "captureCount", type: "number", title: "Capture Frame Count", description: "How many images do you want to save for each motion event (if empty, the value from the parent device will be used)"
input name: "captureDelay", type: "number", title: "Delay Between Frames", description: "How many seconds between image captures for each motion event (if empty, the value from the parent device will be used)"
input name: "username", type: "text", title: "Username", description: "Username if this camera requires authentication (if empty, the value from the parent device will be used)"
input name: "password", type: "text", title: "Password", description: "Password if this camera requires authentication (if empty, the value from the parent device will be used)"
input("logEnable", "bool", title: "Enable Debug Logging?:", required: true)
}
command "captureEvent", [[name:"captureCount",type:"NUMBER"],[name:"captureDelay",type:"NUMBER"]]
command "clearOldEvents", [[name:"daysToKeep*",type:"NUMBER",description:"Setting this to 0 will completely remove this camera from the server"]]
}
def installed() {
initialize()
}
def updated() {
def str = parent.getWebServerURL()
if (str != null && str.length() > 0 && str.charAt(str.length() - 1) == '/') {
str = str.substring(0, str.length() - 1);
}
def cameraName = getDataValue("cameraName")
if(cameraName != null){
state.lastMotionEventImage = str+"/images/"+getDataValue("cameraName")+"/mostRecent.jpg"
}
initialize()
if (logEnable) {
log.warn "debug logging enabled..."
runIn(1800,logsOff)
}
}
def logsOff(){
log.warn "debug logging disabled..."
device.updateSetting("logEnable",[value:"false",type:"bool"])
}
def initialize() {
}
def captureEvent(cCount=captureCount,cDelay=captureDelay){
def cameraName = getDataValue("cameraName")
if (logEnable) log.debug "capture event on: ${cameraName}"
parent.captureEvent(cameraName,cameraURL,cCount,cDelay,username,password)
}
def clearOldEvents(daysToKeep){
def cameraName = getDataValue("cameraName")
parent.clearOldEvents(daysToKeep,cameraName)
}
Click the Save option again and return back to the drivers code section and you should see both drivers as shown in the video and below.
Head up to the the “Devices” menu and select “+ Add Device”.
Choose to add a “Virtual Device” and in the “select device type” field type “Camera” and scroll down to the “User” driver named “Camera Motion Capture”.
Click through to name your Camera Motion Capture device and give it a name that makes sense to you and click Next.
Once the device is created, go back to “devices” and locate it and click on it to open it. Go to the preferences tab at the top of the page and then type in your web server URL which is the same that you used to get to your web server previously. In my case:
Make sure you have the capitilization and spelling of the web root typed properly. Then change the default capture frame count and the delay between frames. My recommendation is 8 frames and 1 second delay between frames. You can also enter the username and password for your cameras here if desired, although that can be overridden at the camera level if needed.
When you are done editing, click “Save and Close”.
Go back into the device again and on the commands page, add a camera by typing its name and that will create a “CMC child” device with the name of the camera.
Be sure to click “Run” to add the camera and then you can exit that input form. Back at the devices menu, refresh your browser and you will now see that the parent device has a child device under it.
Now click the child and go into the “Preferences” screen and enter the Camera Image URL which is the URL you use to get a snapshot from your camera. This will differ according to the manufacturer and model of the camera that you are using.
On this screen you can also override the capture frame count, the delay between frames and the username and password that is set at the parent level. Once you are finished with your changes, click “Save and Close”.
Now go to “Apps” on the left menu and select Add built-in App. We want to search for and add “Rule Machine”. Select Create New Rule.
Name your rule and click continue.
Select Trigger Events.
Create a new trigger event.
Create a Motion trigger event. I am using a Philips Hue motion detector as my trigger.
Once you have selected your motion detector, you should see this screen and you can click “Done with this Trigger Event”.
On the next screen, click “Done with Trigger Events”.
Select Actions to run.
Choose Create New Action.
Choose the option containing “Run Custom Action”.
Select “Run Custom Action” and specify the capability of the action device to be “Actuator”. Remember how the driver was of type “Actuator”?
Choose the actuator which should be the name of your child device and then select the custom command to be “captureEvent”.
Click Done with Action and then Done with actions on the next screen. Finally, click “Install Rule”.
When all is done, your rule should resemble something like this.
What this means is that when the Outside Motion Detector device sees motion, it will capture an event on the FrontDoor. This means it will take six camera snapshots one second apart on the front door camera and save them to the web server as a time stamped event. You will need to do a refresh on the web page to see the first motion event.
Subsequent motion events are displayed immediately and each event will have the six time lapsed frames.
I added an addendum to the tutorial talking about the effectiveness of using a camera for motion detection. I prefer to use an actual motion detector unless I have an AI camera to minimize false positives.
The motion detectors that I recommend are the Philips Hue Outside Motion Detector and the Ecolink PIR Detector for indoor applications.
There are many ways to detect motion and you will have to experiment with what works best for you.