2023-02-01.html (31677B)
1 <div class="wide64"> 2 <a href="/static/media/1920/dwrz_20221023T155810_edit.jpg" > 3 <img class="img-center" 4 src="/static/media/720/dwrz_20221023T155810_edit.jpg"> 5 </a> 6 <p> 7 I live in a ground floor apartment, and want to keep an eye on the space and 8 the cats when I am away. A search for security camera systems that offered 9 timelapse recording, livestream viewing, and instrusion notifications was 10 unsatisfactory. Most consumer systems were either too expensive, had security 11 shortcomings, or lacked sufficient user control. As a result, I endend up 12 assembling a system that meets most of my needs. 13 </p> 14 <p> 15 The solution I've settled on uses three 16 <a href="https://www.raspberrypi.org/"> Raspberry Pi</a>'s, each acting as a 17 server connected to a <a href="https://www.webcamerausb.com/">generic fisheye 18 USB camera</a>. A mix of open-source software and scripts provides a 19 password protected livestream served over HTTPS, timelapse recording, motion 20 and object detection, notifications, and remote storage. 21 </p> 22 <p> 23 The cameras come with limitations and vulnerabilties, some shared with 24 consumer solutions, others unique to a home-brewed setup. But for my needs, 25 they have worked well, and I have appreciate their modularity, the ability 26 to repurpose hardware, and full control over the system and the data that it 27 generates. 28 </p> 29 <p> 30 I was surprised by how quickly I could stand up a system of such disparate 31 parts — in terms of hardware and software — while writing little code of my 32 own. Putting these cameras together seemed to confirm some of the 33 <a href="https://en.wikipedia.org/wiki/Unix_philosophy#Origin">UNIX</a> 34 principles. It's been possible to connect components with just a few scripts 35 as glue. 36 </p> 37 <p> 38 Any code referenced on this page should be available here: 39 <a href="https://code.dwrz.net/vigil/">https://code.dwrz.net/vigil/</a>. 40 I don't intend to keep code on this page up to date; it should only be used 41 for example and inspiration. 42 </p> 43 <p> 44 What follows is a rough guide covering the basic components of the system. 45 It is not intended to be a step-by-step guide, though there is a chance it 46 might work as one. 47 </p> 48 <h2>Hardware</h2> 49 <a href="/static/media/1920/dwrz_20221022T213439.jpg" > 50 <img class="img-center" src="/static/media/720/dwrz_20221022T213439.jpg"> 51 </a> 52 <p> 53 I've opted for the following: 54 <ul> 55 <li> 56 <a href="https://www.raspberrypi.com/products/raspberry-pi-400/">Raspberry 57 Pi 400</a> — easier to source and repurpose for my use cases. I would 58 have preferred a smaller device with more open hardware and USB ports, 59 but it was hard to find anything of comparable price. 60 </li> 61 <li> 62 <a 63 href="https://www.westerndigital.com/products/usb-flash-drives/sandisk-extreme-pro-usb-3-2"> 64 Sandisk SSD Flash Drive</a> — improves performance and reliability 65 compared to running the Raspberry Pi off of a MicroSD card. 66 </li> 67 <li> 68 <a 69 href="https://www.amazon.com/s?me=AVRDPNYMU6GNM&marketplaceID=ATVPDKIKX0DER"> 70 ELP 3.6mm FHD 180° IR Fisheye Camera</a> — can cover an entire room and 71 see in the dark. 72 </li> 73 <li><a href="https://www.amazon.com/stores/ULIBERMAGNET/page/22395264-96B4-4270-9CA4-518AED93106E">ULIBERMAGNET</a> Tripod Ball Head with Magnetic Base — used to hold and position the cameras.</li> 74 </ul> 75 </p> 76 <h2>Server</h2> 77 <img class="img-center" src="/static/media/vigil-diagram.svg"> 78 <p> 79 I've used the default operating system for Raspberry Pi's, <a href="https://www.raspberrypi.com/software/">Raspberry Pi OS</a>, and this guide assumes that context. I won't cover the operating system installation and setup of an administrative user — documentation is available elsewhere. 80 </p> 81 <p> 82 You should be mindful of the security of the servers themselves. This is a 83 <a href="https://www.theverge.com/2022/11/30/23486753/anker-eufy-security-camera-cloud-private-encryption-authentication-storage" title="Anker’s Eufy lied to us about the security of its security cameras">problem</a> that even <a href="https://www.bloomberg.com/news/articles/2021-03-09/hackers-expose-tesla-jails-in-breach-of-150-000-security-cams" title="Hackers Breach Thousands of Security Cameras, Exposing Tesla, Jails, Hospitals">commercial</a> <a href="https://www.washingtonpost.com/technology/2019/04/23/how-nest-designed-keep-intruders-out-peoples-homes-effectively-allowed-hackers-get/" title="How Nest, designed to keep intruders out of people’s homes, effectively allowed hackers to get in">offerings</a> <a href="https://www.consumerreports.org/home-security-cameras/wyze-didnt-completely-fix-security-camera-flaws-for-3-years-a3726294358/" title="Wyze Didn't Completely Fix Flaws in Security Cameras for 3 Years">have not handled well</a>. 84 Your personal circumstances will dictate the balance of features and security. 85 </p> 86 87 <aside class="bordered br-yellow"> 88 <p> 89 When installing the operating system, you'll have to decide whether to 90 encrypt the filesystem (or a partition). Encryption makes it harder to 91 extract data if someone is able to gain physical access to the 92 storage device. However, entering the decryption passwords requires you to 93 be physically present at the machine. This can be a problem if, e.g., a 94 transient power outage happens while you are away from the cameras; they 95 would cease to function until you are on premises again. It's possible to 96 use something like <code><a href="https://wiki.archlinux.org/title/Dm-crypt/Specialties#Remote_unlocking_(hooks:_netconf,_dropbear,_tinyssh,_ppp)">dropbear</a></code> for boot-time SSH, 97 to enter the decryption password. But I've opted to use the servers without 98 encryption, and transfer photos and videos off of the servers as soon as 99 possible. The downside is that any secrets on the device can be 100 compromised, requiring remediation in the event of a breach. 101 </p> 102 </aside> 103 <p> 104 Once you are up and running with Raspberry Pi OS, ensure you are 105 using the latest software and security updates: 106 </p> 107 <pre><code>$ sudo apt-get -y update && sudo apt-get -y dist-upgrade</code></pre> 108 <p> 109 Install any dependencies necessary to get work done, e.g.: 110 </p> 111 <pre><code>$ sudo apt-get install curl git mg</code></pre> 112 <p> 113 Consider enabling <a href="https://wiki.debian.org/UnattendedUpgrades">unattended 114 security upgrades</a>: 115 </p> 116 <pre><code>$ sudo apt-get install unattended-upgrades apt-listchanges</code></pre> 117 <p> 118 To receive email reports for unattended upgrades, a 119 <a href="https://en.wikipedia.org/wiki/Message_transfer_agent"> 120 Message Transfer Agent 121 </a> (MTA) and the <code>mailx</code> command are required. This guide 122 assumes the use of <code><a href="https://marlam.de/msmtp/">msmtp</a></code>: 123 </p> 124 <pre><code>$ sudo apt-get install bsd-mailx msmtp msmtp-mta</code></pre> 125 <p> 126 Setup the <code>msmtp</code> 127 <a href="https://marlam.de/msmtp/msmtp.html#Configuration-files">configuration</a> 128 file for the <code>root</code> user: 129 </p> 130 <pre><code>$ sudo mg /root/.msmtprc</code></pre> 131 <pre><code>defaults 132 auth on 133 tls on 134 tls_trust_file /etc/ssl/certs/ca-certificates.crt 135 logfile /root/.msmtp.log 136 137 account gmail 138 host smtp.gmail.com 139 port 587 140 from user@example.com 141 user user@example.com 142 password ${PASSWORD} 143 # Alternatively, a command may be used to retrieve the password: 144 # passwordeval pass google/gmail/app 145 # See: https://marlam.de/msmtp/msmtp.html#passwordeval. 146 147 account default : gmail</code></pre> 148 <p> 149 This configuration assumes a Gmail or Google Workspace account; you will 150 need to specify appropriate settings for your own mail provider. If you are 151 using Gmail or Google Workspaces, you will need to set up an "app password" 152 for programmatic access. 153 </p> 154 <p> 155 Test that <code>msmtp</code> is working: 156 </p> 157 <pre><code>echo "Test" | mailx -s "Test" user@example.com</code></pre> 158 <p> 159 Edit the <code>unattended-upgrades</code> configuration to send email 160 notifications: 161 </p> 162 <pre><code>$ sudo mg /etc/apt/apt.conf.d/50unattended-upgrades</code></pre> 163 <pre><code>Unattended-Upgrade::Mail "user@example.com";</code></pre> 164 <h2>Networking</h2> 165 <p> 166 I assign a static IP address for each of 167 my servers. Most consumer routers allow for this in their web interface; I 168 have something like the following in my router's <code>/etc/dhcpd.conf</code>: 169 <pre><code>host kitchen { 170 fixed-address 10.0.1.101; 171 hardware ethernet de:ad:be:ef:8d:8e; 172 }</code></pre> 173 </p> 174 <aside class="bordered br-yellow"> 175 <p> 176 If you are using <code>network-manager</code> with a WiFi connection, 177 you'll need to disable MAC address randomization, or your router / DHCP 178 server will not be able to consistently match to the device. 179 </p> 180 <br> 181 <pre><code>$ sudo mg /etc/NetworkManager/conf.d/wifi_rand_mac.conf</code></pre> 182 <br> 183 <pre><code>[device] 184 wifi.scan-rand-mac-address=no</code></pre> 185 </aside> 186 <p> 187 Set up a firewall; I block all ports by default, and at most leave three ports 188 open: one for <code>SSH</code>, one for the camera livestream (e.g., 189 <code>3000</code>), and optionally one for the web interface (e.g., 190 <code>8080</code>).</p> 191 <p> 192 If you intend to share the livestream over the internet, you'll need a relay 193 or a port forward from your router. Depending on your network setup, you may 194 need to enable hairpin NAT or split-horizon DNS to access the servers by their 195 domain name when on the local network. 196 </p> 197 <aside class="bordered br-yellow"> 198 <p> 199 I don't recommend allowing for remote access to web interface; and would 200 advise limiting access to the livestream if possible. <code>motion</code> is 201 not written in a memory safe language; all security cameras are a 202 double-edged sword that can compromise your own privacy. 203 </p> 204 </aside> 205 <h2>SSH</h2> 206 <p>Create an SSH keypair: 207 </p> 208 <pre><code>ssh-keygen -t ed25519</code></pre> 209 <p> 210 Then, copy the public key to the server: 211 </p> 212 <pre><code>ssh-copy-id -i ${SSH_KEY_PATH} username@10.0.1.101 </code></pre> 213 <p>Add the server to your SSH config: 214 </p> 215 <pre><code>Host kitchen 216 Hostname 10.0.1.101 217 IdentityFile ~/.ssh/keys/kitchen</code></pre> 218 <p> 219 On the server, disable root login and password authentication; enable public 220 key authentication. 221 </p> 222 <pre><code>$ sudo mg /etc/ssh/sshd_config</code></pre> 223 <pre><code>PermitRootLogin no 224 PubkeyAuthentication yes 225 PasswordAuthentication no</code></pre> 226 <p> 227 Restart the SSH daemon: 228 </p> 229 <pre><code>$ systemctl restart sshd</code></pre> 230 <h2>Motion</h2> 231 <p> 232 <code><a href="https://motion-project.github.io/">motion</a></code> provides 233 the core features of the system — multiple cameras, live streams, web 234 control, motion detection, saving images and movies, timelapse, and event 235 triggers. Install it: 236 </p> 237 <pre><code>$ sudo apt-get install motion</code></pre> 238 <p> 239 On Raspberry Pi OS, the installation will create a 240 <code>motion</code> user, homed at <code>/var/lib/motion/</code>. The 241 configuration file for <code>motion</code> is located at 242 <code>/etc/motion/motion.conf</code>; the <code>systemd</code> unit file is 243 <code>/usr/lib/systemd/system/motion.service</code>. 244 </p> 245 <p> 246 <code>motion</code>'s behavior is controlled via its configuration file. The 247 <a 248 href="https://motion-project.github.io/motion_config.html">documentation</a> 249 covers the settings, and should be reviewed; situation will dictate which 250 values to use. I discuss configuration below, after setting up the other 251 components of the system. 252 </p> 253 <h2>Object Detection</h2> 254 <a href="/static/media/1920/dwrz_20221026_1.jpg" > 255 <img class="img-center" src="/static/media/720/dwrz_20221026_1.jpg"> 256 </a> 257 <p> 258 <code><a href="https://github.com/WongKinYiu/yolov7">yolov7</a></code> 259 provides the object detection functionality. I use the "tiny" weights for 260 faster processing on a Raspberry Pi. On a Raspberry Pi 400, I typically see 261 inferences complete under a second. 262 </p> 263 <pre><code>$ sudo apt-get install git pip 264 $ sudo -u motion bash 265 $ git clone https://github.com/WongKinYiu/yolov7.git 266 $ cd yolov7/ 267 $ pip install -r requirements.txt 268 $ wget https://github.com/WongKinYiu/yolov7/releases/download/v0.1/yolov7-tiny.pt 269 $ mkdir -p /tmp/yolov7/</code></pre> 270 <p> 271 <a href="https://aws.amazon.com/rekognition/">AWS Rekognition</a> can be used 272 as an alternative to <code>yolov7</code>. I had better — and cheaper — 273 results with <code>yolov7</code>. However, if you encounter any issues with 274 the <code>yolov7</code> installation, AWS offers a convenient fallback. 275 </p> 276 <p> 277 To use Rekognition, you will need to setup an AWS account, install the 278 <code>aws</code> CLI, and ideally, create an IAM user with permissions 279 restricted to the Rekognition service. You will also need to 280 install <code><a href="https://stedolan.github.io/jq/">jq</a></code> to 281 parse the JSON response from AWS. 282 </p> 283 <pre><code>$ sudo apt-get install jq 284 $ pip3 install --system awscli 285 $ sudo -u motion bash 286 $ aws configure</code></pre> 287 <h2>Notifications</h2> 288 <a href="/static/media/1920/dwrz_20230124T165950_edit.jpg" > 289 <img class="img-center" src="/static/media/720/dwrz_20230124T165950_edit.jpg"> 290 </a> 291 <p> 292 I've set things up so that notifications are only sent when two smartphones 293 are not reachable on the network. The upside is less notifications (though 294 they'll sometimes come through if the device goes to sleep). If you go this 295 route, you'll need need to set up static IP addresses for your devices, and 296 remember to take them with you. 297 </p> 298 <p> 299 I send SMS notifications by emailing my mobile phone number, setting the 300 recipient to something like <code>1234567890@msg.fi.google.com</code>. That 301 option may not be available depending on your mobile service provider. A 302 fallback would be to send notifications to an email address, or to use a 303 service like <a href="https://www.twilio.com/">Twilio</a> or 304 <a href="https://aws.amazon.com/sns/">AWS SNS</a>. 305 </p> 306 <p> 307 I include the object-detected camera snapshot in notifications. While it's 308 not too difficult to write a script or simple program to compose 309 <a href="https://en.wikipedia.org/wiki/MIME">MIME</a> 310 emails, it's easier to just install 311 <code><a href="http://www.mutt.org/">mutt</a></code>. 312 </p> 313 <pre><code>$ sudo apt-get install mutt</code></pre> 314 <p> 315 Configure <code>msmtp</code> and <code>mutt</code> for the <code>motion</code> 316 user: 317 </p> 318 <pre><code>$ sudo -u motion bash 319 $ cd ~ 320 $ mg .msmtprc</code></pre> 321 <pre><code>defaults 322 auth on 323 tls on 324 tls_trust_file /etc/ssl/certs/ca-certificates.crt 325 logfile ~/.cache/msmtp.log 326 327 account gmail 328 host smtp.gmail.com 329 port 587 330 from user@example.com 331 user user@example.com 332 password ${PASSWORD} 333 334 account default : gmail</code></pre> 335 <pre><code>$ chmod 600 .msmtprc 336 $ mg .muttrc</code></pre> 337 <pre><code>set sendmail="/usr/bin/msmtp" 338 set use_from=yes 339 set from=user@example.com</code></pre> 340 <h2>Exporting Data</h2> 341 <p> 342 I've configured the system to delete snapshots after sending the 343 corresponding notification. This makes it harder to retrieve data if someone 344 gains access to the server. However, since I want to be able to review past 345 snapshots and timelapse footage, I backup the data off the servers. 346 </p> 347 <p> 348 There are several options — <code>rsync</code> or <code>scp</code> files to 349 a remote server, perhaps one with an encrypted drive. Additionally, or 350 alternatively, the files can be backed up to the cloud, to a service like 351 <a href="https://aws.amazon.com/s3/">AWS S3</a> or <a 352 href="https://www.backblaze.com/b2/cloud-storage.html">Backblaze B2</a>. 353 </p> 354 <p> 355 For <code>b2</code>, I took the following steps: 356 <ol> 357 <li>Create an account.</li> 358 <li>Create a bucket.</li> 359 <li> 360 Setup lifecycle rules on the bucket to delete files after a certain 361 number of days. 362 </li> 363 <li>Create an application key.</li> 364 </ol> 365 </p> 366 <p> 367 On the servers, I install and configure the <code>b2</code> CLI. 368 </p> 369 <pre><code>$ sudo apt-get install backblaze-b2 370 $ sudo -u motion bash 371 $ backblaze-b2 authorize-account</code></pre> 372 <h2>DDNS</h2> 373 <p> 374 To make the camera stream available remotely and conveniently — without a 375 <code>VPN</code>, port forwarding, or dealing with IP addresses — I use 376 subdomains to reach my cameras. You will need your own domain for similar 377 functionality. 378 </p> 379 <p> 380 I don't have a static IP from my ISP, so I use Dynamic DNS to keep my 381 subdomain records updated. A <code>systemd</code> timer regularly runs a 382 script to update the <code>A</code> and/or <code>AAAA</code> records for the 383 server's subdomain. 384 </p> 385 <p> 386 The public IP of the server is retrieved with a DNS lookup, using 387 <code>dig</code>. On Raspberry Pi OS, you'll need to install the 388 <code>dnsutils</code> package: 389 </p> 390 <pre><code>$ sudo apt-get intsall dnsutils</code></pre> 391 <p> 392 How you update your records will depend on your registrar. I use 393 <a href="https://aws.amazon.com/route53/">AWS Route53</a>, and a simple Go 394 program I wrote called <code> 395 <a href="https://code.dwrz.net/src/file/cmd/r53/main.go.html"> 396 r53</a></code>, which wraps around the 397 <a href="https://github.com/aws/aws-sdk-go-v2/">AWS Go SDK</a> and 398 <code>dig</code>. 399 </p> 400 <p> 401 A script is probably easier to install. The following isn't as full featured 402 as <code>r53</code>, but it doesn't require compiling and installing a Go 403 binary: 404 </p> 405 <pre><code>#!/usr/bin/env bash 406 407 readonly HZ="${AWS_HOSTED_ZONE}" 408 readonly DOMAIN="${HOSTNAME}" 409 410 err() { 411 echo "[$(date -u +'%Y-%m-%dT%H:%M:%S%:z')]: $*" >&2 412 } 413 414 main() { 415 if ! [[ -x "$(command -v aws)" ]]; then 416 err "aws cli not installed"; exit 1 417 fi 418 419 # Get the IP address. 420 ip="$(dig -4 +short myip.opendns.com @resolver1.opendns.com)" 421 if [[ -z "${ip}" ]]; then 422 err "failed to get ip address"; exit 2 423 fi 424 printf "ip: %s\n" "${ip}" 425 426 # Update the domains. 427 update='{ 428 "Comment": "DDNS", 429 "Changes": [ 430 { 431 "Action": "UPSERT", 432 "ResourceRecordSet": { 433 "Name": "'"${DOMAIN}"'", 434 "Type": "A", 435 "TTL": 300, 436 "ResourceRecords": [{ "Value": "'"${ip}"'" }] 437 } 438 } 439 ] 440 }' 441 442 printf "requesting update for %s\n" "${DOMAIN}" 443 aws route53 change-resource-record-sets \ 444 --hosted-zone-id "${HZ}" \ 445 --change-batch "${update}" 446 } 447 448 main "$@"</code></pre> 449 <p> 450 If you are using AWS Route53, you'll need to install and setup the <code>aws</code> CLI for whichever user will run the DDNS service. Again, it's best to create an AWS IAM user with permissions limited to Route53. 451 </p> 452 <pre><code>$ pip3 install --system awscli 453 $ sudo su 454 # aws configure</pre></code> 455 <p> 456 <code>scp</code> the script or the <code>r53</code> binary to the server, 457 then move it and set appropriate permissions: 458 </p> 459 <pre><code>$ scp r53 user@server 460 $ sudo mv r53 /usr/local/bin/ 461 $ sudo chmod 755 /usr/local/bin/r53</code></pre> 462 <p> 463 Test the command to ensure that it works: 464 </p> 465 <pre><code>$ r53 $HOSTNAME</code></pre> 466 <p> 467 These are the <code>systemd</code> unit and timer files — install them at 468 <code>/usr/lib/systemd/system/</code>: 469 </p> 470 <pre><code>[Unit] 471 Description=DDNS 472 RefuseManualStart=no 473 RefuseManualStop=yes 474 475 [Service] 476 Type=oneshot 477 ExecStart=ddns 478 479 [Install] 480 WantedBy=timers.target</code></pre> 481 482 <pre><code>[Unit] 483 Description=DDNS 484 RefuseManualStart=no 485 RefuseManualStop=no 486 487 [Timer] 488 OnBootSec=1min 489 OnCalendar=*-*-* *:*/5:00 490 Persistent=true 491 RandomizedDelaySec=15 492 Unit=ddns.service 493 494 [Install] 495 WantedBy=default.target</code></pre> 496 <p> 497 Then, enable the timer: 498 </p> 499 <pre><code>$ systemctl enable --now ddns.timer</code></pre> 500 <a href="/static/media/1920/dwrz_20221018T191948_edit.jpg" > 501 <img class="img-center" src="/static/media/720/dwrz_20221018T191948_edit.jpg"> 502 </a> 503 <h2>TLS Certificates</h2> 504 <p> 505 <code>motion</code> will need TLS certificates to encrypt the livestream, 506 webcontrol, and authentication for each. We can get free certificates from <a 507 href="https://letsencrypt.org/">Let's Encrypt</a>, using <code><a 508 href="https://certbot.eff.org/">certbot</a></code>. The following assumes a 509 <code> 510 <a href="https://letsencrypt.org/docs/challenge-types/#dns-01-challenge"> 511 dns-01 512 </a> 513 </code> challenge with Route53. 514 </p> 515 <p> 516 Install <code>certbot</code> and the <code>python3-certbot-dns-route53</code> 517 plugin: 518 </p> 519 <pre><code>$ sudo apt-get install certbot python3-certbot-dns-route53</code></pre> 520 <p> 521 Run <code>certbot</code> to generate certificates: 522 </p> 523 <pre><code>$ sudo certbot certonly \ 524 --agree-tos \ 525 --email user@example.com \ 526 --non-interactive \ 527 --quiet \ 528 --verbose \ 529 --dns-route53 \ 530 -d ${DOMAIN}</code></pre> 531 <p> 532 Add the <code>motion</code> user to the <code>ssl-cert</code> group. 533 534 Then, change group ownership for access. 535 </p> 536 <pre><code>$ chown -R root:ssl-cert letsencrypt/archive/${DOMAIN} 537 $ chown -R root:ssl-cert letsencrypt/live/${DOMAIN} 538 $ chmod 440 letsencrypt/archive/${DOMAIN}/privkey1.pem</code></pre> 539 <a href="/static/media/1920/dwrz_20230104T104635_edit.jpg" > 540 <img class="img-center" src="/static/media/720/dwrz_20230104T104635_edit.jpg"> 541 </a> 542 <h2>Motion Scripts</h2> 543 <p> 544 We're nearly there. Three scripts are used to tie functionality together; 545 they should be copied over to <code>/var/lib/motion</code> and made 546 executable by the <code>motion</code> user. I use a 547 <a href="https://code.dwrz.net/vigil/file/setup.html">script</a> to make 548 installing the scripts a little easier. 549 </p> 550 <p> 551 An <code>alert</code> script is called when motion is detected; it sends a 552 notification via email. 553 </p> 554 <pre><code>#!/usr/bin/env bash 555 556 # Devices to check -- if populated and up, no notifications are sent. 557 readonly DEVICES=() 558 readonly RECIPIENT="${NOTIFICATION_RECIPIENT}" 559 560 check_devices() { 561 for device in "${DEVICES[@]}"; do 562 if ping -c 1 -w 1 "${device}" &> "/dev/null"; then 563 return 0 564 fi 565 done 566 567 return 255 568 } 569 570 main() { 571 # If devices are present, don't notify. 572 if (( "${#DEVICES[@]}" )); then 573 if check_devices; then 574 exit 0 575 fi 576 fi 577 578 echo "${HOSTNAME}: motion detected at $(date '+%Y-%m-%dT%H:%M:%S%:z')." | \ 579 mutt -s "${HOSTNAME}: Motion Detected" \ 580 -- "${RECIPIENT}" 581 } 582 583 main "$@"</code></pre> 584 <p> 585 The <code>sync</code> script is used to backup timelapse videos: 586 </p> 587 <pre><code>#!/usr/bin/env bash 588 589 readonly BUCKET="${B2_BUCKET}" 590 591 main() { 592 local filepath="$1" 593 local name 594 name="$(basename "${filepath}")" 595 596 backblaze-b2 upload-file \ 597 --threads 2 \ 598 "${BUCKET}" \ 599 "${HOME}/timelapse/${name}" \ 600 "${HOSTNAME}/timelapse/${name}" 601 602 # Delete outdated files. 603 # This assumes the timelapse is created on an hourly basis. 604 rm -f "$1" 605 find "${HOME}/timelapse/" -mmin +60 -delete 606 } 607 608 main "$@"</code></pre> 609 <p> 610 The <code>notify</code> script sends notifications, and backs up the 611 snapshots: 612 </p> 613 <pre><code>#!/usr/bin/env bash 614 615 # Backblaze B2 Bucket 616 readonly BUCKET="${B2_BUCKET}" 617 618 # Devices to check -- if populated and up, no notifications are sent. 619 readonly DEVICES=() 620 621 # COCO Labels 622 readonly LABEL_PERSON=0 623 readonly LABEL_CAT=15 624 625 # Lockfile to ensure that only one instance of the script is running. 626 readonly LOCKFILE="/tmp/motion-notify.lock.d" 627 628 # yolov7 working directory. 629 readonly PROJECT="/tmp/yolov7" 630 631 # Notification recipient. 632 readonly RECIPIENT="${NOTIFICATION_RECIPIENT}" 633 634 acquire_lock () { 635 while true; do 636 if mkdir "${LOCKFILE}"; then 637 break; 638 fi 639 sleep 1 640 done 641 } 642 643 check_devices() { 644 for device in "${DEVICES[@]}"; do 645 if ping -c 1 -w 1 "${device}" &> "/dev/null"; then 646 return 0 647 fi 648 done 649 650 return 255 651 } 652 653 detect_objects() { 654 local filepath="$1" 655 656 python "${HOME}/yolov7/detect.py" \ 657 --exist-ok \ 658 --no-trace \ 659 --save-txt \ 660 --project "${PROJECT}" \ 661 --name "motion" \ 662 --weights "${HOME}/yolov7/yolov7-tiny.pt" \ 663 --source "${filepath}" 664 } 665 666 notify() { 667 local name="$1" 668 669 echo "${HOSTNAME} at $(date '+%Y-%m-%dT%H:%M:%S%:z')" | \ 670 mutt -a "${PROJECT}/motion/${name}.jpg" \ 671 -s "${HOSTNAME}: Motion Detected" \ 672 -- "${RECIPIENT}" 673 } 674 675 upload() { 676 local name="$1" 677 678 backblaze-b2 upload-file \ 679 --threads 2 \ 680 "${BUCKET}" \ 681 "${PROJECT}/motion/${name}.jpg" \ 682 "${HOSTNAME}/photo/${name}.jpg" 683 } 684 685 delete_outdated() { 686 local filepath="$1" 687 688 acquire_lock 689 690 rm -f "$1" 691 rm -f "${PROJECT}/motion/${name}.jpg" 692 find "${HOME}/photo/" -mmin +5 -delete 693 find "${PROJECT}/motion/" -iname "*.jpg" -mmin +5 -delete 694 find "${PROJECT}/motion/labels/" -mmin +5 -delete 695 696 release_lock 697 } 698 699 release_lock () { 700 rmdir "${LOCKFILE}" 701 } 702 703 main() { 704 local filepath="$1" 705 local name 706 name="$(basename "${filepath}" .jpg)" 707 708 # If devices are present, don't notify. 709 if (( "${#DEVICES[@]}" )); then 710 if check_devices; then 711 delete_outdated "${filepath}" "${name}" 712 exit 0 713 fi 714 fi 715 716 detect_objects "${filepath}" 717 718 # Send a notification if we match any labels. 719 labels="$(awk '{print $1}' "${PROJECT}/motion/labels/${name}.txt")" 720 if echo "${labels}" | grep -qw "${LABEL_PERSON}\|${LABEL_CAT}"; then 721 notify "${name}" 722 fi 723 724 upload "${name}" 725 726 delete_outdated "${filepath}" "${name}" 727 } 728 729 main "$@"</code></pre> 730 <p> 731 With AWS Rekognition, you'll need to adapt the script. The following will 732 handle uploading the image to AWS, and check if the labels are actionable: 733 </p> 734 <pre><code>labels="$(env aws rekognition detect-labels \ 735 --min-confidence 90 \ 736 --image-bytes fileb://"${filepath}" \ 737 | jq -j '.Labels | .[] | "\n",.Name," ",.Confidence')" 738 739 if grep --quiet "Human\|Cat" <<< "${labels}"; then 740 echo "${HOSTNAME} at $(date '+%Y-%m-%dT%H:%M:%S%:z')" | \ 741 mutt -a "${filepath}" \ 742 -s "${HOSTNAME}: Motion Detected" \ 743 -- "${RECIPIENT}" 744 fi</code></pre> 745 <h2>Motion Config</h2> 746 <p> 747 The last step is to configure <code>motion</code> to: 748 <ul> 749 <li>Take snapshots on motion detection</li> 750 <li>Capture a timelapse — one photo per second, one file per hour, synced to the Backblaze B2</li> 751 <li>Serve webcontrol on port 8080 over HTTPS</li> 752 <li>Livestream on port 3000 over HTTPS</li> 753 <li>Notify on motion detection and send object-detected snapshots</li> 754 <li>Keep minimal amounts of data on the local drive</li> 755 </ul> 756 </p> 757 <pre><code># GENERAL 758 daemon off 759 target_dir ${MOTION_DIR} 760 log_file ${MOTION_LOG_FILE} 761 762 # IMAGE PROCESSING 763 despeckle_filter EedDl 764 framerate 24 765 text_scale 2 766 text_changes on 767 text_left %$ 768 text_right %Y-%m-%d-T%H:%M:%S %q 769 770 # MOTION DETECTION 771 event_gap 1 772 threshold 2000 773 774 # MOVIES 775 movie_output off 776 movie_filename /video/%Y-%m-%dT%H:%M:%S-%v 777 778 # PICTURES 779 picture_output first 780 picture_filename /photo/%Y-%m-%dT%H-%M-%S_%q 781 782 # TIMELAPSE 783 timelapse_interval 1 784 timelapse_mode hourly 785 timelapse_fps 60 786 timelapse_codec mpg 787 timelapse_filename /timelapse/%Y-%m-%d-%H-%M-%S 788 789 # WEBCONTROL 790 webcontrol_auth_method 2 791 webcontrol_authentication ${MOTION_USER}:${MOTION_PASSWORD} 792 webcontrol_port ${PORT_CONTROL} 793 webcontrol_localhost off 794 webcontrol_cert ${TLS_CERT} 795 webcontrol_key ${TLS_KEY} 796 webcontrol_parms 0 797 webcontrol_tls on 798 799 # LIVE STREAM 800 stream_port ${PORT_STREAM} 801 stream_localhost off 802 stream_quality 25 803 stream_motion on 804 stream_maxrate 24 805 stream_auth_method 2 806 stream_authentication ${MOTION_USER}:${MOTION_PASSWORD} 807 stream_preview_method 0 808 stream_tls on 809 810 # SCRIPTS 811 on_motion_detected ${MOTION_DIR}/alert 812 on_movie_end ${MOTION_DIR}/sync %f 813 on_picture_save ${MOTION_DIR}/notify %f 814 815 # CAMERA 816 camera_name ${CAMERA_NAME} 817 videodevice /dev/video0 818 height 1080 819 width 1920</code></pre> 820 </p> 821 <p>Restart <code>motion</code> to use the updated configuration: 822 </p> 823 <pre><code>$ systemctl restart motion</code></pre> 824 <h2>Camera Management</h2> 825 <p> 826 I use a simple script to manage the cameras. This examples allows control 827 over three servers (one of which has two cameras): 828 </p> 829 <pre><code>#!/usr/bin/env bash 830 831 readonly WEBCONTROL_PORT="${PORT_CONTROL}" 832 833 readonly cameras=( 834 "https://${HOST0}:${WEBCONTROL_PORT}/0" 835 # "https://${HOST0}:${WEBCONTROL_PORT}/1" 836 # "https://${HOST1}:${WEBCONTROL_PORT}/${CAMERA0}" 837 # "https://${HOST2}:${WEBCONTROL_PORT}/${CAMERA0}" 838 ) 839 readonly auth=( 840 "${MOTION_USER}:${MOTION_PASSWORD}" 841 # "${USER0}:${PW0}" 842 # "${USER1}:${PW1}" 843 # "${USER2}:${PW2}" 844 ) 845 846 err() { 847 echo "[$(date -u +'%Y-%m-%dT%H:%M:%S%:z')]: $*" >&2 848 } 849 850 main() { 851 local url="detection/status" 852 853 case "$1" in 854 "capture"|"c") url="detection/snapshot" ;; 855 "pause"|"p") url="detection/pause" ;; 856 "start"|"s") url="detection/start" ;; 857 "status"|"") url="detection/status" ;; 858 *) err "unrecognized command: $1"; exit 1 859 esac 860 861 for i in "${!cameras[@]}"; do 862 curl --digest --user "${auth[i]}" "${cameras[i]}/${url}" 863 done 864 } 865 866 main "$@"</code></pre> 867 <h2>Next Steps</h2> 868 <a href="/static/media/1920/dwrz_20220304T224024_edit.jpg" > 869 <img class="img-center" src="/static/media/720/dwrz_20220304T224024_edit.jpg"> 870 </a> 871 <p> 872 As with all software, this project is a work-in-progress, at times 873 abandoned, and never completed. There are a few ideas I am exploring as I 874 continue to prototype the system: 875 <ul> 876 <li> 877 The most urgent task is to make it easier to set up a new server and to 878 keep configuration consistent across servers. I'm working on minimizing 879 some of the duplicative work. Another option is to use containers. 880 </li> 881 <li> 882 Use <a href="https://www.openbsd.org/">OpenBSD</a> instead of Raspberry 883 Pi OS, replacing <code>msmtp</code> with <code>OpenSTMTPD</code>, 884 <code>acme-client</code> for <code>letsencrypt</code>. The main concern 885 here is whether wireless and <code>yolov7</code> are sufficiently 886 performant. 887 </li> 888 <li> 889 Use an in-memory filesystem and forgo the SSD, which might drop ~$30 890 from the cost of the system. 891 </li> 892 <li> 893 Use different hardware — wireless or PoE security cameras with an RTSP 894 stream and motion running on a single server. 895 </li> 896 <li> 897 Improve object detection with <code>yolov7</code> by training the model. 898 </li> 899 <li> 900 Develop my own Go service to replace or wrap around <code>motion</code>, 901 or replace the bash scripts with Go programs. 902 </li> 903 <li> 904 Add features, like two-way communication. 905 </li> 906 <li> 907 Use <code>yolov7</code> to monitor the camera stream directly, and forgo 908 the motion detection step. 909 </li> 910 </ul> 911 </p> 912 <p> 913 The final thanks must go to the open-source contributors who have made this 914 approach possible, from the operating system all the way up to the 915 interpreters. 916 </p> 917 </div>