diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..ae932d2
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,24 @@
+{
+ "version": "0.2",
+ "configurations": [
+ {
+ "name": "Python: cctv-scheduler",
+ "type": "python",
+ "request": "launch",
+ "program": "${file}",
+ "console": "integratedTerminal",
+ "justMyCode": true
+ },
+ {
+ "name": "Python: cctv-scheduler -s",
+ "type": "python",
+ "request": "launch",
+ "program": "${file}",
+ "args": [
+ "-s"
+ ],
+ "console": "integratedTerminal",
+ "justMyCode": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 4bb2ffc..e53bdd7 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,16 @@
# cctv-scheduler
PTZ IP-Camera management
+____
-![cctv-scheduler](info/images/cctv-scheduler-0.1.png)
+- [`cctv-scheduler.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#cctv-scheduler-py)
+- [`converter.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#converter-sh)
+- [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh)
+- [`streaming.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#streaming-py)
+
+____
+
+![cctv-scheduler](info/images/cctv-scheduler-0.2.png)
## `Installation`
@@ -22,8 +30,8 @@ Look at the description of dependencies and install the necessary.
Download scripts and configs.
```bash
-wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/sequences.sh -O /home/user/cctv-scheduler/sequences.sh
-wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/sequences.conf -O /home/user/cctv-scheduler/sequences.conf
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.py -O /home/user/cctv-scheduler/cctv-scheduler.py
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.conf -O /home/user/cctv-scheduler/cctv-scheduler.conf
wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/converter.sh -O /home/user/cctv-scheduler/converter.sh
wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/converter.conf -O /home/user/cctv-scheduler/converter.conf
wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.sh -O /home/user/cctv-scheduler/publisher.sh
@@ -37,7 +45,7 @@ wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/strea
Edit configs.
```bash
-nano /home/user/cctv-scheduler/sequences.conf
+nano /home/user/cctv-scheduler/cctv-scheduler.conf
nano /home/user/cctv-scheduler/converter.conf
nano /home/user/cctv-scheduler/publisher.conf
```
@@ -50,19 +58,10 @@ Look at examples and edit scheduler tasks:
crontab -e
```
-____
-
-- [`sequences.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#sequences-sh)
-- [`converter.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#converter-sh)
-- [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh)
-- [`streaming.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#streaming-py)
-
-____
-
-## `sequences`.sh
+## `cctv-scheduler`.py
**Description:**
-> [Hikvision](https://git.hmp.today/pavel.muhortov/cctv-scheduler/src/branch/master/info/hikvision/manual/isapi.pdf) PTZ-camera sequences.
+> [Hikvision](https://git.hmp.today/pavel.muhortov/cctv-scheduler/src/branch/master/info/hikvision/manual/isapi.pdf) PTZ IP-Camera management.
> Additionally:
>
> - getting temperature from DS18B20 over SSH,
@@ -72,26 +71,27 @@ ____
**Dependencies:**
>
-> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
-> - [curl](https://curl.se/download.html) (tested version 7.74 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
-> - [sshpass](https://www.cyberciti.biz/faq/noninteractive-shell-script-ssh-password-provider/) (tested version 1.09 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [Python 3](https://www.python.org/downloads/) (tested version 3.9.5 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [paramiko](https://www.paramiko.org/) Python 3 module (tested version 3.1.0)
-| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
-|-----------|--------------|------------------------|---------------|
-| 1 | **[qn]** |execution without pauses||
-| 2 | **[/path/to/conf]** |path to config| ./sequences.conf |
+| PARAMETERS | DESCRIPTION | DEFAULT|
+|-------------|-------------|--------|
+|**[-h]**|print help and exit||
+|**[-s, --sequences]**|run sequences from config file|`None`|
+|**[--config]**|custom configuration file path|`./cctv-scheduler.conf`|
-Example usage in terminal with bash:
+Example usage in terminal with make the script executable:
```bash
-bash ./sequences.sh - ./sequences.conf
+chmod u+x ./cctv-scheduler.py
+./cctv-scheduler.py -s --config /home/user/cctv-scheduler/cctv-scheduler.conf
```
Example usage with cron:
```bash
# crontab -e
-0 * * * * bash /home/user/cctv-scheduler/sequences.sh qn
+0 * * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -s
```
____
@@ -132,8 +132,8 @@ ____
| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
|-----------|--------------|------------------------|---------------|
| 1 | **[qn]** |execution without pauses||
-| 2 | **[/path/to/conf]** |path to config| ./converter.conf |
-| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - today \| '-d' - yesterday \| '-w' - last week \| '-m' - last month \| '-y' - last year||
+| 2 | **[/path/to/conf]** |path to config| `./converter.conf` |
+| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - today \| '-d' - yesterday \| '-w' - last week \| '-m' - last month \| '-y' - last year|`''`|
Example usage in terminal with bash for today's MP4 making:
@@ -176,9 +176,9 @@ ____
| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
|-----------|--------------|------------------------|---------------|
| 1 | **[qn]** |execution without pauses||
-| 2 | **[/path/to/conf]** |path to config| ./converter.conf |
-| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - 0 day \| '-d' - -X day \| '-w' - -X week \| '-m' - -X month \| '-y' - -X year||
-| 4 | **[1\|2\|3..XXX]** |multiplier for period: '' - 1 day\|week\|month\|year|1|
+| 2 | **[/path/to/conf]** |path to config| `./publisher.conf` |
+| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - 0 day \| '-d' - -X day \| '-w' - -X week \| '-m' - -X month \| '-y' - -X year|`''`|
+| 4 | **[1\|2\|3..XXX]** |multiplier for period: '' - 1 day\|week\|month\|year|`1`|
| 5 | **[--onlytg\|--onlywp]** |'--onlytg' - only publish to Telegram \|'--onlywp' - only publish to Wordpress||
Example usage in terminal with bash for publish to Telegram today's MP4:
@@ -235,11 +235,3 @@ Example usage with cron:
* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s rtsp://user:pass@host:554/Streaming/Channels/video,http://Streaming/Channels/audio --dst rtmp://a.rtmp.youtube.com/live2/YOUKEY --mono --watchdog --sec 30 >> /dev/null 2>&1
* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s ~/media.mp4 --dst rtmp://b.rtmp.youtube.com/live2?backup=1/YOUKEY --mono >> /dev/null 2>&1
```
-
-Example usage in Python:
-
-```Python
-from streaming import FFmpeg
-
-FFmpeg.run(src='null, anull', preset='240p', fps=10)
-```
diff --git a/archive/0.1/README.md b/archive/0.1/README.md
new file mode 100644
index 0000000..4bb2ffc
--- /dev/null
+++ b/archive/0.1/README.md
@@ -0,0 +1,245 @@
+# cctv-scheduler
+
+PTZ IP-Camera management
+
+![cctv-scheduler](info/images/cctv-scheduler-0.1.png)
+
+## `Installation`
+
+### `Requirements`
+
+Cameras settings:
+>
+> - Configuration -> System -> Security -> Authentication -> RTSP Authentication: digest/basic
+> - Configuration -> System -> Security -> Authentication -> WEB Authentication: digest/basic
+> - Configuration -> Network -> Advanced Settings -> Integration Protocol -> Enable Hikvision-CGI: Enabled
+> - Configuration -> Network -> Advanced Settings -> Integration Protocol -> Hikvision-CGI Authentication: digest/basic
+
+Look at the description of dependencies and install the necessary.
+
+### `Downloading`
+
+Download scripts and configs.
+
+```bash
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/sequences.sh -O /home/user/cctv-scheduler/sequences.sh
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/sequences.conf -O /home/user/cctv-scheduler/sequences.conf
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/converter.sh -O /home/user/cctv-scheduler/converter.sh
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/converter.conf -O /home/user/cctv-scheduler/converter.conf
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.sh -O /home/user/cctv-scheduler/publisher.sh
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.conf -O /home/user/cctv-scheduler/publisher.conf
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher-template-page-1007.xml -O /home/user/cctv-scheduler/publisher-template-page-1007.xml
+wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/streaming.py -O /home/user/cctv-scheduler/streaming.py
+```
+
+### `Configuration`
+
+Edit configs.
+
+```bash
+nano /home/user/cctv-scheduler/sequences.conf
+nano /home/user/cctv-scheduler/converter.conf
+nano /home/user/cctv-scheduler/publisher.conf
+```
+
+### `Scheduler`
+
+Look at examples and edit scheduler tasks:
+
+```bash
+crontab -e
+```
+
+____
+
+- [`sequences.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#sequences-sh)
+- [`converter.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#converter-sh)
+- [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh)
+- [`streaming.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#streaming-py)
+
+____
+
+## `sequences`.sh
+
+**Description:**
+> [Hikvision](https://git.hmp.today/pavel.muhortov/cctv-scheduler/src/branch/master/info/hikvision/manual/isapi.pdf) PTZ-camera sequences.
+> Additionally:
+>
+> - getting temperature from DS18B20 over SSH,
+> - saving pictures to FTP.
+>
+> This is only a local "proof of conept" for testing and debugging.
+
+**Dependencies:**
+>
+> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [curl](https://curl.se/download.html) (tested version 7.74 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [sshpass](https://www.cyberciti.biz/faq/noninteractive-shell-script-ssh-password-provider/) (tested version 1.09 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+
+| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
+|-----------|--------------|------------------------|---------------|
+| 1 | **[qn]** |execution without pauses||
+| 2 | **[/path/to/conf]** |path to config| ./sequences.conf |
+
+Example usage in terminal with bash:
+
+```bash
+bash ./sequences.sh - ./sequences.conf
+```
+
+Example usage with cron:
+
+```bash
+# crontab -e
+0 * * * * bash /home/user/cctv-scheduler/sequences.sh qn
+```
+
+____
+
+## `converter`.sh
+
+**Description:**
+> JPEG to MP4 converter.
+>
+> This is only a local "proof of conept" for testing and debugging.
+
+**Dependencies:**
+>
+> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - filesystem organization:
+>
+>```bash
+> # filesystem organisation example
+>/root/
+> /2022/
+> /12/
+> /52/
+> /31/
+> /image-01_2022.12.31_time.jpeg
+> /image-02_2022.12.31_time.jpeg
+> /2023/
+> /01/
+> /01/
+> /02/
+> /image-01_2023.01.02_time.jpeg
+> /image-02_2023.01.02_time.jpeg
+> /03/
+> /image-01_2023.01.03_time.jpeg
+> /image-02_2023.01.03_time.jpeg
+>```
+
+| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
+|-----------|--------------|------------------------|---------------|
+| 1 | **[qn]** |execution without pauses||
+| 2 | **[/path/to/conf]** |path to config| ./converter.conf |
+| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - today \| '-d' - yesterday \| '-w' - last week \| '-m' - last month \| '-y' - last year||
+
+Example usage in terminal with bash for today's MP4 making:
+
+```bash
+bash ./converter.sh - ./converter.conf
+```
+
+Example usage with cron:
+
+```bash
+# crontab -e
+1 0 * * * bash /home/user/cctv-scheduler/converter.sh qn - -d
+7 0 * * 1 bash /home/user/cctv-scheduler/converter.sh qn - -w
+30 0 1 * * bash /home/user/cctv-scheduler/converter.sh qn - -m
+36 0 1 1 * bash /home/user/cctv-scheduler/converter.sh qn - -y
+```
+
+____
+
+## `publisher`.sh
+
+**Description:**
+> Uploading MP4 to [Wordpress](https://wordpress.com/) and [Telegram](https://web.telegram.org/).
+> Additionally:
+>
+> - editing [Wordpress](https://codex.wordpress.org/XML-RPC_WordPress_API) page from template
+> - recompressing video if size [over 50MB](https://core.telegram.org/bots/api#sendvideo)
+>
+> This is only a local "proof of conept" for testing and debugging.
+
+**Dependencies:**
+>
+> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [curl](https://curl.se/download.html) (tested version 7.74 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [libxml2-utils](https://gitlab.gnome.org/GNOME/libxml2) (tested version 2.9.10 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [jq](https://stedolan.github.io/jq/download/) (tested version 1.6 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+>
+
+| POSITION | PARAMETERS | DESCRIPTION | DEFAULT |
+|-----------|--------------|------------------------|---------------|
+| 1 | **[qn]** |execution without pauses||
+| 2 | **[/path/to/conf]** |path to config| ./converter.conf |
+| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - 0 day \| '-d' - -X day \| '-w' - -X week \| '-m' - -X month \| '-y' - -X year||
+| 4 | **[1\|2\|3..XXX]** |multiplier for period: '' - 1 day\|week\|month\|year|1|
+| 5 | **[--onlytg\|--onlywp]** |'--onlytg' - only publish to Telegram \|'--onlywp' - only publish to Wordpress||
+
+Example usage in terminal with bash for publish to Telegram today's MP4:
+
+```bash
+bash ./publisher.sh - ./publisher.conf - - - - --onlytg
+```
+
+Example usage with cron:
+
+```bash
+# crontab -e
+1 1 * * * bash /home/user/cctv-scheduler/publisher.sh qn - -d
+7 1 * * 1 bash /home/user/cctv-scheduler/publisher.sh qn - -w
+30 1 1 * * bash /home/user/cctv-scheduler/publisher.sh qn - -m
+36 1 1 1 * bash /home/user/cctv-scheduler/publisher.sh qn - -y
+```
+
+____
+
+## `streaming`.py
+
+**Description:**
+> FFmpeg management from Python
+
+**Dependencies:**
+>
+> - [Python 3](https://www.python.org/downloads/) (tested version 3.9.5 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/))
+
+| PARAMETERS | DESCRIPTION | DEFAULT|
+|-------------|-------------|--------|
+|**-s**, **--src**|sources urls|**REQUIRED**|
+|**[-h]**|print help and exit||
+|**[--preset]**|240p, 360p, 480p, 720p, 1080p, 1440p, 2160p|`None`|
+|**[--fps]**|frame per second encoding output|`None`|
+|**[--dst]**|destination url|`None`|
+|**[--ffpath]**|alternative path to bin|`None`|
+|**[--watchdog]**|detect ffmpeg freeze and terminate||
+|**[--sec]**|seconds to wait before the watchdog terminates|15|
+|**[--mono]**|detect ffmpeg running copy and terminate||
+
+Example usage in terminal with make the script executable:
+
+```bash
+chmod u+x ./streaming.py
+./streaming.py -s rtsp://user:pass@host:554/Streaming/Channels/101 --dst rtp://239.0.0.1:5554
+```
+
+Example usage with cron:
+
+```bash
+# crontab -e
+* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s rtsp://user:pass@host:554/Streaming/Channels/video,http://Streaming/Channels/audio --dst rtmp://a.rtmp.youtube.com/live2/YOUKEY --mono --watchdog --sec 30 >> /dev/null 2>&1
+* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s ~/media.mp4 --dst rtmp://b.rtmp.youtube.com/live2?backup=1/YOUKEY --mono >> /dev/null 2>&1
+```
+
+Example usage in Python:
+
+```Python
+from streaming import FFmpeg
+
+FFmpeg.run(src='null, anull', preset='240p', fps=10)
+```
diff --git a/cctv-scheduler.conf b/cctv-scheduler.conf
new file mode 100644
index 0000000..36acf93
--- /dev/null
+++ b/cctv-scheduler.conf
@@ -0,0 +1,81 @@
+[common]
+# By default, a temporary files directory is created in the same path where the script is located.
+# If you need change it, uncomment the parameter and set the path you want.
+#temp_path = /tmp/cctv-scheduler
+#
+# By default, logs use the same directory where the script is located.
+# If you need change it, uncomment the parameter and set the path you want.
+#log_root = /var/log/cctv-scheduler
+#
+# The default log level is "INFO".
+# If you get errors or want to change the logging level, uncomment the parameter and set the level you want:
+# DEBUG, INFO, WARNING, ERROR, CRITICAL.
+#log_level = DEBUG
+
+
+[enable-sensors]
+# List the sensor block names. Only blocks with the TRUE value will be used.
+sensor.test.local = true
+
+
+[sensor-config:sensor.test.local]
+# Remote host's sensor parameter description block always starts with "sensor-config:".
+hostname = 192.168.254.252
+username = user
+userpass = pass
+# To recognize options for polling a sensor, you must specify the type of sensor.
+# Supported types:
+# ds18b20
+nodetype = ds18b20
+nodename = 28-1a2b3c4d5e6f
+
+
+[enable-sequences]
+# List the sequence/camera block names. Only blocks with the TRUE value will be used.
+camera.test.local = true
+
+
+[camera-config:camera.test.local]
+# Camera parameter description block always starts with "camera-config:".
+hostname = 192.168.254.253
+username = user
+userpass = pass
+# If a record directory on a remote host is used, a username and password must be specified.
+# Supported protocols:
+# FTP, SFTP.
+records_root_path = ftp://192.168.254.254/Records/camera.test.local
+records_root_user = user
+records_root_pass = pass
+
+
+[camera-sequences:camera.test.local]
+# Camera sequence description block always starts with "camera-sequences:".
+# Place only the sequence of PTZ-actions in this block!
+# Variable name can be anything. Only 'downloadjpeg' is using this for filename prefix.
+# Available actions:
+# setcamerapos, setcameramov, settextonosd, downloadjpeg, capabilities, getcamerapos,
+# setptzmovyyu, setptzmovyyd, setptzmovxxl, setptzmovxxr, setptzmovzzi, setptzmovzzo,
+# setptzpreset, setptztostop, setmovtohome, setposashome, rebootcamera.
+#
+# Format (spaces are used for readability only):
+# name = action, x, y, zoom, preset, speed, d(ms), w(s), text, notes or anything
+step001 = capabilities, -, -, -, -, -, -, 3, ,
+step002 = getcamerapos, -, -, -, -, -, -, 3, ,
+step011 = setmovtohome, -, -, -, -, -, -, 15, ,
+step021 = setptzmovyyd, -, -, -, -, 2, -, 5, , 'speed: 1..7'
+step022 = setptzmovyyu, -, -, -, -, 4, -, 3, , 'speed: 1..7'
+step023 = setptzmovxxl, -, -, -, -, 4, -, 3, , 'speed: 1..7'
+step024 = setptzmovxxr, -, -, -, -, 2, -, 5, , 'speed: 1..7'
+step025 = setptzmovzzi, -, -, -, -, 7, -, 3, , 'speed: 1..7'
+step026 = setptzmovzzo, -, -, -, -, 7, -, 3, , 'speed: 1..7'
+step027 = setptztostop, -, -, -, -, -, -, 3, ,
+step031 = setptzpreset, -, -, -, 2, 1, -, 15, , 'speed: 1..7'
+step041 = setcameramov, 33, 33, 66, -, -, -, 5, , 'x: -100..100, y: -100..100, z: -100..100, duration: 0..180000'
+step042 = setcameramov, -66, -66, -99, -, -, 5000, 0, , 'x: -100..100, y: -100..100, z: -100..100, duration: 0..180000'
+step043 = setcamerapos, 0, 0, 0, -, -, -, 15, , 'x: 0..3600, y: -900..2700, z: 0..1000'
+step051 = setposashome, -, -, -, -, -, -, 3.5, ,
+step061 = settextonosd, 0, 0, -, -, -, -, 3, hello, 'x|y: osd text position, text: text for osd without quotes or commas'
+step062 = settextonosd, 0, 0, -, -, -, -, 3, sensor-config:sensor.test.local, 'if a sensor configuration is specified, the sensor value is used instead of text'
+step063 = settextonosd, 0, 0, -, -, -, -, 3, , 'an empty text value is used to clear the osd'
+step071 = downloadjpeg, 1280, 720, -, -, -, -, 5, , 'name: filename prefix, x|y: camera width|height resolution'
+step999 = rebootcamera, -, -, -, -, -, -, 120, ,
diff --git a/cctv-scheduler.py b/cctv-scheduler.py
new file mode 100644
index 0000000..e119e78
--- /dev/null
+++ b/cctv-scheduler.py
@@ -0,0 +1,1240 @@
+#!/usr/bin/env python3
+
+
+import logging
+import urllib.request
+from argparse import ArgumentParser
+from datetime import datetime
+from ftplib import FTP
+from os import path, sep, makedirs, remove, replace
+from time import sleep
+from paramiko import SSHClient, AutoAddPolicy
+
+
+class Parse:
+ """Parser of configs, arguments, parameters.
+ """
+ def __init__(self, parameters, block: str = None) -> None:
+ """Object constructor.
+
+ Args:
+ parameters: dictionary as "key":"value" or
+ ArgumentParser class object or
+ string path to the file or
+ string as "var1=val1;var2=val2".
+ block (str, optional): name of target block from text. Defaults to None.
+ """
+ self.path = ''
+ self.data = {}
+ if type(parameters) is dict:
+ self._dict2dict(parameters)
+ if type(parameters) is ArgumentParser:
+ self._dict2dict(self.argv2dict(parameters))
+ if type(parameters) is str:
+ if path.exists(parameters):
+ self._dict2dict(
+ self.strs2dict(
+ self.conf2strs(parameters),
+ block
+ )
+ )
+ self.path = parameters
+ else:
+ self._dict2dict(self.strs2dict(parameters, block))
+
+ def __str__(self) -> str:
+ """Overrides method for print(object).
+
+ Returns:
+ str: string with contents of the object's dictionary.
+ """
+ string = ''
+ for key, val in self.data.items():
+ string += str(type(val)) + ' ' + str(key) + ' = ' + str(val) + '\n'
+ return string
+
+ def _dict2dict(self, dictionary: dict) -> None:
+ """Updates or adds dictionary data.
+
+ Args:
+ dictionary (dict): dictionary as "key":"value".
+ """
+ self.data.update(dictionary)
+
+ def expand(self, store: str = None) -> dict:
+ """Expand dictionary "key":"name.conf" to dictionary "key":{subkey: subval}.
+
+ Args:
+ store (str, optional): path to directory with name.conf. Defaults to None.
+
+ Returns:
+ dict: expanded dictionary as "key":{subkey: subval}.
+ """
+ for key in self.data:
+ if store:
+ config = store + sep + self.data[key]
+ else:
+ config = self.data[key]
+ with open(config) as file:
+ self.data[key] = Parse(file.read()).data
+ return self.data
+
+ @classmethod
+ def argv2dict(cls, parser: ArgumentParser) -> dict:
+ """Converts startup arguments to a dictionary.
+
+ Args:
+ parser (ArgumentParser): argparse.ArgumentParser class object.
+
+ Returns:
+ dict: dictionary as "key":"value".
+ """
+ parser = ArgumentParser(add_help=False, parents=[parser])
+ return vars(parser.parse_args())
+
+ @classmethod
+ def conf2strs(cls, config: str) -> str:
+ """Builds a dictionary from a file containing parameters.
+
+ Args:
+ config (str): path to the config file.
+
+ Returns:
+ str: string as "var1=val1;\nvar2=val2;".
+ """
+ with open(config) as file:
+ raw = file.read()
+ strs = ''
+ for line in raw.splitlines():
+ if not line.lstrip().startswith('#'):
+ strs += line + '\n'
+ return strs
+
+ @classmethod
+ def strs2dict(cls, strings: str, blockname: str) -> dict:
+ """Builds a dictionary from a strings containing parameters.
+
+ Args:
+ strings (str): string as "var1=val1;var2=val2;".
+ blockname (str): name of target block from text.
+
+ Returns:
+ dict: dictionary as "key":"value".
+ """
+ dictionary = {}
+ if blockname:
+ strings = cls.block(blockname, strings)
+ for line in strings.replace('\n', ';').split(';'):
+ if not line.lstrip().startswith('#') and "=" in line:
+ dictionary[line.split('=')[0].strip()] = line.split('=')[1].strip().split(';')[0].strip()
+ return dictionary
+
+ @classmethod
+ def str2bool(cls, value: str) -> bool:
+ """Converts a string value to boolean.
+
+ Args:
+ value (str): string containing "true" or "false", "yes" or "no", "1" or "0".
+
+ Returns:
+ bool: bool True or False.
+ """
+ return str(value).lower() in ("true", "yes", "1")
+
+ @classmethod
+ def block(cls, blockname: str, text: str) -> str:
+ """Cuts a block of text between line [blockname] and line [next block] or EOF.
+
+ Args:
+ blockname (str): string in [] after which the block starts.
+ text (str): string of text from which the block is needed.
+
+ Returns:
+ str: string of text between line [block name] and line [next block].
+ """
+ level = 1
+ save = False
+ result = ''
+ for line in text.splitlines():
+ if line.startswith('[') and blockname in line:
+ level = line.count('[')
+ save = True
+ elif line.startswith('[') and '['*level in line:
+ save = False
+ elif save:
+ result += line + '\n'
+ return result
+
+
+class Connect:
+ """Set of connection methods (functions) for various protocols.
+ """
+ @staticmethod
+ def http(
+ url: str, method: str = 'GET',
+ username: str = '', password: str = '', authtype: str = None,
+ contenttype: str = 'text/plain', contentdata: str = ''
+ ) -> str:
+ """Handling HTTP request.
+
+ Args:
+ url (str): request url.
+ method (str, optional): HTTP request method. Defaults to 'GET'.
+ username (str, optional): username for url authentication. Defaults to ''.
+ password (str, optional): password for url authentication. Defaults to ''.
+ authtype (str, optional): digest|basic authentication type. Defaults to None.
+ contenttype (str, optional): 'Content-Type' header. Defaults to 'text/plain'.
+ contentdata (str, optional): content data. Defaults to ''.
+
+ Returns:
+ str: HTTP response or 'ERROR'.
+ """
+
+ # Preparing authorization
+ if authtype:
+ pswd = urllib.request.HTTPPasswordMgrWithDefaultRealm()
+ pswd.add_password(None, url, username, password)
+ if authtype == 'basic':
+ auth = urllib.request.HTTPBasicAuthHandler(pswd)
+ if authtype == 'digest':
+ auth = urllib.request.HTTPDigestAuthHandler(pswd)
+ urllib.request.install_opener(urllib.request.build_opener(auth))
+
+ # Preparing request
+ request = urllib.request.Request(url=url, data=bytes(contentdata.encode('utf-8')), method=method)
+ request.add_header('Content-Type', contenttype)
+
+ # Response
+ try:
+ response = urllib.request.urlopen(request).read()
+ logging.debug(
+ msg=''
+ + '\n' + 'uri: ' + url
+ + '\n' + 'method: ' + method
+ + '\n' + 'username: ' + username
+ + '\n' + 'password: ' + password
+ + '\n' + 'authtype: ' + authtype
+ + '\n' + 'content-type: ' + contenttype
+ + '\n' + 'content-data: ' + contentdata
+ )
+ if response.startswith(b'\xff\xd8'):
+ return response
+ else:
+ return str(response.decode('utf-8'))
+ except Exception as error:
+ logging.debug(msg='\n' + 'error: ' + str(error))
+ return 'ERROR'
+
+ @staticmethod
+ def ssh_commands(command: str, hostname: str, username: str, password: str, port: int = 22) -> str:
+ """Handling SSH command executing.
+
+ Args:
+ command (str): command for executing.
+ hostname (str): remote hostname or ip address.
+ username (str): remote host username.
+ password (str): remote host password.
+ port (int, optional): remote host connection port. Defaults to 22.
+
+ Returns:
+ str: terminal response or 'ERROR'.
+ """
+ client = SSHClient()
+ client.set_missing_host_key_policy(AutoAddPolicy())
+ try:
+ client.connect(hostname=hostname, username=username, password=password, port=port)
+ stdin, stdout, stderr = client.exec_command(command=command, get_pty=True)
+ if 'sudo' in command:
+ stdin.write(password + '\n')
+ stdin.flush()
+ stdout.flush()
+ data = stdout.read() + stderr.read()
+ client.close()
+ return data.decode('utf-8')
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'host: ' + hostname + ':' + str(port)
+ + '\n' + 'user: ' + username
+ + '\n' + 'pass: ' + password
+ + '\n' + 'command: ' + command
+ + '\n' + 'error: ' + str(error)
+ )
+ return 'ERROR'
+
+ @staticmethod
+ def ssh_put_file(src_file: str, dst_file: str, hostname: str, username: str, password: str, port: int = 22) -> str:
+ """Handling SFTP upload file.
+
+ Args:
+ src_file (str): /local/path/to/file.
+ dst_file (str): /remote/path/to/file.
+ hostname (str): remote hostname or ip address.
+ username (str): remote host username.
+ password (str): remote host password.
+ port (int, optional): remote host connection port. Defaults to 22.
+
+ Returns:
+ str: '/remote/path/to/file' or 'ERROR'.
+ """
+ client = SSHClient()
+ client.set_missing_host_key_policy(AutoAddPolicy())
+ try:
+ client.connect(hostname=hostname, username=username, password=password, port=port)
+ client.exec_command('mkdir -p ' + path.dirname(dst_file))
+ try:
+ sftp = client.open_sftp()
+ sftp.put(localpath=src_file, remotepath=dst_file)
+ sftp.stat(dst_file)
+ sftp.close()
+ return dst_file
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'dst_file: ' + dst_file
+ + '\n' + 'error: ' + str(error)
+ )
+ return 'ERROR'
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'host: ' + hostname + ':' + str(port)
+ + '\n' + 'user: ' + username
+ + '\n' + 'pass: ' + password
+ + '\n' + 'src_file: ' + src_file
+ + '\n' + 'dst_file: ' + dst_file
+ + '\n' + 'error: ' + str(error)
+ )
+ return 'ERROR'
+ '''
+ @staticmethod
+ def ssh_get_file(src_file: str, dst_file: str, hostname: str, username: str, password: str, port: int = 22) -> str:
+ """Handling SFTP download file.
+
+ Args:
+ src_file (str): /remote/path/to/file.
+ dst_file (str): /local/path/to/file.
+ hostname (str): remote hostname or ip address.
+ username (str): remote host username.
+ password (str): remote host password.
+ port (int, optional): remote host connection port. Defaults to 22.
+
+ Returns:
+ str: '/local/path/to/file' or 'ERROR'.
+ """
+ client = SSHClient()
+ client.set_missing_host_key_policy(AutoAddPolicy())
+ try:
+ client.connect(hostname=hostname, username=username, password=password, port=port)
+ with client.open_sftp() as sftp:
+ sftp.get(remotepath=src_file, localpath=dst_file)
+ client.close()
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'host: ' + hostname + ':' + str(port)
+ + '\n' + 'user: ' + username
+ + '\n' + 'pass: ' + password
+ + '\n' + 'src_file: ' + src_file
+ + '\n' + 'dst_file: ' + dst_file
+ + '\n' + 'error: ' + str(error)
+ )
+ return 'ERROR'
+ '''
+ @staticmethod
+ def ftp_put_file(src_file: str, dst_file: str, hostname: str, username: str, password: str) -> bool:
+ dst_path = dst_file.split('/')[:-1]
+ ftp = FTP(host=hostname)
+ try:
+ ftp.login(user=username, passwd=password)
+ for path_item in dst_path:
+ if path_item.strip() == '':
+ continue
+ path_item = path_item.replace('/', '')
+ try:
+ ftp.cwd(path_item)
+ except Exception:
+ ftp.mkd(path_item)
+ ftp.cwd(path_item)
+ except Exception:
+ pass
+
+ with open(src_file, "rb") as file:
+ ftp.storbinary(f"STOR {dst_file}", file)
+ ftp.quit()
+ return True
+ '''
+ @staticmethod
+ def ftp_get_file(src_file: str, dst_file: str, hostname: str, username: str, password: str):
+ ftp = FTP(host=hostname)
+ try:
+ ftp.login(user=username, passwd=password)
+ with open(dst_file, "wb") as file:
+ ftp.retrbinary(f"RETR {src_file}", file.write)
+ ftp.quit()
+ except Exception:
+ pass
+ '''
+ '''
+ @staticmethod
+ def xmlrpc():
+ pass
+ '''
+
+
+class HikISAPI(Connect):
+ """Representing Hikvision device with ISAPI.
+ The class inherits the necessary connection methods of the Connect class
+ """
+ def __init__(
+ self,
+ hostname: str,
+ username: str, userpass: str,
+ authtype: str = 'digest',
+ hostport: int = 80, protocol: str = 'http',
+ channel: int = 101, videoid: int = 1
+ ) -> None:
+ """Object constructor.
+
+ Args:
+ hostname (str): camera hostname or ip address.
+ username (str): camera admin username.
+ userpass (str): camera admin password.
+ authtype (str, optional): digest|basic camera authentication type. Defaults to 'digest'.
+ hostport (int, optional): camera connection port. Defaults to 80.
+ protocol (str, optional): camera connection protocol. Defaults to 'http'.
+ channel (int, optional): camera channel id. Defaults to 101.
+ videoid (int, optional): camera video id. Defaults to 1.
+ """
+ self._host = hostname
+ self._port = hostport
+ self._user = username
+ self._pswd = userpass
+ self._auth = authtype
+ self._prot = protocol
+ self._chan = channel
+ self._viid = videoid
+
+ def __call(
+ self,
+ url: str, method: str = 'GET',
+ contenttype: str = 'application/x-www-form-urlencoded',
+ contentdata: str = ''
+ ) -> str:
+ """Send request to camera.
+
+ Args:
+ url (str): API path for request.
+ method (str, optional): HTTP request method. Defaults to 'GET'.
+ contenttype (str, optional): Content-Type header. Defaults to 'application/x-www-form-urlencoded'.
+ contentdata (str, optional): data for send with request. Defaults to ''.
+
+ Returns:
+ str: HTTP response content.
+ """
+ return self.http(
+ url=url, method=method,
+ username=self._user, password=self._pswd, authtype=self._auth,
+ contenttype=contenttype, contentdata=contentdata
+ )
+
+ def capabilities(self) -> bool:
+ """Get camera capabilities.
+
+ Returns:
+ bool: True if successed. Printing a response with a logger at the INFO level.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._viid) + "/capabilities"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.info(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def downloadjpeg(self, dst_file: str = path.splitext(__file__)[0] + '.jpeg', x: int = 1920, y: int = 1080) -> bool:
+ """Get static picture from camera.
+
+ Args:
+ dst_file (str, optional): absolute path of picture to save. Defaults to scriptname+'.jpeg'.
+ x (int, optional): picture width. Defaults to 1920.
+ y (int, optional): picture height. Defaults to 1080.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/Streaming/channels/" + str(self._viid)
+ + "/picture?snapShotImageType=JPEG&videoResolutionWidth="
+ + str(x) + "&videoResolutionHeight=" + str(y)
+ )
+ with open(dst_file, "wb") as file:
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ file.write(response)
+ logging.debug(msg='\n' + dst_file + '\n')
+ return True
+ else:
+ return False
+
+ def getcamerapos(self) -> bool:
+ """Get current camera position.
+
+ Returns:
+ bool: True if successed. Printing a response with a logger at the INFO level.
+ """
+ url = (self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + "/status")
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.info(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def rebootcamera(self) -> bool:
+ """Set camera reboot command.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/System/reboot"
+ )
+ response = self.__call(url=url, method="PUT")
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovyyu(self, speed: int = 1) -> bool:
+ """Start camera moving to up.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=TILT_UP&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovyyd(self, speed: int = 1) -> bool:
+ """Start camera moving to down.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=TILT_DOWN&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovxxl(self, speed: int = 1) -> bool:
+ """Start camera moving to left.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=PAN_LEFT&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovxxr(self, speed: int = 1) -> bool:
+ """Start camera moving to right.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=PAN_RIGHT&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovzzi(self, speed: int = 1) -> bool:
+ """Start camera zoom in.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=ZOOM_OUT&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzmovzzo(self, speed: int = 1) -> bool:
+ """Start camera zoom out.
+
+ Args:
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=ZOOM_IN&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptzpreset(self, preset: int, speed: int = 1) -> bool:
+ """Start camera moving to preset.
+
+ Args:
+ preset (int): saved preset number.
+ speed (int, optional): moving speed from 1 to 7. Defaults to 1.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=GOTO_PRESET&presetNo=" + str(preset)
+ + "&speed=" + str(speed)
+ + "&mode=start"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setptztostop(self) -> bool:
+ """Stop any camera moving.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/PTZ/channels/" + str(self._viid)
+ + "/PTZControl?command=GOTO_PRESET&mode=stop"
+ )
+ response = self.__call(url=url, method='GET')
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setcamerapos(self, x: int = 0, y: int = 0, z: int = 0) -> bool:
+ """Set camera moving to absolute position.
+
+ Args:
+ x (int, optional): horisontal camera position from 0 to 3600. Defaults to 0.
+ y (int, optional): vertical camera position from -900 to 2700. Defaults to 0.
+ z (int, optional): zoom camera position from 0 to 1000. Defaults to 0.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan)
+ + "/absolute"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ + '' + str(y) + ''
+ + '' + str(x) + ''
+ + '' + str(z) + ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def __setcameramovcon(self, x: int = 0, y: int = 0, z: int = 0) -> bool:
+ """Set camera moving to direction until other signal or 180 seconds elapse.
+
+ Args:
+ x (int, optional): acceleration of horizontal camera movement from -100 to 100. Defaults to 0.
+ y (int, optional): acceleration of vertical camera movement from -100 to 100. Defaults to 0.
+ z (int, optional): acceleration of zoom camera movement from -100 to 100. Defaults to 0.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan)
+ + "/continuous"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ + '' + str(x) + ''
+ + '' + str(y) + ''
+ + '' + str(z) + ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def __setcameramovmom(self, x: int = 0, y: int = 0, z: int = 0, t: int = 180000) -> bool:
+ """Set camera moving to direction until other signal or duration elapse.
+
+ Args:
+ x (int, optional): acceleration of horizontal camera movement from -100 to 100. Defaults to 0.
+ y (int, optional): acceleration of vertical camera movement from -100 to 100. Defaults to 0.
+ z (int, optional): acceleration of zoom camera movement from -100 to 100. Defaults to 0.
+ t (int, optional): duration in ms of acceleration from 0 to 180000. Defaults to 180000.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan)
+ + "/momentary"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ + '' + str(x) + ''
+ + '' + str(y) + ''
+ + '' + str(z) + ''
+ + '' + str(t) + ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ sleep(t/1000)
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setcameramov(self, x: int = 0, y: int = 0, z: int = 0, t: int = 0) -> bool:
+ """Set camera moving to direction (polymorph abstraction).
+
+ Args:
+ x (int, optional): acceleration of horizontal camera movement from -100 to 100. Defaults to 0.
+ y (int, optional): acceleration of vertical camera movement from -100 to 100. Defaults to 0.
+ z (int, optional): acceleration of zoom camera movement from -100 to 100. Defaults to 0.
+ t (int, optional): duration in ms of acceleration from 0 to 180000. Defaults to 0.
+
+ Returns:
+ bool: True if successed.
+ """
+ if t == '-' or int(t) == 0:
+ return self.__setcameramovcon(x=int(x), y=int(y), z=int(z))
+ else:
+ return self.__setcameramovmom(x=int(x), y=int(y), z=int(z), t=int(t))
+
+ def setmovtohome(self) -> bool:
+ """Set camera moving to homeposition.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan)
+ + "/homeposition/goto"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def setposashome(self) -> bool:
+ """Save current camera position as homeposition.
+
+ Returns:
+ bool: True if successed.
+ """
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/PTZCtrl/channels/" + str(self._chan)
+ + "/homeposition"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+ def settextonosd(self, enabled: str = "true", x: int = 0, y: int = 0, message: str = "") -> bool:
+ """Set message as video overlay text.
+
+ Args:
+ enabled (str, optional): true or false. Defaults to "true".
+ x (int, optional): horizontal text position from 0 to video width. Defaults to 0.
+ y (int, optional): vertical text position from 0 to video heith. Defaults to 0.
+ message (str, optional): overlay text content. Defaults to "".
+
+ Returns:
+ bool: True if successed.
+ """
+ if message == '-':
+ message = ""
+ url = (
+ self._prot + '://' + self._host + ':' + str(self._port)
+ + "/ISAPI/System/Video/inputs/channels/" + str(self._chan)
+ + "/overlays/text"
+ )
+ xml = ''.join(
+ ''
+ + ''
+ + ''
+ + '1'
+ + '' + enabled + ''
+ + '' + str(x) + ''
+ + '' + str(y) + ''
+ + '' + message + ''
+ + ''
+ + ''
+ )
+ response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml)
+ if response != 'ERROR':
+ logging.debug(msg='\n' + response + '\n')
+ return True
+ else:
+ return False
+
+
+class Sensor(Connect):
+ """Representing sensor connected to remote host.
+ The class inherits the necessary connection methods of the Connect class
+ """
+ def __init__(
+ self,
+ hostname: str, username: str, userpass: str,
+ nodetype: str, nodename: str,
+ hostport: int = 22
+ ) -> None:
+ """Object constructor.
+
+ Args:
+ hostname (str): sensor's remote host hostname or ip address.
+ username (str): sensor's remote host username.
+ userpass (str): sensor's remote host password.
+ nodetype (str): 'ds18b20' or other sensor type.
+ nodename (str): 28-1a2b3c4d5e6f (ds18b20 example).
+ hostport (int, optional): sensor's remote host connection port. Defaults to 22.
+ """
+ self._host = hostname
+ self._port = hostport
+ self._user = username
+ self._pswd = userpass
+ self._type = nodetype
+ self._node = nodename
+
+ def __call(self, command: str) -> str:
+ """Send request to sensor's remote host.
+
+ Args:
+ command (str): command to poll the sensor.
+
+ Returns:
+ str: sensor's remote host response content.
+ """
+ return self.ssh_commands(
+ command=command,
+ hostname=self._host, port=self._port,
+ username=self._user, password=self._pswd
+ )
+
+ def __temperature(self, nodename: str) -> str:
+ """Preparating request for ds18b20 sensor type.
+
+ Args:
+ nodename (str): 28-1a2b3c4d5e6f (ds18b20 example).
+
+ Returns:
+ str: formatted string with temperature in Celsius.
+ """
+ command = 'cat /sys/bus/w1/devices/' + nodename + '/temperature'
+ response = self.__call(command=command)
+ if response != 'ERROR':
+ try:
+ temperature = str(int(response)//1000) + "'C"
+ return temperature
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'host: ' + self._host + ':' + str(self._port)
+ + '\n' + 'user: ' + self._user
+ + '\n' + 'pass: ' + self._pswd
+ + '\n' + 'command: ' + command
+ + '\n' + 'error: ' + str(error)
+ )
+ return 'ERROR'
+
+ def value(self) -> str:
+ """Public method to get sensor value.
+
+ Returns:
+ str: sensor value.
+ """
+ if self._type == 'ds18b20':
+ return self.__temperature(nodename=self._node)
+
+
+class Sequence:
+ """Sequence handling.
+ """
+ @staticmethod
+ def run(
+ device: HikISAPI, sensors: dict, sequence: dict,
+ records_root_path: str = None,
+ records_root_user: str = None,
+ records_root_pass: str = None
+ ) -> None:
+ """Sequences executor.
+
+ Args:
+ device (HikISAPI): HikISAPI object.
+ sensors (dict): collection as key=sensorname:value=Sensor object.
+ sequence (dict): sequence steps collection.
+ records_root_path (str, optional): path (local|smb|ftp,sftp) to records directory. Defaults to None.
+ records_root_user (str, optional): username if path on remote host. Defaults to None.
+ records_root_pass (str, optional): password if path on remote host. Defaults to None.
+ """
+ for key, value in sequence.items():
+ action = value.split(',')[0].strip()
+ x = value.split(',')[1].strip()
+ y = value.split(',')[2].strip()
+ z = value.split(',')[3].strip()
+ p = value.split(',')[4].strip()
+ s = value.split(',')[5].strip()
+ t = value.split(',')[6].strip()
+ w = value.split(',')[7].strip()
+ m = value.split(',')[8].strip()
+ if 'sensor-config:' in m:
+ sensor_name = m.split(':')[1].strip()
+ sensor_value = sensors[sensor_name].value()
+ if sensor_value != 'ERROR':
+ m = sensor_value
+ else:
+ m = ''
+ logging.info(
+ msg=' action:' + key + ' = ' + action
+ + ',' + x + ',' + y + ',' + z
+ + ',' + p + ',' + s + ',' + t
+ + ',' + w + ',' + m
+ )
+ if action == 'capabilities':
+ response = device.capabilities()
+ elif action == 'getcamerapos':
+ response = device.getcamerapos()
+ elif action == 'rebootcamera':
+ response = device.rebootcamera()
+ elif action == 'setptzmovyyu':
+ response = device.setptzmovyyu(speed=int(s))
+ elif action == 'setptzmovyyd':
+ response = device.setptzmovyyd(speed=int(s))
+ elif action == 'setptzmovxxl':
+ response = device.setptzmovxxl(speed=int(s))
+ elif action == 'setptzmovxxr':
+ response = device.setptzmovxxr(speed=int(s))
+ elif action == 'setptzmovzzi':
+ response = device.setptzmovzzi(speed=int(s))
+ elif action == 'setptzmovzzo':
+ response = device.setptzmovzzo(speed=int(s))
+ elif action == 'setptzpreset':
+ response = device.setptzpreset(preset=int(p), speed=int(s))
+ elif action == 'setptztostop':
+ response = device.setptztostop()
+ elif action == 'setcamerapos':
+ response = device.setcamerapos(x=int(x), y=int(y), z=int(z))
+ elif action == 'setcameramov':
+ response = device.setcameramov(x=int(x), y=int(y), z=int(z), t=t)
+ elif action == 'setmovtohome':
+ response = device.setmovtohome()
+ elif action == 'setposashome':
+ response = device.setposashome()
+ elif action == 'settextonosd':
+ response = device.settextonosd(x=int(x), y=int(y), message=m)
+ elif action == 'downloadjpeg':
+ records_root_temp = records_root_path
+ if records_root_temp != path.dirname(path.realpath(__file__)):
+ records_root_temp = path.dirname(path.realpath(__file__)) + sep + 'temp'
+ makedirs(records_root_temp, exist_ok=True)
+ dy = datetime.now().strftime('%Y')
+ dm = datetime.now().strftime('%m')
+ dv = datetime.now().strftime('%V')
+ dd = datetime.now().strftime('%d')
+ th = datetime.now().strftime('%H')
+ tm = datetime.now().strftime('%M')
+ ts = datetime.now().strftime('%S')
+ records_file_name = (key + '_' + dy + '-' + dm + '-' + dd + '_' + th + '.' + tm + '.' + ts + '.jpeg')
+ if device.downloadjpeg(x=int(x), y=int(y), dst_file=records_root_temp + sep + records_file_name):
+ hostname = 'localhost'
+ hostport, hosttype = None, None
+ username = records_root_user
+ userpass = records_root_pass
+ hostpath = records_root_path
+ if '://' in records_root_path:
+ hostname = records_root_path.split('/')[2]
+ hosttype = records_root_path.split('://')[0]
+ if hosttype == 'ftp':
+ hostport = 21
+ if hosttype == 'sftp':
+ hostport = 22
+ if hosttype == 'smb':
+ hostport = 445
+ hostpath = records_root_path.replace(hosttype + '://' + hostname, '')
+ if '@' in hostname:
+ username = hostname.split('@')[0].split(':')[0]
+ userpass = hostname.split('@')[0].split(':')[1]
+ hostname = hostname.split('@')[1]
+ if ':' in hostname:
+ hostport = int(hostname.split(':')[1])
+ hostname = hostname.split(':')[0]
+ if hosttype == 'ftp':
+ src_file = records_root_temp + sep + records_file_name
+ dst_file = hostpath + '/' + dy + '/' + dm + '/' + dv + '/' + dd + '/' + records_file_name
+ if Connect.ftp_put_file(
+ src_file=src_file,
+ dst_file=dst_file,
+ hostname=hostname,
+ username=username,
+ password=userpass
+ ):
+ try:
+ remove(src_file)
+ except OSError:
+ pass
+ elif hosttype == 'sftp':
+ src_file = records_root_temp + sep + records_file_name
+ dst_file = hostpath + '/' + dy + '/' + dm + '/' + dv + '/' + dd + '/' + records_file_name
+ response = Connect.ssh_put_file(
+ src_file=src_file, dst_file=dst_file,
+ hostname=hostname, port=hostport,
+ username=username, password=userpass)
+ if response != 'ERROR':
+ try:
+ remove(src_file)
+ except OSError:
+ pass
+ response = True
+ else:
+ response = False
+ else:
+ src_file = records_root_temp + sep + records_file_name
+ dst_file = hostpath + sep + dy + sep + dm + sep + dv + sep + dd + sep + records_file_name
+ try:
+ makedirs(hostpath + sep + dy + sep + dm + sep + dv + sep + dd, exist_ok=True)
+ replace(src=src_file, dst=dst_file)
+ response = True
+ except Exception as error:
+ logging.debug(
+ msg=''
+ + '\n' + 'src_file: ' + src_file
+ + '\n' + 'dst_file: ' + dst_file
+ + '\n' + 'error: ' + str(error)
+ )
+ response = False
+ else:
+ response = False
+ if w != '-' or float(w) != 0:
+ sleep(float(w))
+ if response:
+ logging.info(msg=' result:' + key + ' = OK')
+ else:
+ logging.warning(msg='result:' + key + ' = ERROR')
+
+
+if __name__ == "__main__":
+ time_start = datetime.now()
+
+ args = ArgumentParser(
+ prog='cctv-scheduler',
+ description='Hikvision PTZ IP-Camera management.',
+ epilog='Dependencies: '
+ '- Python 3 (tested version 3.9.5), '
+ '- Python 3 modules: paramiko '
+ )
+ args.add_argument('--config', type=str, default=path.splitext(__file__)[0] + '.conf', required=False,
+ help='custom configuration file path')
+ args.add_argument('-s', '--sequences', action='store_true', required=False,
+ help='run sequences from config file')
+ args.add_argument('-c', '--converter', action='store_true', required=False,
+ help='convert JPEG collection to MP4')
+ args.add_argument('-p', '--publisher', action='store_true', required=False,
+ help='publish content from templates')
+ args = vars(args.parse_args())
+
+ log_root = path.dirname(path.realpath(__file__))
+ log_level = 'INFO'
+ if path.exists(args['config']):
+ conf = Parse(parameters=args['config'], block='common')
+ if 'log_root' in conf.data:
+ log_root = conf.data['log_root']
+ if 'log_level' in conf.data:
+ if conf.data['log_level'] == 'DEBUG':
+ log_level = logging.DEBUG
+ elif conf.data['log_level'] == 'INFO':
+ log_level = logging.INFO
+ elif conf.data['log_level'] == 'WARNING':
+ log_level = logging.WARNING
+ elif conf.data['log_level'] == 'ERROR':
+ log_level = logging.ERROR
+ elif conf.data['log_level'] == 'CRITICAL':
+ log_level = logging.CRITICAL
+ logging.basicConfig(
+ format='%(asctime)s %(levelname)s: %(message)s',
+ datefmt='%Y-%m-%d_%H.%M.%S',
+ handlers=[
+ logging.FileHandler(
+ filename=log_root + sep + path.splitext(path.basename(__file__))[0] + '.log',
+ mode='a'
+ ),
+ logging.StreamHandler()
+ ],
+ level=log_level
+ )
+ logging.getLogger("paramiko").setLevel(logging.WARNING)
+
+ if args['sequences']:
+ logging.info(msg='Starting PTZ sequences from config file')
+ sensors = {}
+ conf = Parse(parameters=args['config'], block='enable-sensor')
+ for key, value in conf.data.items():
+ if value == 'true':
+ device_config = Parse(
+ parameters=args['config'],
+ block='sensor-config:' + key
+ ).data
+ device_entity = Sensor(
+ hostname=device_config['hostname'],
+ username=device_config['username'],
+ userpass=device_config['userpass'],
+ nodetype=device_config['nodetype'],
+ nodename=device_config['nodename']
+ )
+ sensors[key] = device_entity
+
+ conf = Parse(parameters=args['config'], block='enable-sequences')
+ for key, value in conf.data.items():
+ if value == 'true':
+ device_sequence = Parse(
+ parameters=args['config'],
+ block='camera-sequences:' + key
+ ).data
+ device_config = Parse(
+ parameters=args['config'],
+ block='camera-config:' + key
+ ).data
+ device_entity = HikISAPI(
+ hostname=device_config['hostname'],
+ username=device_config['username'],
+ userpass=device_config['userpass']
+ )
+ records_root_path = path.dirname(path.realpath(__file__))
+ records_root_user = None
+ records_root_pass = None
+ if 'records_root_path' in device_config:
+ records_root_path = device_config['records_root_path']
+ if 'records_root_user' in device_config:
+ records_root_user = device_config['records_root_user']
+ if 'records_root_pass' in device_config:
+ records_root_pass = device_config['records_root_pass']
+ Sequence.run(
+ device=device_entity,
+ sensors=sensors,
+ sequence=device_sequence,
+ records_root_path=records_root_path,
+ records_root_user=records_root_user,
+ records_root_pass=records_root_pass
+ )
+ elif args['converter']:
+ logging.info(msg='Starting convert JPEG collection to MP4')
+ elif args['publisher']:
+ logging.info(msg='Starting publish content from templates')
+ else:
+ logging.info(msg='Start arguments was not selected. Exit.')
+
+ time_execute = datetime.now() - time_start
+ logging.info(msg='execution time is ' + str(time_execute) + '. Exit.')
diff --git a/info/images/cctv-scheduler-0.2.png b/info/images/cctv-scheduler-0.2.png
new file mode 100644
index 0000000..d71c082
Binary files /dev/null and b/info/images/cctv-scheduler-0.2.png differ