new "moonfire-nvr config" subcommand

This is a ncurses-based user interface for configuration. This fills a major
usability gap: the system can be configured without manual SQL commands.
This commit is contained in:
Scott Lamb 2017-02-05 19:58:41 -08:00
parent b3a7795407
commit c82f038bef
11 changed files with 1079 additions and 133 deletions

112
Cargo.lock generated
View File

@ -5,6 +5,7 @@ dependencies = [
"byteorder 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
"chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)",
"chan-signal 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
"cursive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"docopt 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
"ffmpeg 0.2.0-alpha.2 (git+https://github.com/scottlamb/rust-ffmpeg?branch=2.x)",
"ffmpeg-sys 2.8.9 (registry+https://github.com/rust-lang/crates.io-index)",
@ -51,6 +52,14 @@ dependencies = [
"memchr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "bit-set"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bit-vec 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "bit-set"
version = "0.4.0"
@ -87,6 +96,17 @@ dependencies = [
"rand 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "chan-signal"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bit-set 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
"chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "chan-signal"
version = "0.2.0"
@ -112,6 +132,21 @@ name = "crossbeam"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "cursive"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)",
"chan-signal 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
"ncurses 5.85.0 (registry+https://github.com/rust-lang/crates.io-index)",
"num 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
"odds 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)",
"toml 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
"unicode-segmentation 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "docopt"
version = "0.7.0"
@ -254,6 +289,11 @@ name = "language-tags"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "lazy_static"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "lazy_static"
version = "0.2.2"
@ -342,16 +382,48 @@ dependencies = [
"log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ncurses"
version = "5.85.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"gcc 0.3.42 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)",
"num-complex 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)",
"num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)",
"num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)",
"num-rational 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-bigint"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
"rand 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)",
"rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-complex"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
"rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-integer"
version = "0.1.32"
@ -369,6 +441,17 @@ dependencies = [
"num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-rational"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)",
"num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)",
"rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-traits"
version = "0.1.36"
@ -390,6 +473,11 @@ dependencies = [
"libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "odds"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "openssl"
version = "0.9.6"
@ -726,6 +814,9 @@ dependencies = [
name = "toml"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "traitobject"
@ -758,6 +849,16 @@ name = "unicode-normalization"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "unicode-segmentation"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "unicode-width"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "unicode-xid"
version = "0.0.4"
@ -826,15 +927,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"checksum aho-corasick 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66"
"checksum aho-corasick 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4f660b942762979b56c9f07b4b36bb559776fbad102f05d6771e1b629e8fd5bf"
"checksum bit-set 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e6e1e6fb1c9e3d6fcdec57216a74eaa03e41f52a22f13a16438251d8e88b89da"
"checksum bit-set 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9bf6104718e80d7b26a68fdbacff3481cfc05df670821affc7e9cbc1884400c"
"checksum bit-vec 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "5b97c2c8e8bbb4251754f559df8af22fb264853c7d009084a576cdf12565089d"
"checksum bitflags 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "32866f4d103c4e438b1db1158aa1b1a80ee078e5d77a59a2f906fd62a577389c"
"checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d"
"checksum byteorder 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c40977b0ee6b9885c9013cd41d9feffdd22deb3bb4dc3a71d901cc7a77de18c8"
"checksum chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)" = "82b22acfef7960fd8f829bc50749273be637cbd76b9d4cc20497666cc3a33329"
"checksum chan-signal 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "365122ab60a9dc6240b48e39d011b4389c3853093d98bf586edd2b79bfb4fbfa"
"checksum chan-signal 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "0f3bb6c3bc387004ad914f0c5b7f33ace8bf7604bbec35f228b1a017f52cd3a0"
"checksum chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)" = "9213f7cd7c27e95c2b57c49f0e69b1ea65b27138da84a170133fd21b07659c00"
"checksum crossbeam 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)" = "0c5ea215664ca264da8a9d9c3be80d2eaf30923c259d03e870388eb927508f97"
"checksum cursive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8d226ba768f44025aeec3485ce3ca9c7e93c3f2a7e02e8f00d5b4c70c490011b"
"checksum docopt 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ab32ea6e284d87987066f21a9e809a73c14720571ef34516f0890b3d355ccfd8"
"checksum dtoa 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0dd841b58510c9618291ffa448da2e4e0f699d984d436122372f446dae62263d"
"checksum error-chain 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "318cb3c71ee4cdea69fdc9e15c173b245ed6063e1709029e8fd32525a881120f"
@ -852,6 +956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum itoa 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ae3088ea4baeceb0284ee9eea42f591226e6beaecf65373e41b38d95a1b8e7a1"
"checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d"
"checksum language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a"
"checksum lazy_static 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "cf186d1a8aa5f5bee5fd662bc9c1b949e0259e1bcc379d1f006847b0080c7417"
"checksum lazy_static 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6abe0ee2e758cd6bc8a2cd56726359007748fbf4128da998b65d0b70f881e19b"
"checksum libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)" = "684f330624d8c3784fb9558ca46c4ce488073a8d22450415c5eb4f4cfb0d11b5"
"checksum libsqlite3-sys 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b6de3eea39ba6ed0cddf04e1c7a78486e3f750441e0a0b15b6ea39d0dd8e1b8c"
@ -864,12 +969,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum memmap 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "065ce59af31c18ea2c419100bda6247dd4ec3099423202b12f0bd32e529fabd2"
"checksum metadeps 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "829fffe7ea1d747e23f64be972991bc516b2f1ac2ae4a3b33d8bea150c410151"
"checksum mime 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b5c93a4bd787ddc6e7833c519b73a50883deb5863d76d9b71eb8216fb7f94e66"
"checksum ncurses 5.85.0 (registry+https://github.com/rust-lang/crates.io-index)" = "21f71f0e1ae96558612b1e9d188ec4f23149a11ee4fb4b96e130523bea52d605"
"checksum num 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)" = "bde7c03b09e7c6a301ee81f6ddf66d7a28ec305699e3d3b056d2fc56470e3120"
"checksum num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "88b14378471f7c2adc5262f05b4701ef53e8da376453a8d8fee48e51db745e49"
"checksum num-complex 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "f0c78e054dd19c3fd03419ade63fa661e9c49bb890ce3beb4eee5b7baf93f92f"
"checksum num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "fb24d9bfb3f222010df27995441ded1e954f8f69cd35021f6bef02ca9552fb92"
"checksum num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "287a1c9969a847055e1122ec0ea7a5c5d6f72aad97934e131c83d5c08ab4e45c"
"checksum num-rational 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "54ff603b8334a72fbb27fe66948aac0abaaa40231b3cecd189e76162f6f38aaf"
"checksum num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)" = "a16a42856a256b39c6d3484f097f6713e14feacd9bfb02290917904fae46c81c"
"checksum num_cpus 0.2.13 (registry+https://github.com/rust-lang/crates.io-index)" = "cee7e88156f3f9e19bdd598f8d6c9db7bf4078f99f8381f43a55b09648d1a6e3"
"checksum num_cpus 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a225d1e2717567599c24f88e49f00856c6e825a12125181ee42c4257e3688d39"
"checksum odds 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)" = "c3df9b730298cea3a1c3faa90b7e2f9df3a9c400d0936d6015e6165734eefcba"
"checksum openssl 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0c00da69323449142e00a5410f0e022b39e8bbb7dc569cee8fc6af279279483c"
"checksum openssl-sys 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)" = "b1482f9a06f56c906007e17ea14d73d102210b5d27bc948bf5e175f493f3f7c3"
"checksum pkg-config 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "3a8b4c6b8165cd1a1cd4b9b120978131389f64bdaf456435caa41e630edba903"
@ -914,6 +1024,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum unicase 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "13a5906ca2b98c799f4b1ab4557b76367ebd6ae5ef14930ec841c74aed5f3764"
"checksum unicode-bidi 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)" = "b61814f3e7fd0e0f15370f767c7c943e08bc2e3214233ae8f88522b334ceb778"
"checksum unicode-normalization 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "5e94e9f6961090fcc75180629c4ef33e5310d6ed2c0dd173f4ca63c9043b669e"
"checksum unicode-segmentation 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7baebdc1df1363fa66161fca2fe047e4f4209011cc7e045948298996afdf85df"
"checksum unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "bf3a113775714a22dcb774d8ea3655c53a32debae63a063acc00a91cc586245f"
"checksum unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc"
"checksum unreachable 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1f2ae5ddb18e1c92664717616dd9549dde73f539f01bd7b77c2edb2446bdff91"
"checksum url 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f5ba8a749fb4479b043733416c244fa9d1d3af3d7c23804944651c8a448cb87e"

View File

@ -42,6 +42,11 @@ serde_codegen = "0.8"
[dev-dependencies]
tempdir = "0.3"
[dependencies.cursive]
version = "0.4"
#default-features = false
#features = ["termion"]
[dependencies.ffmpeg]
git = "https://github.com/scottlamb/rust-ffmpeg"
branch = "2.x"

110
README.md
View File

@ -89,6 +89,9 @@ You will need the following C libraries installed:
* [SQLite3](https://www.sqlite.org/).
* [`ncursesw`](https://www.gnu.org/software/ncurses/), the UTF-8 version of
the `ncurses` library.
On Ubuntu 16.04.1 LTS or Raspbian Jessie, the following command will install
all non-Rust dependencies:
@ -97,13 +100,8 @@ all non-Rust dependencies:
libavcodec-dev \
libavformat-dev \
libavutil-dev \
sqlite3 \
libsqlite3-dev \
uuid-runtime
uuid-runtime is only necessary if you wish to use the uuid command to generate
uuids for your cameras (see below). If you obtain them elsewhere, you can skip this
package.
libncursesw-dev \
libsqlite3-dev
Next, you need Rust and Cargo. The easiest way to install them is by following
the instructions at [rustup.rs](https://www.rustup.rs/). Note that Rust 1.13
@ -119,7 +117,6 @@ build and install, or alternatively you can run the prep script called `prep.sh`
The script will take the following command line options, should you need them:
* `-D`: Skip database initialization.
* `-S`: Skip updating and installing dependencies through apt-get. This too can be
useful on repeated builds.
@ -133,7 +130,7 @@ store the video samples inside a directory named `samples` there, you would set:
SAMPLES_DIR=/media/nvr/samples
The script will perform all necessary steps to leave you with a fully built,
installed moonfire-nvr binary and (running) system service. The only thing
installed moonfire-nvr binary. The only thing
you'll have to do manually is add your camera configuration(s) to the database.
Alternatively, before running the script, you can create a file named `cameras.sql`
in the same directory as the `prep.sh` script and it will be automatically
@ -180,82 +177,35 @@ If a dedicated hard drive is available, set up the mount point:
$ sudo mount /var/lib/moonfire-nvr/sample
Once setup is complete, it is time to add camera configurations to the
database. However, the interface for adding new cameras is not yet written,
so you will have to manually insert cameras configurations with the `sqlite3`
command line tool prior to starting Moonfire NVR.
database. If the daemon is running, you will need to stop it temporarily:
Before setting up a camera, it may be helpful to test settings with the
`ffmpeg` command line tool:
$ sudo systemctl stop moonfire-nvr
$ ffmpeg \
-i "rtsp://admin:12345@192.168.1.101:554/Streaming/Channels/1" \
-c copy \
-map 0:0 \
-rtsp_transport tcp \
-flags:v +global_header \
test.mp4
You can configure the system through a text-based user interface:
Once you have a working `ffmpeg` command line, insert the camera config as
follows. See the schema SQL file's comments for more information.
Note that the sum of `retain_bytes` for all cameras combined should be
somewhat less than the available bytes on the sample file directory's
filesystem, as the currently-writing sample files are not included in
this sum. Be sure also to subtract out the filesystem's reserve for root
(typically 5%).
$ sudo -u moonfire-nvr moonfire-nvr config
In the following example, we generate a uuid which is then later used
to uniquely identify this camera. Thus, you will generate a new one for
each camera you insert using this method.
In the user interface, add your cameras under the "Edit cameras" dialog.
There's a "Test" button to verify your settings directly from the dialog.
$ uuidgen | sed -e 's/-//g'
b47f48706d91414591cd6c931bf836b4
$ sudo -u moonfire-nvr sqlite3 ~moonfire-nvr/db/db
sqlite3> insert into camera (
...> uuid, short_name, description, host, username, password,
...> main_rtsp_path, sub_rtsp_path, retain_bytes,
...> next_recording_id) values (
...> X'b47f48706d91414591cd6c931bf836b4', 'driveway',
...> 'Longer description of this camera', '192.168.1.101',
...> 'admin', '12345', '/Streaming/Channels/1',
...> '/Streaming/Channels/2', 104857600, 0);
sqlite3> ^D
After the cameras look correct, go to "Edit retention" to assign disk space to
each camera. Leave a little slack (at least 100 MB per camera) between the total
limit and the filesystem capacity, even if you store nothing else on the disk.
There are several reasons this is needed:
### Using automatic camera configuration inclusion with `prep.sh`
* The limit currently controls fully-written files only. There will be up
to two minutes of video per camera of additional video.
* The rotation happens after the limit is exceeded, not proactively.
* Moonfire NVR currently doesn't account for the unused space in the final
filesystem block at the end of each file.
* Moonfire NVR doesn't account for the space used for directory listings.
* If a file is open when it is deleted (such as if a HTTP client is
downloading it), it stays around until the file is closed. Moonfire NVR
currently doesn't account for this.
Not withstanding the instructions above, you can also prepare a file named
`cameras.sql` before you run the `prep.sh` script. The format of this file
should be something like in the example below for two cameras (SQL gives you
lots of freedom in the use of blank space and newlines, so this is formatted
for easy reading, and editing, and does not have to be altered in formatting,
but can if you wish and know what you are doing):
When finished, start the daemon:
insert into camera (
uuid,
short_name, description,
host, username, password,
main_rtsp_path, sub_rtsp_path,
retain_bytes, next_recording_id
)
values
(
X'1c944181b8074b8083eb579c8e194451',
'Front Left', 'Front Left Driveway',
'192.168.1.41',
'admin', 'secret',
'/Streaming/Channels/1', '/Streaming/Channels/2',
346870912000, 0
),
(
X'da5921f493ac4279aafe68e69e174026',
'Front Right', 'Front Right Driveway',
'192.168.1.42',
'admin', 'secret',
'/Streaming/Channels/1', '/Streaming/Channels/2',
346870912000, 0
);
You'll still have to find the correct rtsp paths, usernames and passwords, and
set retained byte counts, as explained above.
$ sudo systemctl start moonfire-nvr
## System Service
@ -290,9 +240,9 @@ directly exposed to the Internet.
Complete the installation through `systemctl` commands:
$ sudo systemctl daemon-reload
$ sudo systemctl start moonfire-nvr.service
$ sudo systemctl status moonfire-nvr.service
$ sudo systemctl enable moonfire-nvr.service
$ sudo systemctl start moonfire-nvr
$ sudo systemctl status moonfire-nvr
$ sudo systemctl enable moonfire-nvr
See the [systemd](http://www.freedesktop.org/wiki/Software/systemd/)
documentation for more information. The [manual

10
prep.sh
View File

@ -34,7 +34,6 @@
# Script to prepare for moonfire-nvr operations
#
# Command line options:
# -D: Skip database initialization
# -S: Skip apt-get update and install
#
@ -106,7 +105,6 @@ NVR_GROUP="${NVR_GROUP:-$NVR_USER}"
NVR_PORT="${NVR_PORT:-8080}"
NVR_HOME_BASE="${NVR_HOME_BASE:-/var/lib}"
NVR_HOME="${NVR_HOME_BASE}/${NVR_USER}"
DB_NAME="${DB_NAME:-db}"
DB_DIR="${DB_DIR:-$NVR_HOME/db}"
SAMPLES_DIR_NAME="${SAMPLES_DIR_NAME:-samples}"
SAMPLES_DIR="${SAMPLES_DIR:-$NVR_HOME/$SAMPLES_DIR_NAME}"
@ -214,13 +212,7 @@ echo 'Create database...'; echo
if [ ! -d "${DB_DIR}" ]; then
sudo -u ${NVR_USER} -H mkdir "${DB_DIR}"
fi
DB_PATH="${DB_DIR}/${DB_NAME}"
CAMERAS_PATH="${SRC_DIR}/../cameras.sql"
[ "${SKIP_DB:-0}" == 0 ] && sudo -u ${NVR_USER} -H ${SERVICE_BIN} init --db-dir="${DB_PATH}"
if [ -r "${CAMERAS_PATH}" ]; then
echo 'Add cameras...'; echo
sudo -u ${NVR_USER} -H sqlite3 "${DB_PATH}" < "${CAMERAS_PATH}"
fi
sudo -u ${NVR_USER} -H ${SERVICE_BIN} init --db-dir="${DB_DIR}"
# Prepare service files
#

270
src/cmds/config/cameras.rs Normal file
View File

@ -0,0 +1,270 @@
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2017 Scott Lamb <slamb@slamb.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
extern crate cursive;
use self::cursive::Cursive;
use self::cursive::traits::{Boxable, Identifiable, Finder};
use self::cursive::views;
use db;
use dir;
use error::Error;
use std::sync::Arc;
use stream::{self, Opener, Stream};
use super::{decode_size, encode_size};
/// Builds a `CameraChange` from an active `edit_camera_dialog`.
fn get_change(siv: &mut Cursive) -> db::CameraChange {
db::CameraChange{
short_name: siv.find_id::<views::EditView>("short_name")
.unwrap().get_content().as_str().into(),
description: siv.find_id::<views::TextArea>("description").unwrap().get_content().into(),
host: siv.find_id::<views::EditView>("host").unwrap().get_content().as_str().into(),
username: siv.find_id::<views::EditView>("username").unwrap().get_content().as_str().into(),
password: siv.find_id::<views::EditView>("password").unwrap().get_content().as_str().into(),
main_rtsp_path: siv.find_id::<views::EditView>("main_rtsp_path")
.unwrap().get_content().as_str().into(),
sub_rtsp_path: siv.find_id::<views::EditView>("sub_rtsp_path")
.unwrap().get_content().as_str().into(),
}
}
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
id: Option<i32>) {
let change = get_change(siv);
siv.pop_layer(); // get rid of the add/edit camera dialog.
let result = {
let mut l = db.lock();
if let Some(id) = id {
l.update_camera(id, change)
} else {
l.add_camera(change).map(|_| ())
}
};
if let Err(e) = result {
siv.add_layer(views::Dialog::text(format!("Unable to add camera: {}", e))
.title("Error")
.dismiss_button("Abort"));
} else {
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
siv.pop_layer();
add_dialog(db, dir, siv);
}
}
fn press_test_inner(url: &str) -> Result<String, Error> {
let stream = stream::FFMPEG.open(stream::Source::Rtsp(url))?;
let extra_data = stream.get_extra_data()?;
Ok(format!("{}x{} video stream", extra_data.width, extra_data.height))
}
fn press_test(siv: &mut Cursive, c: &db::CameraChange, stream: &str, path: &str) {
let url = format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host, path);
let description = match press_test_inner(&url) {
Err(e) => {
siv.add_layer(
views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, e))
.title("Stream test failed")
.dismiss_button("Back"));
return;
},
Ok(d) => d,
};
siv.add_layer(views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, description))
.title("Stream test succeeded")
.dismiss_button("Back"));
}
fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, id: i32,
name: String, to_delete: i64) {
let dialog = if to_delete > 0 {
let prompt = format!("Camera {} has recorded video. Please confirm the amount \
of data to delete by typing it back:\n\n{}", name,
encode_size(to_delete));
views::Dialog::around(
views::LinearLayout::vertical()
.child(views::TextView::new(prompt))
.child(views::DummyView)
.child(views::EditView::new().on_submit({
let db = db.clone();
let dir = dir.clone();
move |siv, _| confirm_deletion(siv, &db, &dir, id, to_delete)
}).with_id("confirm")))
.button("Delete", {
let db = db.clone();
let dir = dir.clone();
move |siv| confirm_deletion(siv, &db, &dir, id, to_delete)
})
} else {
views::Dialog::text(format!("Delete camera {}? This camera has no recorded video.", name))
.button("Delete", {
let db = db.clone();
let dir = dir.clone();
move |s| actually_delete(s, &db, &dir, id)
})
}.title("Delete camera").dismiss_button("Cancel");
siv.add_layer(dialog);
}
fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
id: i32, to_delete: i64) {
let typed = siv.find_id::<views::EditView>("confirm").unwrap().get_content();
if decode_size(typed.as_str()).ok() == Some(to_delete) {
siv.pop_layer(); // deletion confirmation dialog
if let Err(e) = dir::lower_retention(dir.clone(),
&[dir::NewLimit{camera_id: id, limit: 0}]) {
siv.add_layer(views::Dialog::text(format!("Unable to delete recordings: {}", e))
.title("Error")
.dismiss_button("Abort"));
return;
}
actually_delete(siv, db, dir, id);
} else {
siv.add_layer(views::Dialog::text("Please confirm amount.")
.title("Try again")
.dismiss_button("Back"));
}
}
fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
id: i32) {
info!("actually_delete call");
siv.pop_layer(); // get rid of the add/edit camera dialog.
let result = {
let mut l = db.lock();
l.delete_camera(id)
};
if let Err(e) = result {
siv.add_layer(views::Dialog::text(format!("Unable to delete camera: {}", e))
.title("Error")
.dismiss_button("Abort"));
} else {
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
siv.pop_layer();
add_dialog(db, dir, siv);
}
}
/// Adds or updates a camera.
/// (The former if `item` is None; the latter otherwise.)
fn edit_camera_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive,
item: &Option<i32>) {
let list = views::ListView::new()
.child("id", views::TextView::new(match *item {
None => "<new>".to_string(),
Some(id) => id.to_string(),
}))
.child("uuid", views::TextView::new("<new>").with_id("uuid"))
.child("short name", views::EditView::new().with_id("short_name"))
.child("host", views::EditView::new().with_id("host"))
.child("username", views::EditView::new().with_id("username"))
.child("password", views::EditView::new().with_id("password"))
.child("main_rtsp_path", views::LinearLayout::horizontal()
.child(views::EditView::new().with_id("main_rtsp_path").full_width())
.child(views::DummyView)
.child(views::Button::new("Test", |siv| {
let c = get_change(siv);
press_test(siv, &c, "main", &c.main_rtsp_path)
})))
.child("sub_rtsp_path", views::LinearLayout::horizontal()
.child(views::EditView::new().with_id("sub_rtsp_path").full_width())
.child(views::DummyView)
.child(views::Button::new("Test", |siv| {
let c = get_change(siv);
press_test(siv, &c, "sub", &c.sub_rtsp_path)
})))
.min_height(8);
let layout = views::LinearLayout::vertical()
.child(list)
.child(views::TextView::new("description"))
.child(views::TextArea::new().with_id("description").min_height(3))
.full_width();
let mut dialog = views::Dialog::around(layout);
let dialog = if let Some(id) = *item {
let l = db.lock();
let camera = l.cameras_by_id().get(&id).expect("missing camera");
dialog.find_id::<views::TextView>("uuid")
.expect("missing TextView")
.set_content(camera.uuid.to_string());
let bytes = camera.sample_file_bytes;
let name = camera.short_name.clone();
for &(view_id, content) in &[("short_name", &camera.short_name),
("host", &camera.host),
("username", &camera.username),
("password", &camera.password),
("main_rtsp_path", &camera.main_rtsp_path),
("sub_rtsp_path", &camera.sub_rtsp_path)] {
dialog.find_id::<views::EditView>(view_id)
.expect("missing EditView")
.set_content(content.to_string());
}
dialog.find_id::<views::TextArea>("description")
.expect("missing TextArea")
.set_content(camera.description.to_string());
dialog.title("Edit camera")
.button("Edit", {
let db = db.clone();
let dir = dir.clone();
move |s| press_edit(s, &db, &dir, Some(id))
})
.button("Delete", {
let db = db.clone();
let dir = dir.clone();
move |s| press_delete(s, &db, &dir, id, name.clone(), bytes)
})
} else {
dialog.title("Add camera")
.button("Add", {
let db = db.clone();
let dir = dir.clone();
move |s| press_edit(s, &db, &dir, None)
})
};
siv.add_layer(dialog.dismiss_button("Cancel"));
}
pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive) {
siv.add_layer(views::Dialog::around(
views::SelectView::new()
.on_submit({
let db = db.clone();
let dir = dir.clone();
move |siv, item| edit_camera_dialog(&db, &dir, siv, item)
})
.item("<new camera>".to_string(), None)
.with_all(db.lock()
.cameras_by_id()
.iter()
.map(|(&id, camera)| (format!("{}: {}", id, camera.short_name), Some(id))))
.full_width())
.dismiss_button("Done")
.title("Edit cameras"));
}

163
src/cmds/config/mod.rs Normal file
View File

@ -0,0 +1,163 @@
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2017 Scott Lamb <slamb@slamb.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Text-based configuration interface.
//!
//! This code is a bit messy, but it's essentially a prototype. Eventually Moonfire NVR's
//! configuration will likely be almost entirely done through a web-based UI.
extern crate cursive;
use self::cursive::Cursive;
use self::cursive::views;
use db;
use dir;
use error::Error;
use regex::Regex;
use std::sync::Arc;
use std::fmt::Write;
use std::str::FromStr;
mod cameras;
mod retention;
static USAGE: &'static str = r#"
Interactive configuration editor.
Usage:
moonfire-nvr config [options]
moonfire-nvr config --help
Options:
--db-dir=DIR Set the directory holding the SQLite3 index database.
This is typically on a flash device.
[default: /var/lib/moonfire-nvr/db]
--sample-file-dir=DIR Set the directory holding video data.
This is typically on a hard drive.
[default: /var/lib/moonfire-nvr/sample]
"#;
static MULTIPLIERS: [(char, u64); 4] = [
// (suffix character, power of 2)
('T', 40),
('G', 30),
('M', 20),
('K', 10),
];
fn encode_size(mut raw: i64) -> String {
let mut encoded = String::new();
for &(c, n) in &MULTIPLIERS {
if raw >= 1i64<<n {
write!(&mut encoded, "{}{} ", raw >> n, c).unwrap();
raw &= (1i64 << n) - 1;
}
}
if raw > 0 || encoded.len() == 0 {
write!(&mut encoded, "{}", raw).unwrap();
} else {
encoded.pop(); // remove trailing space.
}
encoded
}
fn decode_size(encoded: &str) -> Result<i64, ()> {
let mut decoded = 0i64;
lazy_static! {
static ref RE: Regex = Regex::new(r"\s*([0-9]+)([TGMK])?,?\s*").unwrap();
}
let mut last_pos = 0;
for cap in RE.captures_iter(encoded) {
let whole_cap = cap.get(0).unwrap();
if whole_cap.start() > last_pos {
return Err(());
}
last_pos = whole_cap.end();
let mut piece = i64::from_str(&cap[1]).map_err(|_| ())?;
if let Some(m) = cap.get(2) {
let m = m.as_str().as_bytes()[0] as char;
for &(some_m, n) in &MULTIPLIERS {
if some_m == m {
piece *= 1i64<<n;
break;
}
}
}
decoded += piece;
}
if last_pos < encoded.len() {
return Err(());
}
Ok(decoded)
}
#[derive(Debug, RustcDecodable)]
struct Args {
flag_db_dir: String,
flag_sample_file_dir: String,
}
pub fn run() -> Result<(), Error> {
let args: Args = super::parse_args(USAGE)?;
super::install_logger(false);
let (_db_dir, conn) = super::open_conn(&args.flag_db_dir, super::OpenMode::ReadWrite)?;
let db = Arc::new(db::Database::new(conn)?);
//let dir = Arc::new(dir::Fd::open(&args.flag_sample_file_dir)?);
let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone())?;
let mut siv = Cursive::new();
//siv.add_global_callback('q', |s| s.quit());
siv.add_layer(views::Dialog::around(
views::SelectView::<fn(&Arc<db::Database>, &Arc<dir::SampleFileDir>, &mut Cursive)>::new()
.on_submit({
let db = db.clone();
let dir = dir.clone();
move |siv, item| item(&db, &dir, siv)
})
.item("Edit cameras".to_string(), cameras::add_dialog)
.item("Edit retention".to_string(), retention::add_dialog))
.button("Quit", |siv| siv.quit())
.title("Main menu"));
siv.run();
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn test_decode() {
assert_eq!(super::decode_size("100M").unwrap(), 100i64 << 20);
}
}

View File

@ -0,0 +1,265 @@
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2017 Scott Lamb <slamb@slamb.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
extern crate cursive;
use self::cursive::Cursive;
use self::cursive::traits::{Boxable, Identifiable};
use self::cursive::views;
use db;
use dir;
use error::Error;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use std::sync::Arc;
use super::{decode_size, encode_size};
struct Camera {
label: String,
used: i64,
retain: Option<i64>, // None if unparseable
}
struct Model {
db: Arc<db::Database>,
dir: Arc<dir::SampleFileDir>,
fs_capacity: i64,
total_used: i64,
total_retain: i64,
errors: isize,
cameras: BTreeMap<i32, Camera>,
}
/// Updates the limits in the database. Doesn't delete excess data (if any).
fn update_limits_inner(model: &Model) -> Result<(), Error> {
let mut db = model.db.lock();
let mut tx = db.tx()?;
for (&id, camera) in &model.cameras {
tx.update_retention(id, camera.retain.unwrap())?;
}
tx.commit()
}
fn update_limits(model: &Model, siv: &mut Cursive) {
if let Err(e) = update_limits_inner(model) {
siv.add_layer(views::Dialog::text(format!("Unable to update limits: {}", e))
.dismiss_button("Back")
.title("Error"));
}
}
fn edit_limit(model: &RefCell<Model>, siv: &mut Cursive, id: i32, content: &str) {
info!("on_edit called for id {}", id);
let mut model = model.borrow_mut();
let model: &mut Model = &mut *model;
let camera = model.cameras.get_mut(&id).unwrap();
let new_value = decode_size(content).ok();
let delta = new_value.unwrap_or(0) - camera.retain.unwrap_or(0);
let old_errors = model.errors;
if delta != 0 {
let prev_over = model.total_retain > model.fs_capacity;
model.total_retain += delta;
siv.find_id::<views::TextView>("total_retain")
.unwrap()
.set_content(encode_size(model.total_retain));
let now_over = model.total_retain > model.fs_capacity;
info!("now_over: {}", now_over);
if now_over != prev_over {
model.errors += if now_over { 1 } else { -1 };
siv.find_id::<views::TextView>("total_ok")
.unwrap()
.set_content(if now_over { "*" } else { " " });
}
}
if new_value.is_none() != camera.retain.is_none() {
model.errors += if new_value.is_none() { 1 } else { -1 };
siv.find_id::<views::TextView>(&format!("{}_ok", id))
.unwrap()
.set_content(if new_value.is_none() { "*" } else { " " });
}
camera.retain = new_value;
info!("model.errors = {}", model.errors);
if (model.errors == 0) != (old_errors == 0) {
info!("toggling change state: errors={}", model.errors);
siv.find_id::<views::Button>("change")
.unwrap()
.set_enabled(model.errors == 0);
}
}
fn confirm_deletion(model: &RefCell<Model>, siv: &mut Cursive, to_delete: i64) {
let typed = siv.find_id::<views::EditView>("confirm")
.unwrap()
.get_content();
info!("confirm, typed: {} vs expected: {}", typed.as_str(), to_delete);
if decode_size(typed.as_str()).ok() == Some(to_delete) {
actually_delete(model, siv);
} else {
siv.add_layer(views::Dialog::text("Please confirm amount.")
.title("Try again")
.dismiss_button("Back"));
}
}
fn actually_delete(model: &RefCell<Model>, siv: &mut Cursive) {
let model = &*model.borrow();
let new_limits: Vec<_> =
model.cameras.iter()
.map(|(&id, c)| dir::NewLimit{camera_id: id, limit: c.retain.unwrap()})
.collect();
siv.pop_layer(); // deletion confirmation
siv.pop_layer(); // retention dialog
if let Err(e) = dir::lower_retention(model.dir.clone(), &new_limits[..]) {
siv.add_layer(views::Dialog::text(format!("Unable to delete excess video: {}", e))
.title("Error")
.dismiss_button("Abort"));
} else {
update_limits(model, siv);
}
}
fn press_change(model: &Rc<RefCell<Model>>, siv: &mut Cursive) {
if model.borrow().errors > 0 {
return;
}
let to_delete = model.borrow().cameras.values().map(
|c| ::std::cmp::max(c.used - c.retain.unwrap(), 0)).sum();
info!("change press, to_delete={}", to_delete);
if to_delete > 0 {
let prompt = format!("Some cameras' usage exceeds new limit. Please confirm the amount \
of data to delete by typing it back:\n\n{}", encode_size(to_delete));
let dialog = views::Dialog::around(
views::LinearLayout::vertical()
.child(views::TextView::new(prompt))
.child(views::DummyView)
.child(views::EditView::new().on_submit({
let model = model.clone();
move |siv, _| confirm_deletion(&model, siv, to_delete)
}).with_id("confirm")))
.button("Confirm", {
let model = model.clone();
move |siv| confirm_deletion(&model, siv, to_delete)
})
.dismiss_button("Cancel")
.title("Confirm deletion");
siv.add_layer(dialog);
} else {
siv.screen_mut().pop_layer();
update_limits(&model.borrow(), siv);
}
}
pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive) {
let model = {
let mut cameras = BTreeMap::new();
let mut total_used = 0;
let mut total_retain = 0;
{
let db = db.lock();
for (&id, camera) in db.cameras_by_id() {
cameras.insert(id, Camera{
label: format!("{}: {}", id, camera.short_name),
used: camera.sample_file_bytes,
retain: Some(camera.retain_bytes),
});
total_used += camera.sample_file_bytes;
total_retain += camera.retain_bytes;
}
}
let stat = dir.statfs().unwrap();
let fs_capacity = (stat.f_bsize * (stat.f_blocks - stat.f_bfree + stat.f_bavail)) as i64 -
total_used;
Rc::new(RefCell::new(Model{
dir: dir.clone(),
db: db.clone(),
fs_capacity: fs_capacity,
total_used: total_used,
total_retain: total_retain,
errors: (total_retain > fs_capacity) as isize,
cameras: cameras,
}))
};
let mut list = views::ListView::new();
list.add_child(
"camera",
views::LinearLayout::horizontal()
.child(views::TextView::new("usage").fixed_width(25))
.child(views::TextView::new("limit").fixed_width(25)));
for (&id, camera) in &model.borrow().cameras {
list.add_child(
&camera.label,
views::LinearLayout::horizontal()
.child(views::TextView::new(encode_size(camera.used)).fixed_width(25))
.child(views::EditView::new()
.content(encode_size(camera.retain.unwrap()))
.on_edit({
let model = model.clone();
move |siv, content, _pos| edit_limit(&model, siv, id, content)
})
.on_submit({
let model = model.clone();
move |siv, _| press_change(&model, siv)
})
.fixed_width(25))
.child(views::TextView::new("").with_id(&format!("{}_ok", id)).fixed_width(1)));
}
let over = model.borrow().total_retain > model.borrow().fs_capacity;
list.add_child(
"total",
views::LinearLayout::horizontal()
.child(views::TextView::new(encode_size(model.borrow().total_used)).fixed_width(25))
.child(views::TextView::new(encode_size(model.borrow().total_retain))
.with_id("total_retain").fixed_width(25))
.child(views::TextView::new(if over { "*" } else { " " }).with_id("total_ok")));
list.add_child(
"filesystem",
views::LinearLayout::horizontal()
.child(views::TextView::new("").fixed_width(25))
.child(views::TextView::new(encode_size(model.borrow().fs_capacity)).fixed_width(25)));
let mut change_button = views::Button::new("Change", {
let model = model.clone();
move |siv| press_change(&model, siv)
});
change_button.set_enabled(!over);
let mut buttons = views::LinearLayout::horizontal()
.child(views::DummyView.full_width());
buttons.add_child(change_button.with_id("change"));
buttons.add_child(views::DummyView);
buttons.add_child(views::Button::new("Cancel", |siv| siv.screen_mut().pop_layer()));
siv.add_layer(
views::Dialog::around(
views::LinearLayout::vertical()
.child(list)
.child(views::DummyView)
.child(buttons))
.title("Edit retention"));
}

View File

@ -40,6 +40,7 @@ use slog_term;
use std::path::Path;
mod check;
mod config;
mod init;
mod run;
mod ts;
@ -48,6 +49,7 @@ mod upgrade;
#[derive(Debug, RustcDecodable)]
pub enum Command {
Check,
Config,
Init,
Run,
Ts,
@ -58,6 +60,7 @@ impl Command {
pub fn run(&self) -> Result<(), Error> {
match *self {
Command::Check => check::run(),
Command::Config => config::run(),
Command::Init => init::run(),
Command::Run => run::run(),
Command::Ts => ts::run(),
@ -71,7 +74,7 @@ impl Command {
/// Sync logging should be preferred for other modes because async apparently is never flushed
/// before the program exits, and partial output from these tools is very confusing.
fn install_logger(async: bool) {
let drain = slog_term::StreamerBuilder::new();
let drain = slog_term::StreamerBuilder::new().stderr();
let drain = slog_envlogger::new(if async { drain.async() } else { drain }.full().build());
slog_stdlog::set_logger(slog::Logger::root(drain.ignore_err(), None)).unwrap();
}

135
src/db.rs
View File

@ -342,6 +342,18 @@ pub struct Camera {
next_recording_id: i32,
}
/// Information about a camera, used by `add_camera` and `update_camera`.
#[derive(Debug)]
pub struct CameraChange {
pub short_name: String,
pub description: String,
pub host: String,
pub username: String,
pub password: String,
pub main_rtsp_path: String,
pub sub_rtsp_path: String,
}
/// Adds `delta` to the day represented by `day` in the map `m`.
/// Inserts a map entry if absent; removes the entry if it has 0 entries on exit.
fn adjust_day(day: CameraDayKey, delta: CameraDayValue,
@ -527,6 +539,9 @@ struct CameraModification {
/// Reset the next_recording_id to the specified value.
new_next_recording_id: Option<i32>,
/// Reset the retain_bytes to the specified value.
new_retain_bytes: Option<i64>,
}
fn composite_id(camera_id: i32, recording_id: i32) -> i64 {
@ -667,6 +682,26 @@ impl<'a> Transaction<'a> {
Ok(())
}
/// Updates the `retain_bytes` for the given camera to the specified limit.
/// Note this just resets the limit in the database; it's the caller's responsibility to ensure
/// current usage is under the new limit if desired.
pub fn update_retention(&mut self, camera_id: i32, new_limit: i64) -> Result<(), Error> {
if new_limit < 0 {
return Err(Error::new(format!("can't set limit for camera {} to {}; must be >= 0",
camera_id, new_limit)));
}
self.check_must_rollback()?;
let mut stmt =
self.tx.prepare_cached("update camera set retain_bytes = :retain where id = :id")?;
let changes = stmt.execute_named(&[(":retain", &new_limit), (":id", &camera_id)])?;
if changes != 1 {
return Err(Error::new(format!("no such camera {}", camera_id)));
}
let mut m = Transaction::get_mods_by_camera(&mut self.mods_by_camera, camera_id);
m.new_retain_bytes = Some(new_limit);
Ok(())
}
/// Commits these changes, consuming the Transaction.
pub fn commit(mut self) -> Result<(), Error> {
self.check_must_rollback()?;
@ -684,6 +719,9 @@ impl<'a> Transaction<'a> {
if let Some(id) = m.new_next_recording_id {
camera.next_recording_id = id;
}
if let Some(b) = m.new_retain_bytes {
camera.retain_bytes = b;
}
}
Ok(())
}
@ -706,6 +744,7 @@ impl<'a> Transaction<'a> {
range: None,
days: BTreeMap::new(),
new_next_recording_id: None,
new_retain_bytes: None,
}
})
}
@ -1103,6 +1142,102 @@ impl LockedDatabase {
Ok(id)
}
/// Adds a camera.
pub fn add_camera(&mut self, camera: CameraChange) -> Result<i32, Error> {
let uuid = Uuid::new_v4();
let uuid_bytes = &uuid.as_bytes()[..];
let mut stmt = self.conn.prepare_cached(r#"
insert into camera (uuid, short_name, description, host, username, password,
main_rtsp_path, sub_rtsp_path, retain_bytes, next_recording_id)
values (:uuid, :short_name, :description, :host, :username, :password,
:main_rtsp_path, :sub_rtsp_path, 0, 1)
"#)?;
stmt.execute_named(&[
(":uuid", &uuid_bytes),
(":short_name", &camera.short_name),
(":description", &camera.description),
(":host", &camera.host),
(":username", &camera.username),
(":password", &camera.password),
(":main_rtsp_path", &camera.main_rtsp_path),
(":sub_rtsp_path", &camera.sub_rtsp_path),
])?;
let id = self.conn.last_insert_rowid() as i32;
self.state.cameras_by_id.insert(id, Camera{
id: id,
uuid: uuid,
short_name: camera.short_name,
description: camera.description,
host: camera.host,
username: camera.username,
password: camera.password,
main_rtsp_path: camera.main_rtsp_path,
sub_rtsp_path: camera.sub_rtsp_path,
retain_bytes: 0,
range: None,
sample_file_bytes: 0,
duration: recording::Duration(0),
days: BTreeMap::new(),
next_recording_id: 1,
});
self.state.cameras_by_uuid.insert(uuid, id);
Ok(id)
}
/// Updates a camera.
pub fn update_camera(&mut self, id: i32, camera: CameraChange) -> Result<(), Error> {
let mut stmt = self.conn.prepare_cached(r#"
update camera set
short_name = :short_name,
description = :description,
host = :host,
username = :username,
password = :password,
main_rtsp_path = :main_rtsp_path,
sub_rtsp_path = :sub_rtsp_path
where
id = :id
"#)?;
stmt.execute_named(&[
(":id", &id),
(":short_name", &camera.short_name),
(":description", &camera.description),
(":host", &camera.host),
(":username", &camera.username),
(":password", &camera.password),
(":main_rtsp_path", &camera.main_rtsp_path),
(":sub_rtsp_path", &camera.sub_rtsp_path),
])?;
let c = self.state.cameras_by_id.get_mut(&id).unwrap();
c.short_name = camera.short_name;
c.description = camera.description;
c.host = camera.host;
c.username = camera.username;
c.password = camera.password;
c.main_rtsp_path = camera.main_rtsp_path;
c.sub_rtsp_path = camera.sub_rtsp_path;
Ok(())
}
/// Deletes a camera. The camera must have no recordings.
pub fn delete_camera(&mut self, id: i32) -> Result<(), Error> {
let (has_recordings, uuid) =
self.state.cameras_by_id.get(&id)
.map(|c| (c.range.is_some(), c.uuid))
.ok_or_else(|| Error::new(format!("No such camera {} to remove", id)))?;
if has_recordings {
return Err(Error::new(format!("Can't remove camera {}; has recordings.", id)));
};
let mut stmt = self.conn.prepare_cached(r"delete from camera where id = :id")?;
let rows = stmt.execute_named(&[(":id", &id)])?;
if rows != 1 {
return Err(Error::new(format!("Camera {} missing from database", id)));
}
self.state.cameras_by_id.remove(&id);
self.state.cameras_by_uuid.remove(&uuid);
return Ok(())
}
}
/// Gets the schema version from the given database connection.

View File

@ -43,7 +43,7 @@ use std::fs;
use std::io::{self, Write};
use std::mem;
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex, MutexGuard};
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
use uuid::Uuid;
@ -98,6 +98,16 @@ impl Fd {
}
Ok(())
}
pub fn statfs(&self) -> Result<libc::statvfs, io::Error> {
unsafe {
let mut stat: libc::statvfs = mem::zeroed();
if libc::fstatvfs(self.0, &mut stat) < 0 {
return Err(io::Error::last_os_error())
}
Ok(stat)
}
}
}
impl SampleFileDir {
@ -153,6 +163,8 @@ impl SampleFileDir {
Writer::open(f, uuid, prev, camera_id, video_sample_entry_id, channel)
}
pub fn statfs(&self) -> Result<libc::statvfs, io::Error> { self.fd.statfs() }
/// Opens a sample file within this directory with the given flags and (if creating) mode.
fn open_int(&self, uuid: Uuid, flags: libc::c_int, mode: libc::c_int)
-> Result<fs::File, io::Error> {
@ -215,11 +227,10 @@ enum SyncerCommand {
pub struct SyncerChannel(mpsc::Sender<SyncerCommand>);
/// State of the worker thread.
struct SyncerState {
struct Syncer {
dir: Arc<SampleFileDir>,
to_unlink: Vec<Uuid>,
to_mark_deleted: Vec<Uuid>,
cmds: mpsc::Receiver<SyncerCommand>,
}
/// Starts a syncer for the given sample file directory.
@ -234,15 +245,71 @@ pub fn start_syncer(dir: Arc<SampleFileDir>)
-> Result<(SyncerChannel, thread::JoinHandle<()>), Error> {
let to_unlink = dir.db.lock().list_reserved_sample_files()?;
let (snd, rcv) = mpsc::channel();
let mut state = SyncerState {
let mut syncer = Syncer {
dir: dir,
to_unlink: to_unlink,
to_mark_deleted: Vec::new(),
cmds: rcv,
};
state.initial_rotation()?;
syncer.initial_rotation()?;
Ok((SyncerChannel(snd),
thread::Builder::new().name("syncer".into()).spawn(move || state.run()).unwrap()))
thread::Builder::new().name("syncer".into()).spawn(move || syncer.run(rcv)).unwrap()))
}
pub struct NewLimit {
pub camera_id: i32,
pub limit: i64,
}
/// Deletes recordings if necessary to fit within the given new `retain_bytes` limit.
/// Note this doesn't change the limit in the database; it only deletes files.
/// Pass a limit of 0 to delete all recordings associated with a camera.
pub fn lower_retention(dir: Arc<SampleFileDir>, limits: &[NewLimit]) -> Result<(), Error> {
let to_unlink = dir.db.lock().list_reserved_sample_files()?;
let mut syncer = Syncer {
dir: dir,
to_unlink: to_unlink,
to_mark_deleted: Vec::new(),
};
syncer.do_rotation(|db| {
let mut to_delete = Vec::new();
for l in limits {
let before = to_delete.len();
let camera = db.cameras_by_id().get(&l.camera_id)
.ok_or_else(|| Error::new(format!("no such camera {}", l.camera_id)))?;
if l.limit >= camera.sample_file_bytes { continue }
get_rows_to_delete(db, l.camera_id, camera, camera.retain_bytes - l.limit,
&mut to_delete)?;
info!("camera {}, {}->{}, deleting {} rows", camera.short_name,
camera.sample_file_bytes, l.limit, to_delete.len() - before);
}
Ok(to_delete)
})
}
/// Gets rows to delete to bring a camera's disk usage within bounds.
fn get_rows_to_delete(db: &db::LockedDatabase, camera_id: i32,
camera: &db::Camera, extra_bytes_needed: i64,
to_delete: &mut Vec<db::ListOldestSampleFilesRow>) -> Result<(), Error> {
let bytes_needed = camera.sample_file_bytes + extra_bytes_needed - camera.retain_bytes;
let mut bytes_to_delete = 0;
if bytes_needed <= 0 {
debug!("{}: have remaining quota of {}", camera.short_name, -bytes_needed);
return Ok(());
}
let mut n = 0;
db.list_oldest_sample_files(camera_id, |row| {
bytes_to_delete += row.sample_file_bytes as i64;
to_delete.push(row);
n += 1;
bytes_needed > bytes_to_delete // continue as long as more deletions are needed.
})?;
if bytes_needed > bytes_to_delete {
return Err(Error::new(format!("{}: couldn't find enough files to delete: {} left.",
camera.short_name, bytes_needed)));
}
info!("{}: deleting {} bytes in {} recordings ({} bytes needed)",
camera.short_name, bytes_to_delete, n, bytes_needed);
Ok(())
}
impl SyncerChannel {
@ -265,10 +332,10 @@ impl SyncerChannel {
}
}
impl SyncerState {
fn run(&mut self) {
impl Syncer {
fn run(&mut self, cmds: mpsc::Receiver<SyncerCommand>) {
loop {
match self.cmds.recv() {
match cmds.recv() {
Err(_) => return, // all senders have closed the channel; shutdown
Ok(SyncerCommand::AsyncSaveRecording(recording, f)) => self.save(recording, f),
Ok(SyncerCommand::AsyncAbandonRecording(uuid)) => self.abandon(uuid),
@ -280,16 +347,25 @@ impl SyncerState {
/// Rotates files for all cameras and deletes stale reserved uuids from previous runs.
fn initial_rotation(&mut self) -> Result<(), Error> {
let mut to_delete = Vec::new();
{
let mut db = self.dir.db.lock();
self.do_rotation(|db| {
let mut to_delete = Vec::new();
for (camera_id, camera) in db.cameras_by_id() {
self.get_rows_to_delete(&db, *camera_id, camera, 0, &mut to_delete)?;
get_rows_to_delete(&db, *camera_id, camera, 0, &mut to_delete)?;
}
Ok(to_delete)
})
}
fn do_rotation<F>(&mut self, get_rows_to_delete: F) -> Result<(), Error>
where F: FnOnce(&db::LockedDatabase) -> Result<Vec<db::ListOldestSampleFilesRow>, Error> {
let to_delete = {
let mut db = self.dir.db.lock();
let to_delete = get_rows_to_delete(&*db)?;
let mut tx = db.tx()?;
tx.delete_recordings(&to_delete)?;
tx.commit()?;
}
to_delete
};
for row in to_delete {
self.to_unlink.push(row.uuid);
}
@ -345,8 +421,8 @@ impl SyncerState {
let camera =
db.cameras_by_id().get(&recording.camera_id)
.ok_or_else(|| Error::new(format!("no such camera {}", recording.camera_id)))?;
self.get_rows_to_delete(&db, recording.camera_id, camera,
recording.sample_file_bytes as i64, &mut to_delete)?;
get_rows_to_delete(&db, recording.camera_id, camera,
recording.sample_file_bytes as i64, &mut to_delete)?;
}
let mut tx = db.tx()?;
tx.mark_sample_files_deleted(&self.to_mark_deleted)?;
@ -363,32 +439,6 @@ impl SyncerState {
Ok(())
}
/// Gets rows to delete to bring a camera's disk usage within bounds.
fn get_rows_to_delete(&self, db: &MutexGuard<db::LockedDatabase>, camera_id: i32,
camera: &db::Camera, extra_bytes_needed: i64,
to_delete: &mut Vec<db::ListOldestSampleFilesRow>) -> Result<(), Error> {
let bytes_needed = camera.sample_file_bytes + extra_bytes_needed - camera.retain_bytes;
let mut bytes_to_delete = 0;
if bytes_needed <= 0 {
debug!("{}: have remaining quota of {}", camera.short_name, -bytes_needed);
return Ok(());
}
let mut n = 0;
db.list_oldest_sample_files(camera_id, |row| {
bytes_to_delete += row.sample_file_bytes as i64;
to_delete.push(row);
n += 1;
bytes_needed > bytes_to_delete // continue as long as more deletions are needed.
})?;
if bytes_needed > bytes_to_delete {
return Err(Error::new(format!("{}: couldn't find enough files to delete: {} left.",
camera.short_name, bytes_needed)));
}
info!("{}: deleting {} bytes in {} recordings ({} bytes needed)",
camera.short_name, bytes_to_delete, n, bytes_needed);
Ok(())
}
/// Tries to unlink all the uuids in `self.to_unlink`. Any which can't be unlinked will
/// be retained in the vec.
fn try_unlink(&mut self) {

View File

@ -93,6 +93,7 @@ Commands:
check Check database integrity
init Initialize a database
run Run the daemon: record from cameras and handle HTTP requests
shell Start an interactive shell to modify the database
ts Translate between human-readable and numeric timestamps
upgrade Upgrade the database to the latest schema
";