├── .gitignore ├── LICENSE ├── README.md ├── communication_ref.md ├── config.cfg ├── installers ├── r_pi_4_installer.sh └── ubuntu_22.04_installer.sh ├── ossc_client.py └── ossc_client_service.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /ignored/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Open Source Videos Capstone Project 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Installation Notes 2 | This guide is for installing on a Raspberry Pi 4 Bullseye (32bit). However, you can use any version of linux where you're able to get the dependencies working. The details of installing on anything else will be left up to you, but this guide will most likely be a good starting point anyway. 3 | 4 | Note that the Raspberry Pi foundation broke their old camera support in Nov of 2021, and Motion and MotionEye don't work Raspberry Pi integrated cameras anymore. However, it still works with networked cameras, usb cameras or other types that are supported by Motion. The nice thing about this is that you can still use MotionEyeOS on a Raspberry Pi with an integrated camera, set it up as a network cam, then have this installed on a hub that handles the recording and remote communications. If this situation changes, you will likely be able to install this matrix-chat based security cam setup directly on a Raspberry Pi with an integrated camera following the same setup, only differing in the install of MotionEye. 5 | 6 | Why can't we just use Raspian Buster? Well, the problem with this is that Buster used an older version of python3, and some of the other dependencies for Matrix NIO aren't compatible with Buster. So we'll need to stick with Bullseye. Plus, hitching our cart to the legacy version of Raspian isn't a recipe for long term success. 7 | 8 | # Install Scripts 9 | Shell scripts for installing on Ubuntu 22.04, and a raspberry pi 4 (using Raspian Bullseye 32 bit) are included in the installers folder. These are essentially setup for an install on a clean OS. So if you're installing on an existing system or a different OS, you should follow the manual installation steps. 10 | 11 | ## Using Install Scripts 12 | To use the install scripts you will need to download the repository, for example using a git clone. 13 | 14 | ``` 15 | git clone https://github.com/Open-Source-Videos/Matrix-surveillance-camera-controller 16 | ``` 17 | 18 | Make your chosen installer executable. Then move the install script that you're using into the repos base directory. 19 | 20 | ``` 21 | cd Matrix-surveillance-camera-controller 22 | chmod +x ./installers/r_pu_4_installer.sh 23 | mv ./installers/r_pi_4_installer.sh . 24 | ``` 25 | 26 | Then you need to run it. This will involve installing various dependencies, and moving files around, so it will need to be run as sudo. 27 | 28 | ``` 29 | sudo ./r_pi_4_installer.sh 30 | ``` 31 | 32 | This will take some time to run. If it fails, try the manual installation. After complete, you will need to run the controller program once manually to configure it to work with your matrix room. You will need to have a user and room already setup to link the controller to. 33 | 34 | ``` 35 | sudo python3 /var/lib/ossc_client/ossc_client.py 36 | ``` 37 | 38 | You will be prompted to input your homeserver, username, password, and room ID for the matrix room to link to. After this, restart the controller service. 39 | 40 | ``` 41 | sudo systemctl restart ossc_client 42 | ``` 43 | Done! 44 | 45 | # Raspberry Pi setup. This specifically is for the Raspberry Pi 4. 46 | 47 | ## 1: Install raspian lite - 32bit (Bullseye) 48 | Use Raspberry Pi Imager - https://www.raspberrypi.com/software/ 49 | In the Raspberry Pi Imager select the appropriate operating system from the menu. Press ctrl-x (or press the gear icon) to bring up config menu. Enable SSH, select a password, username will be 'pi' by default. Make sure to enable wifi and configure too, and setup region details. 50 | 51 | write your image to your micro SD Card. 52 | 53 | SSH into the raspberry... Look up the IP it got on your local router if needed. 54 | 55 | ## 2: Install ffmpeg: 56 | ``` 57 | sudo apt-get install ffmpeg libmariadb3 libpq5 libmicrohttpd12 -y 58 | ``` 59 | 60 | ## 3: Install motion: 61 | I recommend going and getting the latest version of motion manually. This one labeled as buster works just fine with my setup on Bullseye 32 bit. 62 | ``` 63 | wget https://github.com/Motion-Project/motion/releases/download/release-4.4.0/pi_buster_motion_4.4.0-1_armhf.deb 64 | sudo dpkg -i pi_buster_motion_4.4.0-1_armhf.deb 65 | ``` 66 | These steps only needed if there is an error starting motion 67 | ``` 68 | sudo mkdir /tmp/motion 69 | sudo chown motion:motion /tmp/motion 70 | sudo nano /etc/motion/motion.conf 71 | ``` 72 | --Edit the file to point the logs at our new file... The "logfile" line: 73 | ``` 74 | logfile /tmp/motion/motion.log 75 | ``` 76 | 77 | 78 | ## 4: Stop motion: 79 | 80 | ``` 81 | sudo systemctl stop motion 82 | sudo systemctl disable motion 83 | ``` 84 | 85 | ## 5: Install pip, motioneye and dependencies: 86 | 87 | ``` 88 | sudo apt-get update 89 | sudo apt-get install python2 python-dev-is-python2 -y 90 | curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py 91 | sudo python2 get-pip.py 92 | sudo apt-get install libssl-dev libcurl4-openssl-dev libjpeg-dev zlib1g-dev -y 93 | sudo python2 -m pip install motioneye 94 | ``` 95 | 96 | ## 6: Prep config directory: 97 | ``` 98 | sudo mkdir -p /etc/motioneye 99 | ``` 100 | Copy the motioneye.conf.sample file to the directory as below... you might have to change the source directory.\ 101 | ``` 102 | sudo cp /usr/local/share/motioneye/extra/motioneye.conf.sample /etc/motioneye/motioneye.conf 103 | ``` 104 | 105 | ## 7: Prep the media directory: 106 | 107 | ``` 108 | sudo mkdir -p /var/lib/motioneye 109 | ``` 110 | 111 | ## 8: Add an init script, configure it to run at startup and start the motionEye server: 112 | ``` 113 | sudo cp /usr/local/share/motioneye/extra/motioneye.systemd-unit-local /etc/systemd/system/motioneye.service 114 | sudo systemctl daemon-reload 115 | sudo systemctl enable motioneye 116 | sudo systemctl start motioneye 117 | ``` 118 | 119 | ## 9: Add to MotionEye: 120 | This is something you will need to work out with your particular setup, as you can use basically anything compatible with MotionEye. 121 | Again, this won't work with the integrated Raspberry Pi camera yet, as MotionEye hasn't been updated (at this time) to use the new Raspberry Pi camera stack. You can use other types of cameras as specified in their documentation. 122 | You can however, use a raspberry pi camera setup to be a network cam. For example I setup my pi zero using "MotionEyeOS", then in its settings under "expert settings" enabled "Fast Network Cam" which just streams the camera output to an address which can be found under stream settings. Mine for example was located at http://192.168.0.198:8081/ Then on my controller Raspberry Pi 4 I added this camera as a network cam. I couldn't get MotionEye recording to work correctly with the cam setup as a remote MotionEye camera, this is because the remote camera will record on the remote device, and not the camera hub. So use a Fast Network Cam Setup. 123 | 124 | ## 10: Configure MotionEye 125 | Setup movies and motion detection. You will want to experiment to see what is right for you, as motion has plenty of options for masking areas, and thresholds for recording. One setting that you might want to tweak right away is the frame rate on the video device, as by default its set to 2. Bumping it up to 10 seems pretty good for my purposes, and should help keep video sizes down. 126 | 127 | All done. MotionEye should be installed and running. Next we need to install the Matrix Communication Dependencies. 128 | 129 | # Installing Matrix NIO on a Raspberry Pi 4 130 | Your needs and setup my be different if not using a Raspberry Pi 4. You will need Raspian Bullseye for this next portion to work though, as many of the dependencies don't exist for earlier versions of Raspian (buster for example). 131 | ### === Additional things to add to cam maybe. Testing stuff === 132 | ``` 133 | sudo apt-get -y install python3-pip 134 | ``` 135 | 136 | #### Install Matrix dependencies. 137 | 138 | ``` 139 | sudo apt install python3 python3-pip 140 | sudo apt-get install libzbar-dev libzbar0 -y 141 | sudo apt install libolm3 libolm-dev -y 142 | sudo python3 -m pip install python-olm 143 | ``` 144 | 145 | #### Matrix nio, and encrypted client. 146 | ``` 147 | sudo pip3 install matrix-nio 148 | sudo pip3 install "matrix-nio[e2e]" 149 | ``` 150 | 151 | #### Pillow for the client scripts 152 | ``` 153 | sudo python3 -m pip install --upgrade Pillow 154 | sudo python3 -m pip install python-magic 155 | ``` 156 | 157 | 158 | # Install Camera Client Software 159 | 160 | Our dependencies: 161 | MotionEye 162 | MatrixNio 163 | Watchdog 164 | 165 | 166 | ``` 167 | sudo python3 -m pip install watchdog 168 | ``` 169 | 170 | Create directories for the client 171 | 172 | ``` 173 | sudo mkdir /var/lib/ossc_client 174 | sudo mkdir /var/lib/ossc_client/log 175 | sudo mkdir /var/lib/ossc_client/credentials 176 | ``` 177 | 178 | Move files to their correct locations - This assumes you've copied them to the device and are in their current directory. 179 | 180 | ``` 181 | sudo mv ./ossc_client.py /var/lib/ossc_client 182 | sudo mv ./config.cfg /var/lib/ossc_client 183 | sudo mv ./ossc_client_service.sh /var/lib/ossc_client 184 | ``` 185 | 186 | Setup the service 187 | ``` 188 | sudo ln -s /var/lib/ossc_client/ossc_client_service.sh /etc/init.d 189 | sudo update-rc.d ossc_client_service.sh defaults 190 | ``` 191 | 192 | How to uninstall service: 193 | ``` 194 | update-rc.d ossc_client_service.sh remove 195 | ``` 196 | 197 | Next you need to connect it to your matrix server, and room. You need to have the homeserver, username, password, and the room ID already setup and ready for this. 198 | 199 | ``` 200 | sudo python3 /var/lib/ossc_client/ossc_client.py 201 | ``` 202 | 203 | You will be prompted to input your homeserver, username, password, and room ID for the matrix room to link to. After this, restart the controller service. 204 | 205 | ``` 206 | sudo systemctl restart ossc_client 207 | ``` 208 | Done! 209 | 210 | 211 | # Log into different room 212 | By default the camera client will always reconnect to the same room. To log it into a different room you will need to remove its credentials files, and then re-run it manually. 213 | 214 | ``` 215 | cd /var/lib/ossc_client/credential/ 216 | sudo rm *.* 217 | sudo python3 /var/lib/ossc_client/ossc_client.py 218 | ``` 219 | 220 | Fill out your login details as prompted, then restart the client service. 221 | 222 | ``` 223 | sudo systemctl restart ossc_client 224 | ``` 225 | 226 | 227 | # Troubleshooting issues 228 | Logs are kept in the /var/lib/ossc_client/logs folder. These can be useful in determining cause of issues. -------------------------------------------------------------------------------- /communication_ref.md: -------------------------------------------------------------------------------- 1 | # Communications reference 2 | 3 | All messages have 3 parameters, type, content, requestor_id. By default requestor_id will be "0" and represents a message for all remote clients 4 | 5 | ## Types of messages FROM camera hub 6 | 7 | ### cam-config: 8 | content = dict, or object structure. The keys are the camera number, the values are the camera names. 9 | 10 | Example: 11 | 12 | `{"type":"cam-config", "content":{"1":"Camera1", "2":"Cam 2 name"}, "requestor_id":"0"}` 13 | 14 | ### error: 15 | content = string. This notifies all clients that there was an error serving up a request by a remote client. 16 | 17 | Example: 18 | 19 | `{"type" : "error", "content" : "File Upload Failed", "requestor_id":"0"}` 20 | 21 | ### snapshot-send: 22 | content = string - is the camera ID for the snapshot whose picture is taken, comma separated is the time at which the image is taken. This is the reply to a snapshot request. 23 | 24 | Example: 25 | 26 | `{"type" : "snapshot-send", "content" : "1,2022-05-06T13:04:04.482374", "requestor_id":"client_that_requested"}` 27 | 28 | ### video-send: 29 | content = string - contains the path / name of the video, followed by the ISO datetime that the video was recorded. This is sent to the client that requested it. 30 | 31 | Example: 32 | 33 | `{"type" : "video-send", "content" : "/var/lib/motioneye/Camrea1/02-05-2022/15-25-30.mp4,2022-02-05T15:25:30", "requestor_id":"client_that_requested"}` 34 | 35 | ### thumbnail: 36 | content = string. The content is the path / name of the thumbnail and video, followed by the ISO datetime that the video was recorded. Upon a motion detection event a thumbnail is uploaded to everyone when the video has been recorded. 37 | 38 | Example: 39 | 40 | `{"type" : "thumbnail", "content" : "/var/lib/motioneye/Camera1/02-05-2022/15-25-30.mp4.thumb, 2022-02-05T15:25:30", "requestor_id":"0"}` 41 | 42 | 43 | ### thumb-reply: 44 | content = string. When a thumbnail for a particular video is requested this reply is the thumbnail. 45 | 46 | Example: 47 | 48 | `{"type" : "thumb-reply", "content" : "/var/lib/motioneye/Camera1/02-05-2022/15-25-30.mp4.thumb, 2022-02-05T15:25:30", "requestor_id":"client_that_requested"}` 49 | 50 | 51 | 52 | ### list-recording-reply: 53 | content = string. Upon user request to list out stored video thumbnails from a specified date range, the output will be a json containing the 'date_range' which was provided in the original request, and an array of the pairs of file paths to the found video thumbnails, and their time stamps. These thumbnail file paths can then be used to request the videos. 54 | 55 | Example: 56 | 57 | `{"type" : "list-recording-reply", "content" : "{'date_range': '2022-05-06T00:00:01, 2022-05-06T14:10:00', 'recordings': [['/var/lib/motioneye/Camera1/2022-05-06/00-58-33.mp4.thumb', '2022-05-06T00:58:33'], ['/var/lib/motioneye/Camera1/2022-05-06/12-31-45.mp4.thumb', '2022-05-06T12:31:45'], ['/var/lib/motioneye/Camera1/2022-05-06/01-01-20.mp4.thumb', '2022-05-06T01:01:20']]}", "requestor_id" : "my_client_name"}` 58 | 59 | 60 | ## Types of messages TO camera hub 61 | 62 | ### snapshot: 63 | content = string or integer. This requests a screenshot from the camera hub. Content must specify the camera number whose snapshot is requested. 64 | Example: 65 | 66 | `{"type" : "snapshot", "content" : "1", "requestor_id":"my_client_name"}` 67 | 68 | Expected reply is a screenshot sent to the client specified in the requestor_id field, or an error. 69 | 70 | ### video-request: 71 | content = string. The content string should be the fully qualified name for the thumbnail as provided when thumbnail was sent. 72 | 73 | Example: 74 | 75 | `{"type" : "video-request", "content" : "/var/lib/motioneye/Camrea1/02-05-2021/15-25-30.mp4.thumb", "requestor_id":"my_client_name"}` 76 | 77 | Expected reply is a video directed to the client specified in the requestor_id field that matches the thumbnail or an error. 78 | 79 | 80 | ### thumb-request: 81 | content = string. The content string should be the fully qualified name for a thumbnail which is requested. 82 | 83 | Example: 84 | 85 | `{"type" : "thumb-request", "content" : "/var/lib/motioneye/Camrea1/02-05-2021/15-25-30.mp4.thumb", "requestor_id":"my_client_name"}` 86 | 87 | Expected reply is the thumbnail file directed to the client specified in the requestor_id field that matches the thumbnail or an error. 88 | 89 | ### record-video: 90 | Note on this, it triggers a simulated motion detection for the specified duration in seconds. So it'll appear to everyone as if its a motion detection event video. Maximum duration is 300 seconds, and a minimum of 1 second. 91 | 92 | content = string. The content needs to contain the camera and the duration comma separated. like "1,20" will be camera 1, 20 seconds. 93 | 94 | `{"type" : "record-video", "content" : "1,20", "requestor_id":"0"}` 95 | 96 | Expected reply is a motion detection event after the specified duration. 97 | 98 | ### cam-config-request: 99 | content = string. Content can be null. This will just request to that camera hub send out an updated list of cameras. 100 | Example: 101 | 102 | `{"type" : "cam-config-request", "content" : "", "requestor_id":"my_client_name"}` 103 | 104 | Expected reply is a cam-config message. 105 | 106 | ### list-recordings: 107 | content = string. String should be two dates. Start Datetime, followed by end datetime, 24 hour clock. 'stardatetime, enddatetime' in the format 'YYYY-MM-DDTHH:MM:SS,YYYY-MM-DDTHH:MM:SS'. 108 | The following example will request a list of recordings between April 27th 2022, at 10:30:00 AM, and April 28th 2022, at 2:45:30 PM. 109 | 110 | `{"type" : "list-recordings", "content" : "2022-04-27T10:30:00, 2022-04-28T14:45:30", "requestor_id":"my_client_name"}` 111 | 112 | Expected reply is a list of all saved recordings for that time duration. -------------------------------------------------------------------------------- /config.cfg: -------------------------------------------------------------------------------- 1 | [FILES] 2 | cred_file = credentials.json 3 | store_path = /var/lib/ossc_client/credentials/ 4 | log_path = /var/lib/ossc_client/log/ 5 | recording_path = /var/lib/motioneye/ 6 | cam_config_path = /etc/motioneye/ 7 | 8 | [CAMERAS] 9 | -------------------------------------------------------------------------------- /installers/r_pi_4_installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo apt-get update -y 4 | sudo apt-get install ffmpeg libmariadb3 libpq5 libmicrohttpd12 -y 5 | wget https://github.com/Motion-Project/motion/releases/download/release-4.4.0/pi_buster_motion_4.4.0-1_armhf.deb 6 | sudo dpkg -i pi_buster_motion_4.4.0-1_armhf.deb 7 | sudo rm pi_buster_motion_4.4.0-1_armhf.deb 8 | 9 | sudo systemctl stop motion 10 | sudo systemctl disable motion 11 | 12 | sudo apt-get install python2 python-dev-is-python2 -y 13 | sudo curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py 14 | sudo python2 get-pip.py 15 | sudo apt-get install libssl-dev libcurl4-openssl-dev libjpeg-dev zlib1g-dev -y 16 | sudo python2 -m pip install motioneye 17 | 18 | sudo mkdir -p /etc/motioneye 19 | sudo cp /usr/local/share/motioneye/extra/motioneye.conf.sample /etc/motioneye/motioneye.conf 20 | sudo mkdir -p /var/lib/motioneye 21 | 22 | sudo cp /usr/local/share/motioneye/extra/motioneye.systemd-unit-local /etc/systemd/system/motioneye.service 23 | sudo systemctl daemon-reload 24 | sudo systemctl enable motioneye 25 | sudo systemctl start motioneye 26 | 27 | sudo apt-get -y install python3-pip 28 | sudo apt-get install libzbar-dev libzbar0 -y 29 | sudo apt install libolm3 libolm-dev -y 30 | sudo python3 -m pip install python-olm 31 | 32 | sudo pip3 install matrix-nio 33 | sudo pip3 install "matrix-nio[e2e]" 34 | 35 | sudo python3 -m pip install --upgrade Pillow 36 | sudo python3 -m pip install python-magic 37 | 38 | sudo python3 -m pip install watchdog 39 | 40 | sudo mkdir /var/lib/ossc_client 41 | sudo mkdir /var/lib/ossc_client/log 42 | sudo mkdir /var/lib/ossc_client/credentials 43 | 44 | 45 | sudo mv ./ossc_client.py /var/lib/ossc_client 46 | sudo mv ./config.cfg /var/lib/ossc_client 47 | sudo mv ./ossc_client_service.sh /var/lib/ossc_client 48 | 49 | sudo ln -s /var/lib/ossc_client/ossc_client_service.sh /etc/init.d 50 | sudo update-rc.d ossc_client_service.sh defaults 51 | 52 | echo "Completed install script" -------------------------------------------------------------------------------- /installers/ubuntu_22.04_installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Executing install script!" 4 | 5 | sudo apt update && sudo apt upgrade -y 6 | sudo apt-get install ssh curl motion ffmpeg v4l-utils -y 7 | sudo apt-get install libmariadb3 libpq5 libmicrohttpd12 -y 8 | 9 | sudo apt-get install python2 -y 10 | curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py 11 | sudo python2 get-pip.py 12 | sudo apt-get install libffi-dev libzbar-dev libzbar0 -y 13 | sudo apt-get install python2-dev libssl-dev libcurl4-openssl-dev libjpeg-dev -y 14 | 15 | sudo python2 -m pip install motioneye 16 | sudo mkdir -p /etc/motioneye 17 | sudo cp /usr/local/share/motioneye/extra/motioneye.conf.sample /etc/motioneye/motioneye.conf 18 | sudo mkdir -p /var/lib/motioneye 19 | sudo cp /usr/local/share/motioneye/extra/motioneye.systemd-unit-local /etc/systemd/system/motioneye.service 20 | 21 | sudo systemctl daemon-reload 22 | sudo systemctl enable motioneye 23 | sudo systemctl start motioneye 24 | 25 | sudo apt-get install python3-pip -y 26 | 27 | sudo apt install libolm3 libolm-dev -y 28 | sudo python3 -m pip install python-olm 29 | sudo pip3 install matrix-nio 30 | sudo pip3 install "matrix-nio[e2e]" 31 | sudo python3 -m pip install --upgrade Pillow 32 | sudo python3 -m pip install python-magic 33 | sudo python3 -m pip install watchdog 34 | 35 | sudo mkdir /var/lib/ossc_client 36 | sudo mkdir /var/lib/ossc_client/log 37 | sudo mkdir /var/lib/ossc_client/credentials 38 | 39 | sudo mv ./ossc_client.py /var/lib/ossc_client 40 | sudo mv ./config.cfg /var/lib/ossc_client 41 | sudo mv ./ossc_client_service.sh /var/lib/ossc_client 42 | 43 | sudo ln -s /var/lib/ossc_client/ossc_client_service.sh /etc/init.d 44 | sudo update-rc.d ossc_client_service.sh defaults 45 | 46 | echo "Completed install script!" 47 | -------------------------------------------------------------------------------- /ossc_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import configparser 3 | from email import message 4 | from imp import get_magic 5 | import json 6 | import os 7 | import sys 8 | import time 9 | from datetime import datetime, date, time, timedelta 10 | import getpass 11 | from PIL import Image 12 | import aiofiles.os 13 | import magic 14 | import logging 15 | from logging.handlers import RotatingFileHandler 16 | 17 | #File watcher, async 18 | from watchdog.observers import Observer 19 | 20 | #MATRIX NIO SDK classes 21 | from nio import AsyncClient, AsyncClientConfig, LoginResponse, UploadResponse, RoomResolveAliasError, RoomMessageText, RoomMessage, Event, OlmEvent 22 | 23 | 24 | #Configuration setup 25 | config = configparser.ConfigParser() 26 | try: 27 | config.read('/var/lib/ossc_client/config.cfg') 28 | except Exception as e: 29 | print("CONFIG FILE NOT FOUND!") 30 | print(e) 31 | sys.exit(1) 32 | 33 | CRED_FILE = config['FILES']['cred_file'] 34 | STORE_PATH = config['FILES']['store_path'] 35 | LOG_PATH = config['FILES']['log_path'] 36 | RECORDING_PATH = config['FILES']['recording_path'] 37 | CAM_CONFIG_PATH = config['FILES']['cam_config_path'] 38 | 39 | #Global variable to keep track of cameras. 40 | CAMERAS = {} 41 | 42 | try: 43 | #Setup for logging 44 | logging.basicConfig(filename=(LOG_PATH + "ossc_client.log"),level=logging.INFO, format='%(asctime)s - %(message)s') #Config without an output file 45 | logger = logging.getLogger("ossc_client_log") 46 | handler = RotatingFileHandler((LOG_PATH + "ossc_client.log"), maxBytes=1048576, backupCount=20) 47 | logger.addHandler(handler) 48 | except Exception as e: 49 | print("Logging directory not found. Please check config, and or create the correct directory. Exiting") 50 | print(e) 51 | sys.exit(1) 52 | 53 | 54 | #Async IO watchdog wrapper 55 | class AIOWatchdogWrapper(object): 56 | def __init__(self, path='.', event_handler=None): 57 | self._observer = Observer() 58 | self.eventhandler = event_handler 59 | self._observer.schedule(self.eventhandler, path, True) 60 | def start(self): 61 | self._observer.start() 62 | def stop(self): 63 | self._observer.stop() 64 | self._observer.join() 65 | 66 | #Event Hander for file monitor. 67 | class EventHandler(): 68 | def __init__(self, client, room_id): 69 | self.loop = asyncio.get_event_loop() 70 | self.future = asyncio.create_task 71 | self.client = client 72 | self.room_id = room_id 73 | self.methods = { 74 | "moved": self.on_moved, 75 | "modified": self.on_modified, 76 | "deleted": self.on_deleted, 77 | "created": self.on_created, 78 | "closed": self.on_closed, 79 | } 80 | def dispatch(self, event): 81 | self.loop.call_soon_threadsafe(self.future, self.methods[event.event_type](event)) 82 | 83 | #Events for files being modified. Specifically camera config files 84 | async def on_modified(self, event): 85 | # Config file update on camera 86 | if str(event.src_path).endswith('.conf') and str(event.src_path).startswith(CAM_CONFIG_PATH + "camera-"): 87 | global CAMERAS 88 | # New camera added. Notify clients that 89 | i = 0 90 | #Try for 10 seconds to wait for the file to be completely written 91 | while not magic.from_file(event.src_path, mime=True).startswith("text/") and i < 10: 92 | await asyncio.sleep(1) 93 | i += 1 94 | if i < 10: 95 | with open(event.src_path, "r") as f: 96 | for line in f: 97 | if line.startswith('camera_name'): 98 | cam_name = line.replace('camera_name ', '').strip() 99 | camnum = str(event.src_path).replace(CAM_CONFIG_PATH + "camera-", '').replace('.conf','') 100 | CAMERAS[str(camnum)] = cam_name 101 | logger.info("Camera config modified: " + camnum + " - " + cam_name) 102 | #Update cam configs 103 | await send_cam_configs(self.client, self.room_id) 104 | 105 | #On file delete check to see if its a camera configuration file, and handle the update 106 | async def on_deleted(self, event): 107 | if str(event.src_path).endswith('.conf') and str(event.src_path).startswith(CAM_CONFIG_PATH): 108 | global CAMERAS 109 | # Camera removed from config. Notify remote client. 110 | camnum = str(event.src_path).replace(CAM_CONFIG_PATH + "camera-", '') 111 | camnum = camnum.replace('.conf', '') 112 | try: 113 | del CAMERAS[camnum] 114 | except Exception as e: 115 | logger.info("Failed to remove camera config: " + str(e)) 116 | 117 | logger.info("Camera config modified: " + camnum + " removed") 118 | #Update cam configs 119 | await send_cam_configs(self.client, self.room_id) 120 | pass 121 | 122 | #File move events... none 123 | async def on_moved(self, event): 124 | #Currently no configs for on moved files 125 | pass 126 | 127 | #Files closed. Not used 128 | async def on_closed(self, event): 129 | pass 130 | 131 | #File creation events. Thumbnails mostly, but also cameras being added. 132 | async def on_created(self, event): 133 | logger.info(event) 134 | if str(event.src_path).endswith('.thumb'): 135 | logger.info("New motion detect video. Uploading thumbnail") 136 | try: 137 | i = 0 138 | #Try for 10 seconds to wait for the file to be completely written 139 | while not magic.from_file(event.src_path, mime=True).startswith("image/") and i < 10: 140 | await asyncio.sleep(1) 141 | i += 1 142 | result = await send_image(self.client, self.room_id, str(event.src_path), requestor_id="0", msg_type="thumbnail", text=str(event.src_path)) 143 | if result != "success": 144 | msg = '{"type" : "error", "content" :"' + result + '", "requestor_id" : "0"}' 145 | await send_message(self.client, self.room_id, msg) 146 | except Exception as error: 147 | logger.info("Motion detect image thumb send fail" + str(error)) 148 | elif str(event.src_path).endswith('.conf') and str(event.src_path).startswith(CAM_CONFIG_PATH + "camera-"): 149 | global CAMERAS 150 | # New camera added. Notify clients that 151 | i = 0 152 | #Try for 10 seconds to wait for the file to be completely written 153 | while not magic.from_file(event.src_path, mime=True).startswith("text/") and i < 10: 154 | await asyncio.sleep(1) 155 | i += 1 156 | if i < 10: 157 | with open(event.src_path, "r") as f: 158 | #Look for the line with the camera name in it. 159 | for line in f: 160 | if line.startswith('camera_name'): 161 | cam_name = line.replace('camera_name ', '').strip() 162 | camnum = str(event.src_path).replace(CAM_CONFIG_PATH + "camera-", '').replace('.conf','') 163 | CAMERAS[str(camnum)] = cam_name 164 | #Write the new cam config to the config file 165 | logger.info("New camera added: " + camnum + " - " + cam_name) 166 | await send_cam_configs(self.client, self.room_id) 167 | 168 | 169 | #Take a snapshot and upload it to the client, room. Camera number required. 170 | async def snapshot_upload(client, room_id, camera, requestor_id = 0): 171 | impath = RECORDING_PATH + "snapshot.jpg" 172 | command = "curl -m 10 -o " + impath + " http://localhost:8765/picture/" + str(camera) + "/current" 173 | try: 174 | os.system(command) 175 | #Try for 10 seconds to wait for the file to be completely written 176 | i = 0 177 | while not magic.from_file(impath, mime=True).startswith("image/") and i < 10: 178 | await asyncio.sleep(1) 179 | i += 1 180 | 181 | logger.info("Snapshot should be taken. Attempting to upload") 182 | msg_text = str(camera) + "," + str(datetime.now().isoformat()) 183 | result = await send_image(client, room_id, impath, requestor_id = str(requestor_id), msg_type="snapshot-send", text=msg_text) 184 | if result != "success": 185 | msg = '{"type" : "error", "content" :"' + result + '", "requestor_id":"' + str(requestor_id) + '"}' 186 | await send_message(client, room_id, msg) 187 | except Exception as e: 188 | logger.info("Failed to take and upload snapshot: " + str(e)) 189 | 190 | 191 | #Creates file monitor, and event handler, then waits. Watches cam files, and config files. 192 | async def file_monitor(path, client, room_id, cam_path): 193 | handler = EventHandler(client, room_id) 194 | filemonitor = AIOWatchdogWrapper(path, event_handler=handler) 195 | filemonitor.start() 196 | configmonitor = AIOWatchdogWrapper(cam_path, event_handler=handler) 197 | configmonitor.start() 198 | logger.info("Starting File Monitor") 199 | try: 200 | while True: 201 | await asyncio.sleep(5) 202 | except KeyboardInterrupt: 203 | filemonitor.stop() 204 | 205 | 206 | #Dumps matrix config to disk 207 | def write_details_to_disk(resp: LoginResponse, homeserver, room_id) -> None: 208 | # open the config file in write-mode 209 | with open(STORE_PATH + CRED_FILE, "w") as f: 210 | # write the login details to disk 211 | json.dump( 212 | { 213 | "homeserver": homeserver, # e.g. "https://matrix.example.org" 214 | "user_id": resp.user_id, # e.g. "@user:example.org" 215 | "device_id": resp.device_id, # device ID, 10 uppercase letters 216 | "access_token": resp.access_token, # cryptogr. access token 217 | "room_id": room_id, 218 | }, 219 | f 220 | ) 221 | 222 | 223 | #Extracts a time stamp from a given filename. Files are assumed to be in the recording path, and either be .mp4 or .thumb. 224 | #If the above conditions aren't met, it simply returns the current time. 225 | def extract_time_stamp(filename): 226 | tformat = "%Y-%m-%d %H-%M-%S" 227 | try: 228 | t_arr = filename.replace(RECORDING_PATH, '').split('/') 229 | if t_arr[2].endswith('.mp4'): 230 | timestamp = t_arr[1] + " " + t_arr[2].replace('.mp4', '') 231 | elif t_arr[2].endswith('.mp4.thumb'): 232 | timestamp = t_arr[1] + " " + t_arr[2].replace('.mp4.thumb', '') 233 | else: 234 | return datetime.now().isoformat() 235 | return datetime.strptime(timestamp, tformat).isoformat() 236 | except Exception as e: 237 | logger.info("Error in extracting a time stamp for filename: " + filename) 238 | logger.info("Error in extracting a time stamp... Exception: " + e) 239 | return datetime.now().isoformat() 240 | 241 | #Formats a camera configuration send event, and sends it. 242 | async def send_cam_configs(client, room_id): 243 | msg = '{"type" : "cam-config", "content" : "' + str(CAMERAS) + '", "requestor_id" : "0"}' 244 | await send_message(client, room_id, msg) 245 | 246 | #Finds all camera configuration files, and then extracts numbers and names 247 | async def read_cam_configs(): 248 | 249 | #Gets list of files in the config directory 250 | files = os.listdir(CAM_CONFIG_PATH) 251 | 252 | cameras_new = {} 253 | for f in files: 254 | if f.startswith('camera-') and f.endswith('.conf'): 255 | with open(CAM_CONFIG_PATH + f, "r") as c: 256 | for line in c: 257 | if line.startswith('camera_name'): 258 | cam_name = line.replace('camera_name ', '').strip() 259 | camnum = f.replace("camera-", '').replace('.conf','') 260 | cameras_new[str(camnum)] = cam_name 261 | #Write the new cam config to the config file 262 | logger.info("New camera added: " + camnum + " - " + cam_name) 263 | 264 | global CAMERAS 265 | CAMERAS = cameras_new 266 | 267 | # Basic Message send to room. 268 | async def send_message(client, room_id, message_text): 269 | try: 270 | if client.should_upload_keys: 271 | await client.keys_upload() 272 | if client.should_query_keys: 273 | await client.keys_query() 274 | if client.should_claim_keys: 275 | await client.keys_claim(client.get_users_for_key_claiming()) 276 | except Exception as e: 277 | logger.info("Problem synching keys: " + str(e)) 278 | 279 | content = {"msgtype": "m.text", "body": message_text} 280 | try: 281 | await client.room_send( 282 | room_id, 283 | message_type="m.room.message", 284 | content=content, 285 | ignore_unverified_devices=True, 286 | ) 287 | logger.info("Message send success") 288 | except Exception as e: 289 | logger.info("Failed to send message: " + str(e)) 290 | 291 | 292 | # If the room given could be an alias try to resolve it into a room ID - Unused. Private rooms don't use aliases 293 | async def room_id_from_alias(client, alias) -> str: 294 | result = await client.room_resolve_alias(alias) 295 | if isinstance(result, RoomResolveAliasError): 296 | print("Failed to resolve alias") 297 | else: 298 | return result.room_id 299 | 300 | 301 | #Basically just checks if the first digit is a # -- Unused 302 | def alias_check(room_id) -> bool: 303 | if room_id[0] == "#": 304 | return True 305 | return False 306 | 307 | 308 | #Send Image File To Room 309 | async def send_image(client, room_id, image, requestor_id = "0", msg_type = "blank", text = ""): 310 | #Adding support for custom text in image send 311 | if text == "": 312 | text = image 313 | 314 | try: 315 | if client.should_upload_keys: 316 | await client.keys_upload() 317 | except Exception as e: 318 | logger.info("Problem synching keys: " + str(e)) 319 | 320 | #Checks to see if the file is an image format 321 | mime_type = magic.from_file(image, mime=True) 322 | if not mime_type.startswith("image/"): 323 | logger.info("Failed to send image thumb. Mime type problem: " + str(mime_type)) 324 | return "Unable to upload image for one of the following reasons: File doesn't exist, camera is off, another reason" 325 | 326 | im = Image.open(image) 327 | (width, height) = im.size # im.size returns (width,height) tuple 328 | 329 | # first do an upload of image, then send URI of upload to room 330 | file_stat = await aiofiles.os.stat(image) 331 | async with aiofiles.open(image, "r+b") as f: 332 | resp, decrypt_keys = await client.upload( 333 | f, 334 | content_type=mime_type, # image/jpeg 335 | filename=os.path.basename(image), 336 | filesize=file_stat.st_size, 337 | encrypt=True # Always encrypt 338 | ) 339 | if (isinstance(resp, UploadResponse)): 340 | logger.info("Image was uploaded successfully to server. ") 341 | else: 342 | logger.info(f"Failed to upload image. Failure response: {resp}") 343 | return "Failed to upload file" 344 | 345 | if msg_type == "snapshot-send": 346 | msg = '{"type":"' + msg_type + '", "content" : "' + text + '", "requestor_id":"' + requestor_id + '"}' 347 | else: 348 | msg = '{"type":"' + msg_type + '", "content" : "' + text + "," + str(extract_time_stamp(image)) + '", "requestor_id":"' + requestor_id + '"}' 349 | 350 | 351 | # Now that the image has been uploaded, we need to message the room with this uploaded file. 352 | content = { 353 | "body": msg, 354 | "info": { 355 | "size": file_stat.st_size, 356 | "mimetype": mime_type, 357 | "thumbnail_info": None, # This image is the thumbnail! 358 | "w": width, # width in pixel 359 | "h": height, # height in pixel 360 | "thumbnail_url": None, # This image is the thumbnail! 361 | }, 362 | "msgtype": "m.image", 363 | "file": { 364 | "url": resp.content_uri, 365 | "key": decrypt_keys["key"], 366 | "iv": decrypt_keys["iv"], 367 | "hashes": decrypt_keys["hashes"], 368 | "v": decrypt_keys["v"], 369 | }, 370 | } 371 | 372 | try: 373 | await client.room_send( 374 | room_id, 375 | message_type="m.room.message", 376 | content=content, 377 | ignore_unverified_devices=True, 378 | ) 379 | logger.info("Image send successful") 380 | except Exception as e: 381 | logger.info("Image send failure: " + str(e)) 382 | return "Image send of file failed." 383 | 384 | return "success" 385 | 386 | 387 | def get_motion_config_port(): 388 | try: 389 | with open(CAM_CONFIG_PATH + "motioneye.conf") as f: 390 | for line in f: 391 | if line.startswith("motion_control_port"): 392 | port = int(line.replace("motion_control_port ", "").strip()) 393 | except Exception as e: 394 | logger.info(e) 395 | 396 | if isinstance(port, int): 397 | return str(port) 398 | return "fail" 399 | 400 | 401 | async def record_video(client, room_id, duration=10, cam_id="1"): 402 | 403 | port = get_motion_config_port() 404 | logger.info("port: " + port) 405 | if port == "fail": 406 | err = '{"type" : "error", "content" : "Unable to find motion config port. Cannot trigger recording manually.", "requestor_id":"0"}' 407 | send_message(client, room_id, err) 408 | else: 409 | command = 'curl "http://localhost:' + port + '/' + cam_id + '/config/set?emulate_motion=1"' 410 | try: 411 | logger.info("Triggering simulated motion for " + str(duration) + " seconds") 412 | os.system(command) 413 | await asyncio.sleep(duration) 414 | command = 'curl "http://localhost:' + port + '/' + cam_id + '/config/set?emulate_motion=0"' 415 | os.system(command) 416 | logger.info("Simulated motion ended.") 417 | except Exception as e: 418 | logger.info(e) 419 | return 420 | 421 | 422 | #Send Video File to room. 423 | async def send_video(client, room_id, video, msg_type="blank", requestor_id="0"): 424 | try: 425 | if client.should_upload_keys: 426 | await client.keys_upload() 427 | except Exception as e: 428 | logger.info("Problem synching keys: " + str(e)) 429 | 430 | #Make sure file is video type 431 | mime_type = magic.from_file(video, mime=True) 432 | if not mime_type.startswith("video/"): 433 | logger.info("Drop message because file does not have a video mime type.") 434 | return "Failed to send, bad file or file type" 435 | 436 | # first do an upload of the video, then send URI of upload to room 437 | file_stat = await aiofiles.os.stat(video) 438 | async with aiofiles.open(video, "r+b") as f: 439 | resp, decrypt_keys = await client.upload( 440 | f, 441 | content_type=mime_type, 442 | filename=os.path.basename(video), 443 | filesize=file_stat.st_size, 444 | encrypt=True # Always encrypt 445 | ) 446 | 447 | # Check response 448 | if (isinstance(resp, UploadResponse)): 449 | logger.info("Video send success: " + video) 450 | else: 451 | logger.info("Video send fail: " + str(resp)) 452 | return "Video send of file " + video + "failed." 453 | 454 | # Now that the video has been uploaded, we need to message the room with this uploaded file. 455 | msg = '{"type":"' + msg_type + '", "content" : "' + str(video) + "," + str(extract_time_stamp(video)) + '", "requestor_id":"' + requestor_id + '"}' 456 | 457 | #Build content package for message 458 | content = { 459 | "body": msg, 460 | "info": {"size": file_stat.st_size, "mimetype": mime_type}, 461 | "msgtype":"m.video", 462 | "file": { 463 | "url": resp.content_uri, 464 | "key": decrypt_keys["key"], 465 | "iv": decrypt_keys["iv"], 466 | "hashes": decrypt_keys["hashes"], 467 | "v": decrypt_keys["v"], 468 | }, 469 | } 470 | 471 | #Send video message 472 | try: 473 | await client.room_send( 474 | room_id, 475 | message_type="m.room.message", 476 | content=content, 477 | ignore_unverified_devices=True, 478 | ) 479 | logger.info("Video send success: " + video) 480 | except Exception as e: 481 | logger.info("Video send fail: " + str(e)) 482 | return "Video send of file " + video + "failed." 483 | return "success" 484 | 485 | #List video thumbnails of a specified directory in the chat room. 486 | async def send_recording_list(client, room_id, date_range, files_in_range, msg_type = "list-video-response", requestor_id = "0"): 487 | try: 488 | details = {'date_range':date_range} 489 | details['recordings'] = files_in_range 490 | msg = '{"type" : "' + msg_type + '", "content" : "' + str(details) + '", "requestor_id" : "' + requestor_id + '"}' #Construct json formatted message 491 | await send_message(client, room_id, msg) #Send complete message to the chat room 492 | except Exception as e: 493 | logger.info(e) 494 | 495 | 496 | #Callbacks list for matrix listening client. 497 | class Callback(): 498 | def __init__(self, client, room_id) -> None: 499 | self.client = client 500 | self.room_id = room_id 501 | 502 | #Function that checks for received message types, and executes requests 503 | async def message_receive_callback(self, room, event) -> None: 504 | if(isinstance(event, RoomMessageText)) : 505 | logger.info(f"Event Received: {event}") 506 | 507 | #attempt to parse messages 508 | try: 509 | #Load in data as JSON 510 | message_data = json.loads(event.body) 511 | logger.info("JSON: " + str(message_data)) 512 | 513 | #Check type in message data 514 | #Snapshot indicates a request for a live picture from a camera. 515 | if message_data['type'] == "snapshot": 516 | logger.info("Attempting to upload snapshot.") 517 | await snapshot_upload(self.client, self.room_id, message_data['content'], requestor_id = message_data['requestor_id']) 518 | logger.info("Snapshot taken, and uploaded for camera " + str(message_data.camera) + " - for - " + message_data['requestor_id']) 519 | 520 | #Video-request is an upload request for a video that matches a supplied thumbnail. 521 | if message_data['type'] == "video-request": 522 | if message_data['content'].endswith('.thumb'): 523 | logger.info("Attempting to upload video: " + message_data['content'][:-6]) 524 | result = await send_video(self.client, self.room_id, message_data['content'][:-6], msg_type="video-send", requestor_id = message_data['requestor_id']) 525 | if result != 'success': 526 | msg = '{"type" : "error", "content" : "' + result + '", "requestor_id" : "' + message_data['requestor_id'] + '"}' 527 | await send_message(self.client, self.room_id, msg) 528 | else: 529 | logger.info("Improperly formatted command: " + message_data['content']) 530 | 531 | #thumb-request is a request to upload the thumbnail for the file specified. 532 | if message_data['type'] == "thumb-request": 533 | if message_data['content'].endswith('.thumb'): 534 | logger.info("Attempting to upload thumbnail for file: " + message_data['content']) 535 | result = await send_image(self.client, self.room_id, message_data['content'], requestor_id = message_data['requestor_id'], msg_type = "thumb-reply", text = message_data['content']) 536 | if result != 'success': 537 | msg = '{"type" : "error", "content" : "' + result + '", "requestor_id" : "' + message_data['requestor_id'] + '"}' 538 | await send_message(self.client, self.room_id, msg) 539 | else: 540 | logger.info("Improperly formatted command: " + message_data['content']) 541 | 542 | #Cam config request is a general get request for all cameras. 543 | if message_data['type'] == "cam-config-request": 544 | await send_cam_configs(self.client, self.room_id) 545 | 546 | #Record request. 547 | if message_data['type'] == "record-video": 548 | try: 549 | params = message_data['content'].strip().split(",") 550 | cam = params[0] 551 | dur = int(params[1]) 552 | logger.info("Video recording params: " + str(params)) 553 | if dur > 300: 554 | dur = 300 555 | elif dur < 1: 556 | dur = 1 557 | await record_video(self.client, self.room_id, dur, cam) 558 | except Exception as e: 559 | msg = '{"type" : "error", "content" : "Failed to trigger recording. Check request format", "requestor_id" : "' + message_data['requestor_id'] + '"}' 560 | await send_message(self.client,self.room_id,msg) 561 | logger.info("Failed to record video" + str(e)) 562 | 563 | #List stored video thumbnails by date 564 | if message_data['type'] == "list-recordings": 565 | try: 566 | all_recordings = os.scandir(path = RECORDING_PATH) #Capture camera file paths 567 | 568 | date_range = message_data['content'].strip().split(",") #Command parameters are comma-separated 569 | startDate = datetime.fromisoformat(date_range[0].strip()) #Convert to datetime 570 | start = str(startDate) #Convert to string to allow split parsing 571 | startSplit = start.split(" ") #Separate date and time 572 | startDSplit = startSplit[0].split("-") #Parse out start date 573 | dstart = date(int(startDSplit[0]),int(startDSplit[1]),int(startDSplit[2])) #Create date type from date numbers 574 | endDate = datetime.fromisoformat(date_range[1].strip()) #Convert to datetime 575 | end = str(endDate) #Convert to string to allow split parsing 576 | endSplit = end.split(" ") #Separate date and time 577 | endDSplit = endSplit[0].split("-") #Parse out end date 578 | dend = date(int(endDSplit[0]),int(endDSplit[1]),int(endDSplit[2])) #Create date type from date numbers 579 | logger.info("Dates extraced from message. Attempting to scan files.") 580 | files_in_range = [] #Holds file paths in range 581 | tformat = "%Y-%m-%d %H-%M-%S" #Sets datetime formatting 582 | for cam in all_recordings: #Loop through camera directories 583 | if(cam.name.startswith('Cam')): #Exclude snapshot 584 | dates = os.scandir(cam.path) #Scan camera directory for date subdirectories 585 | for d in dates: #Loop through date directories 586 | if(d.name != "lastsnap.jpg") and not(d.name.startswith('.')): #Exclude lastsnap, hidden files 587 | nameSplit = d.name.split("-") #Parse date name 588 | dname = date(int(nameSplit[0]), int(nameSplit[1]), int(nameSplit[2])) #Convert to date type 589 | if dname >= dstart and dname <= dend: #Compare dates 590 | camRecords = os.scandir(d.path) #Scan date directory for video recordings 591 | for f in camRecords: #Loop through date directories 592 | if f.name.endswith(".mp4.thumb"): 593 | file = os.path.splitext(f.name) #Parse out .thumb extension 594 | file = os.path.splitext(file[0]) #Parse out .mpt extension 595 | dt = d.name + " " + file[0] #Construct datetime format 596 | dtf = datetime.strptime(dt, tformat) #Convert to datetime.datetime type 597 | if dtf >= startDate and dtf <= endDate: #Compare datetime of file 598 | logger.info("Found Path in range: " + f.path) 599 | files_in_range.append([f.path, str(extract_time_stamp(f.path))]) #A file that passes above checks must be within specified date range, add to list 600 | await send_recording_list(self.client, self.room_id, message_data['content'], files_in_range, msg_type = "list-recording-reply", requestor_id = message_data['requestor_id']) 601 | 602 | except Exception as e: 603 | msg = '{"type" : "error", "content" : "Failed to list media directory contents. Check request format", "requestor_id" : "' + message_data['requestor_id'] + '"}' 604 | await send_message(self.client, self.room_id, msg) 605 | logger.info("Failed to list contents of media directory" + str(e)) 606 | 607 | #Default other messages 608 | else: 609 | logger.info("Message not for this client, or improperly formatted") 610 | except Exception as e: 611 | logger.error("Action on incoming message error: " + str(e)) 612 | else: 613 | logger.info("Event not a room message, or encrypted: " + str(event)) 614 | message_data = event.source 615 | if(message_data['type'] == 'm.room.encrypted'): 616 | try: 617 | logger.info("Trying to get missing sessions and keys") 618 | event.as_key_request(message_data['sender'], self.client.device_id, event.session_id) 619 | key_requests = self.client.get_active_key_requests(event.sender, event.device_id) 620 | missing_sessions = self.client.get_missing_sessions(self.room_id) 621 | users_for_keys = self.client.get_users_for_key_claiming() 622 | logger.info("Session: " + event.session_id + " ... Active key requests: " + str(key_requests)) 623 | logger.info("Missing sessions: " + str(missing_sessions)) 624 | logger.info("Get Users for Key claiming: " + str(users_for_keys)) 625 | await self.client.keys_claim(self.client.get_missing_sessions(self.room_id)) 626 | await self.client.keys_query() 627 | await self.client.keys_claim(self.client.get_users_for_key_claiming()) 628 | decrypted_event = await self.client.decrypt_event(event) 629 | await self.message_receive_callback(room, decrypted_event) 630 | except Exception as e: 631 | logger.info("Problem in synch and decrypt event: " + str(e)) 632 | await send_message(self.client, self.room_id, ("Unable to decrypt a message. No session exists for: " + event.session_id + ". Please upload one time keys so a session can be established.")) 633 | 634 | else: 635 | await self.client.keys_claim(self.client.get_missing_sessions(self.room_id)) 636 | if self.client.should_query_keys: 637 | await self.client.keys_query() 638 | if self.client.should_claim_keys: 639 | await self.client.keys_claim(self.client.get_users_for_key_claiming()) 640 | return 641 | 642 | 643 | #Listener creates the callbacks class and then says to wait forever. 644 | async def start_listening(client, room_id) -> None: 645 | callback = Callback(client, room_id) 646 | client.add_event_callback(callback.message_receive_callback, (RoomMessageText, RoomMessage, Event)) 647 | while True: 648 | try: 649 | await client.sync_forever(timeout=30000, full_state=True) 650 | except Exception as e: 651 | logger.info("Client syncing failed. Will try again: " + str(e)) 652 | 653 | 654 | 655 | #Attempts to login, checks for config file, or prompts user for input. 656 | async def login() -> AsyncClient: 657 | # If there are no previously-saved credentials, we'll use the password 658 | if not os.path.exists(STORE_PATH + CRED_FILE): 659 | logger.debug("First Run. Gathering info to log in to matrix") 660 | print("First time use. Did not find credential file. Asking for " 661 | "homeserver, user, and password to create credential file.") 662 | homeserver = "https://matrix.example.org" 663 | homeserver = input(f"Enter your homeserver URL: [{homeserver}] ") 664 | 665 | if not (homeserver.startswith("https://") 666 | or homeserver.startswith("http://")): 667 | homeserver = "https://" + homeserver 668 | 669 | user_id = "@user:example.org" 670 | user_id = input(f"Enter your full user ID: [{user_id}] ") 671 | 672 | device_name = "matrix-nio" 673 | device_name = input(f"Choose a name for this device: [{device_name}] ") 674 | 675 | client_config = AsyncClientConfig( 676 | max_limit_exceeded=0, 677 | max_timeouts=0, 678 | store_sync_tokens=True, 679 | encryption_enabled=True, 680 | ) 681 | 682 | client = AsyncClient( 683 | homeserver, 684 | user_id, 685 | store_path=STORE_PATH, 686 | config=client_config, 687 | ) 688 | pw = getpass.getpass() 689 | 690 | room_id = "!someroom:matrix.org" 691 | room_id = input(f"Enter room id for client: [{room_id}] ") 692 | 693 | logger.info("Attempting to log in...") 694 | logger.info("Homeserver: " + homeserver) 695 | logger.info("User ID: " + user_id) 696 | logger.info("Device Name: " + device_name) 697 | logger.info("Room ID: " + room_id) 698 | 699 | 700 | resp = await client.login(pw, device_name=device_name) 701 | 702 | # check that we logged in succesfully 703 | if (isinstance(resp, LoginResponse)): 704 | write_details_to_disk(resp, homeserver, room_id) 705 | logger.info("Login Successful. Credentials saved to disk.") 706 | else: 707 | logger.error(f"Failed to log in: {resp}") 708 | logger.error("Exiting...") 709 | print(f"homeserver = \"{homeserver}\"; user = \"{user_id}\"") 710 | print(f"Failed to log in: {resp}") 711 | sys.exit(1) 712 | 713 | print( 714 | "Logged in using a password successfully. Credentials were stored.", 715 | ) 716 | 717 | # Otherwise the config file exists, so we'll use the stored credentials 718 | else: 719 | # open the file in read-only mode 720 | logger.info("Credentials file found. Restoring login") 721 | 722 | try: 723 | with open(STORE_PATH + CRED_FILE, "r") as f: 724 | 725 | #Setup config 726 | client_config = AsyncClientConfig( 727 | max_limit_exceeded=0, 728 | max_timeouts=0, 729 | store_sync_tokens=True, 730 | encryption_enabled=True, 731 | ) 732 | config = json.load(f) 733 | 734 | #Create client set configs 735 | client = AsyncClient( 736 | config['homeserver'], 737 | store_path=STORE_PATH, 738 | config=client_config) 739 | client.access_token = config['access_token'] 740 | client.user_id = config['user_id'] 741 | client.device_id = config['device_id'] 742 | #Restore login info 743 | client.restore_login( 744 | user_id=config['user_id'], device_id=config['device_id'], access_token=config['access_token']) 745 | room_id=config['room_id'] 746 | logger.info("Login restored") 747 | except Exception as e: 748 | logger.error("Failed to restore login. Try deleting credentials file and logging back in: " + str(e)) 749 | 750 | # Keys, syncing with server. Retry on failure 751 | while True: 752 | try: 753 | if client.should_upload_keys: 754 | await client.keys_upload() 755 | 756 | await client.sync(timeout=30000, full_state=True) 757 | logger.info("Client synch complete") 758 | return client, room_id 759 | except Exception as e: 760 | logger.error("Failed to sync with server. Waiting 20 seconds and trying again: " + str(e)) 761 | await asyncio.sleep(20) 762 | 763 | 764 | #Main shall log in, and then create the file listener task, and the matrix monitor task. 765 | async def main(): 766 | client, room_id = await login() 767 | #Update room with current cam configs 768 | await read_cam_configs() 769 | await send_cam_configs(client, room_id) 770 | 771 | logger.info("Login complete. Attempting to create event loop") 772 | loop = asyncio.get_event_loop() 773 | loop.create_task(listen(client, room_id)) 774 | loop.create_task(start_monitor(client, room_id)) 775 | logger.info("File monitor, message listener now running") 776 | 777 | # Login and start listening 778 | async def listen(client, room_id): 779 | await start_listening(client, room_id) 780 | 781 | # Log in and start monitoring files 782 | async def start_monitor(client, room_id): 783 | await file_monitor(RECORDING_PATH, client, room_id, CAM_CONFIG_PATH) 784 | 785 | #Check if config file exists. If not, login for first time. 786 | if not os.path.exists(STORE_PATH + CRED_FILE): 787 | asyncio.run(login()) 788 | print("You may now run the program normally via the daemon, or restart it") 789 | else: 790 | loop = asyncio.get_event_loop() 791 | loop.create_task(main()) 792 | loop.run_forever() 793 | -------------------------------------------------------------------------------- /ossc_client_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ### BEGIN INIT INFO 4 | # Provides: ossc_client 5 | # Required-Start: $remote_fs $syslog 6 | # Required-Stop: $remote_fs $syslog 7 | # Default-Start: 2 3 4 5 8 | # Default-Stop: 0 1 6 9 | # Short-Description: ossc_client 10 | # Description: Open Source Security Camera Client 11 | ### END INIT INFO 12 | 13 | source /lib/lsb/init-functions 14 | 15 | do_start () { 16 | log_daemon_msg "Starting system ossc_client daemon" 17 | start-stop-daemon --start --background --pidfile /var/run/ossc_client.pid --make-pidfile --user root --chuid root --startas /usr/bin/python3 /var/lib/ossc_client/ossc_client.py 18 | log_end_msg $? 19 | } 20 | do_stop () { 21 | log_daemon_msg "Stopping system ossc_client daemon" 22 | start-stop-daemon --stop --pidfile /var/run/ossc_client.pid --retry 10 23 | log_end_msg $? 24 | } 25 | 26 | case "$1" in 27 | start|stop) 28 | do_${1} 29 | ;; 30 | restart|reload|force-reload) 31 | do_stop 32 | do_start 33 | ;; 34 | status) 35 | status_of_proc "ossc_client" "DAEMON" && exit 0 || exit $? 36 | ;; 37 | *) 38 | echo "Usage: ossc_client {start|stop|restart|reload|force-reload|status}" 39 | ;; 40 | 41 | esac 42 | exit 0 --------------------------------------------------------------------------------