src

Go monorepo.
git clone git://code.dwrz.net/src
Log | Files | Refs

2023-02-01.html (30844B)


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