json-based config for cameras and streams

for #155

The config interface code for changing cameras is quite messy but
seems to work for now.
This commit is contained in:
Scott Lamb
2021-09-10 16:31:03 -07:00
parent 070400095d
commit dafd9041d6
14 changed files with 694 additions and 315 deletions

View File

@@ -4,104 +4,117 @@
use crate::stream::{self, Opener};
use base::strutil::{decode_size, encode_size};
use cursive::traits::{Boxable, Finder, Identifiable};
use cursive::traits::{Boxable, Finder, Identifiable, Scrollable};
use cursive::views::{self, ViewRef};
use cursive::Cursive;
use db::writer;
use failure::{bail, Error, ResultExt};
use failure::{bail, format_err, Error, ResultExt};
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use url::Url;
/// Builds a `CameraChange` from an active `edit_camera_dialog`.
fn get_change(siv: &mut Cursive) -> Result<db::CameraChange, Error> {
struct Camera {
short_name: String,
description: String,
onvif_base_url: String,
username: String,
password: String,
streams: [Stream; db::NUM_STREAM_TYPES],
}
#[derive(Default)]
struct Stream {
url: String,
record: bool,
flush_if_sec: String,
sample_file_dir_id: Option<i32>,
}
/// Builds a `Camera` from an active `edit_camera_dialog`. No validation.
fn get_camera(siv: &mut Cursive) -> Camera {
// Note: these find_name calls are separate statements, which seems to be important:
// https://github.com/gyscos/Cursive/issues/144
let sn = siv
let short_name = siv
.find_name::<views::EditView>("short_name")
.unwrap()
.get_content()
.as_str()
.into();
let d = siv
let description = siv
.find_name::<views::TextArea>("description")
.unwrap()
.get_content()
.into();
let h = siv
.find_name::<views::EditView>("onvif_host")
let onvif_base_url: String = siv
.find_name::<views::EditView>("onvif_base_url")
.unwrap()
.get_content()
.as_str()
.into();
let username = match siv
let username = siv
.find_name::<views::EditView>("username")
.unwrap()
.get_content()
.as_str()
{
"" => None,
u => Some(u.to_owned()),
};
let password = match siv
.to_owned();
let password = siv
.find_name::<views::EditView>("password")
.unwrap()
.get_content()
.as_str()
{
"" => None,
p => Some(p.to_owned()),
};
let mut c = db::CameraChange {
short_name: sn,
description: d,
onvif_host: h,
.to_owned();
let mut camera = Camera {
short_name,
description,
onvif_base_url,
username,
password,
streams: Default::default(),
};
for &t in &db::ALL_STREAM_TYPES {
let rtsp_url = parse_url(
siv.find_name::<views::EditView>(&format!("{}_rtsp_url", t.as_str()))
.unwrap()
.get_content()
.as_str(),
)?;
let url = siv
.find_name::<views::EditView>(&format!("{}_url", t.as_str()))
.unwrap()
.get_content()
.as_str()
.to_owned();
let record = siv
.find_name::<views::Checkbox>(&format!("{}_record", t.as_str()))
.unwrap()
.is_checked();
let flush_if_sec = i64::from_str(
siv.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
.unwrap()
.get_content()
.as_str(),
)
.unwrap_or(0);
let flush_if_sec = siv
.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
.unwrap()
.get_content()
.as_str()
.to_owned();
let sample_file_dir_id = *siv
.find_name::<views::SelectView<Option<i32>>>(&format!("{}_sample_file_dir", t.as_str()))
.unwrap()
.selection()
.unwrap();
c.streams[t.index()] = db::StreamChange {
rtsp_url,
sample_file_dir_id,
camera.streams[t.index()] = Stream {
url,
record,
flush_if_sec,
sample_file_dir_id,
};
}
Ok(c)
camera
}
/// Attempts to parse a URL field into a sort-of-validated URL.
fn parse_url(raw: &str) -> Result<Option<Url>, Error> {
fn parse_url(raw: &str, allowed_schemes: &'static [&'static str]) -> Result<Option<Url>, Error> {
if raw.is_empty() {
return Ok(None);
}
let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?;
if url.scheme() != "rtsp" {
bail!("Expected URL scheme rtsp:// in URL {}", &url);
if allowed_schemes
.iter()
.find(|scheme| **scheme == url.scheme())
.is_none()
{
bail!("Unexpected scheme in URL {}", &url);
}
if !url.username().is_empty() || url.password().is_some() {
bail!(
@@ -114,17 +127,46 @@ fn parse_url(raw: &str) -> Result<Option<Url>, Error> {
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
let result = (|| {
let change = get_change(siv)?;
for (i, stream) in change.streams.iter().enumerate() {
if stream.record && (stream.rtsp_url.is_none() || stream.sample_file_dir_id.is_none()) {
let type_ = db::StreamType::from_index(i).unwrap();
let mut l = db.lock();
let mut change = if let Some(id) = id {
l.null_camera_change(id)?
} else {
db::CameraChange::default()
};
let camera = get_camera(siv);
change.short_name = camera.short_name;
change.config.description = camera.description;
change.config.onvif_base_url = parse_url(&camera.onvif_base_url, &["http", "https"])?;
change.config.username = camera.username;
change.config.password = camera.password;
for (i, stream) in camera.streams.iter().enumerate() {
let type_ = db::StreamType::from_index(i).unwrap();
if stream.record && (stream.url.is_empty() || stream.sample_file_dir_id.is_none()) {
bail!(
"Can't record {} stream without RTSP URL and sample file directory",
type_.as_str()
);
}
let stream_change = &mut change.streams[i];
stream_change.config.mode = (if stream.record {
db::json::STREAM_MODE_RECORD
} else {
""
})
.to_owned();
stream_change.config.url = parse_url(&stream.url, &["rtsp"])?;
stream_change.sample_file_dir_id = stream.sample_file_dir_id;
stream_change.config.flush_if_sec = if stream.flush_if_sec.is_empty() {
0
} else {
stream.flush_if_sec.parse().map_err(|_| {
format_err!(
"flush_if_sec for {} must be a non-negative integer",
type_.as_str()
)
})?
};
}
let mut l = db.lock();
if let Some(id) = id {
l.update_camera(id, change)
} else {
@@ -146,17 +188,14 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
}
}
fn press_test_inner(
url: Url,
username: Option<String>,
password: Option<String>,
) -> Result<String, Error> {
fn press_test_inner(url: Url, username: String, password: String) -> Result<String, Error> {
let pass_creds = !username.is_empty();
let (extra_data, _stream) = stream::FFMPEG.open(
"test stream".to_owned(),
stream::Source::Rtsp {
url,
username,
password,
username: if pass_creds { Some(username) } else { None },
password: if pass_creds { Some(password) } else { None },
transport: retina::client::Transport::Tcp,
},
)?;
@@ -167,21 +206,15 @@ fn press_test_inner(
}
fn press_test(siv: &mut Cursive, t: db::StreamType) {
let mut c = match get_change(siv) {
Ok(u) => u,
Err(e) => {
siv.add_layer(
views::Dialog::text(format!("{}", e))
.title("Stream test failed")
.dismiss_button("Back"),
);
return;
}
let c = get_camera(siv);
let url = &c.streams[t.index()].url;
let url = match parse_url(url, &["rtsp"]) {
Ok(Some(u)) => u,
_ => panic!(
"test button should only be enabled with valid URL, not {:?}",
url
),
};
let url = c.streams[t.index()]
.rtsp_url
.take()
.expect("test button only enabled when URL set");
let username = c.username;
let password = c.password;
@@ -190,7 +223,7 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) {
"Testing {} stream at {}. This may take a while \
on timeout or if you have a long key frame interval",
t.as_str(),
&url
url.as_str()
))
.title("Testing"),
);
@@ -348,7 +381,7 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
}
fn edit_url(content: &str, mut test_button: ViewRef<views::Button>) {
let enable_test = matches!(parse_url(content), Ok(Some(_)));
let enable_test = matches!(parse_url(content, &["rtsp"]), Ok(Some(_)));
test_button.set_enabled(enable_test);
}
@@ -365,7 +398,10 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
)
.child("uuid", views::TextView::new("<new>").with_name("uuid"))
.child("short name", views::EditView::new().with_name("short_name"))
.child("onvif_host", views::EditView::new().with_name("onvif_host"))
.child(
"onvif_base_url",
views::EditView::new().with_name("onvif_base_url"),
)
.child("username", views::EditView::new().with_name("username"))
.child("password", views::EditView::new().with_name("password"))
.min_height(6);
@@ -399,7 +435,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
.unwrap();
edit_url(content, test_button)
})
.with_name(format!("{}_rtsp_url", type_.as_str()))
.with_name(format!("{}_url", type_.as_str()))
.full_width(),
)
.child(views::DummyView)
@@ -434,7 +470,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
layout.add_child(list);
}
let mut dialog = views::Dialog::around(layout);
let mut dialog = views::Dialog::around(layout.scrollable());
let dialog = if let Some(camera_id) = *item {
let l = db.lock();
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
@@ -460,35 +496,41 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
}
}
bytes += s.sample_file_bytes;
let u = if s.retain_bytes == 0 {
let u = if s.config.retain_bytes == 0 {
"0 / 0 (0.0%)".to_owned()
} else {
format!(
"{} / {} ({:.1}%)",
s.fs_bytes,
s.retain_bytes,
100. * s.fs_bytes as f32 / s.retain_bytes as f32
s.config.retain_bytes,
100. * s.fs_bytes as f32 / s.config.retain_bytes as f32
)
};
dialog.call_on_name(
&format!("{}_rtsp_url", t.as_str()),
|v: &mut views::EditView| v.set_content(s.rtsp_url.to_owned()),
);
dialog.call_on_name(&format!("{}_url", t.as_str()), |v: &mut views::EditView| {
if let Some(url) = s.config.url.as_ref() {
v.set_content(url.as_str().to_owned());
}
});
let test_button = dialog
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
.unwrap();
edit_url(&s.rtsp_url, test_button);
edit_url(
&s.config.url.as_ref().map(Url::as_str).unwrap_or(""),
test_button,
);
dialog.call_on_name(
&format!("{}_usage_cap", t.as_str()),
|v: &mut views::TextView| v.set_content(u),
);
dialog.call_on_name(
&format!("{}_record", t.as_str()),
|v: &mut views::Checkbox| v.set_checked(s.record),
|v: &mut views::Checkbox| {
v.set_checked(s.config.mode == db::json::STREAM_MODE_RECORD)
},
);
dialog.call_on_name(
&format!("{}_flush_if_sec", t.as_str()),
|v: &mut views::EditView| v.set_content(s.flush_if_sec.to_string()),
|v: &mut views::EditView| v.set_content(s.config.flush_if_sec.to_string()),
);
}
dialog.call_on_name(
@@ -499,9 +541,17 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
let name = camera.short_name.clone();
for &(view_id, content) in &[
("short_name", &*camera.short_name),
("onvif_host", &*camera.onvif_host),
("username", camera.username.as_deref().unwrap_or("")),
("password", camera.password.as_deref().unwrap_or("")),
(
"onvif_base_url",
&camera
.config
.onvif_base_url
.as_ref()
.map(Url::as_str)
.unwrap_or(""),
),
("username", &camera.config.username),
("password", &camera.config.password),
] {
dialog
.call_on_name(view_id, |v: &mut views::EditView| {
@@ -511,7 +561,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
}
dialog
.call_on_name("description", |v: &mut views::TextArea| {
v.set_content(camera.description.to_string())
v.set_content(camera.config.description.clone())
})
.expect("missing TextArea");
dialog

View File

@@ -315,12 +315,12 @@ fn edit_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
Stream {
label: format!("{}: {}: {}", id, c.short_name, s.type_.as_str()),
used: s.fs_bytes,
record: s.record,
retain: Some(s.retain_bytes),
record: s.config.mode == db::json::STREAM_MODE_RECORD,
retain: Some(s.config.retain_bytes),
},
);
total_used += s.fs_bytes;
total_retain += s.retain_bytes;
total_retain += s.config.retain_bytes;
}
if streams.is_empty() {
return delete_dir_dialog(db, siv, dir_id);

View File

@@ -232,12 +232,20 @@ async fn async_run(args: &Args) -> Result<i32, Error> {
// Get the directories that need syncers.
for stream in l.streams_by_id().values() {
if let (Some(id), true) = (stream.sample_file_dir_id, stream.record) {
if stream.config.mode != db::json::STREAM_MODE_RECORD {
continue;
}
if let Some(id) = stream.sample_file_dir_id {
dirs.entry(id).or_insert_with(|| {
let d = l.sample_file_dirs_by_id().get(&id).unwrap();
info!("Starting syncer for path {}", d.path);
d.get().unwrap()
});
} else {
warn!(
"Stream {} set to record but has no sample file dir id",
stream.id
);
}
}
@@ -253,7 +261,7 @@ async fn async_run(args: &Args) -> Result<i32, Error> {
let handle = tokio::runtime::Handle::current();
let l = db.lock();
for (i, (id, stream)) in l.streams_by_id().iter().enumerate() {
if !stream.record {
if stream.config.mode != db::json::STREAM_MODE_RECORD {
continue;
}
let camera = l.cameras_by_id().get(&stream.camera_id).unwrap();

View File

@@ -56,10 +56,9 @@ pub struct Camera<'a> {
pub uuid: Uuid,
pub id: i32,
pub short_name: &'a str,
pub description: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<CameraConfig<'a>>,
pub config: Option<&'a db::json::CameraConfig>,
#[serde(serialize_with = "Camera::serialize_streams")]
pub streams: [Option<Stream<'a>>; 2],
@@ -90,13 +89,7 @@ pub struct Stream<'a> {
pub days: Option<db::days::Map<db::days::StreamValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<StreamConfig<'a>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamConfig<'a> {
pub rtsp_url: &'a str,
pub config: Option<&'a db::json::StreamConfig>,
}
#[derive(Serialize)]
@@ -188,14 +181,9 @@ impl<'a> Camera<'a> {
uuid: c.uuid,
id: c.id,
short_name: &c.short_name,
description: &c.description,
config: match include_config {
false => None,
true => Some(CameraConfig {
onvif_host: &c.onvif_host,
username: c.username.as_deref(),
password: c.password.as_deref(),
}),
true => Some(&c.config),
},
streams: [
Stream::wrap(db, c.streams[0], include_days, include_config)?,
@@ -240,19 +228,17 @@ impl<'a> Stream<'a> {
.ok_or_else(|| format_err!("missing stream {}", id))?;
Ok(Some(Stream {
id: s.id,
retain_bytes: s.retain_bytes,
retain_bytes: s.config.retain_bytes,
min_start_time_90k: s.range.as_ref().map(|r| r.start),
max_end_time_90k: s.range.as_ref().map(|r| r.end),
total_duration_90k: s.duration,
total_sample_file_bytes: s.sample_file_bytes,
fs_bytes: s.fs_bytes,
record: s.record,
record: s.config.mode == db::json::STREAM_MODE_RECORD,
days: if include_days { Some(s.days()) } else { None },
config: match include_config {
false => None,
true => Some(StreamConfig {
rtsp_url: &s.rtsp_url,
}),
true => Some(&s.config),
},
}))
}

View File

@@ -5,7 +5,7 @@
use crate::stream;
use base::clock::{Clocks, TimerGuard};
use db::{dir, recording, writer, Camera, Database, Stream};
use failure::{bail, Error};
use failure::{bail, format_err, Error};
use log::{debug, info, trace, warn};
use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -44,8 +44,8 @@ where
stream_id: i32,
short_name: String,
url: Url,
username: Option<String>,
password: Option<String>,
username: String,
password: String,
}
impl<'a, C> Streamer<'a, C>
@@ -62,7 +62,11 @@ where
rotate_offset_sec: i64,
rotate_interval_sec: i64,
) -> Result<Self, Error> {
let url = Url::parse(&s.rtsp_url)?;
let url = s
.config
.url
.as_ref()
.ok_or_else(|| format_err!("Stream has no RTSP URL"))?;
if !url.username().is_empty() || url.password().is_some() {
bail!("RTSP URL shouldn't include credentials");
}
@@ -77,9 +81,9 @@ where
transport: env.transport,
stream_id,
short_name: format!("{}-{}", c.short_name, s.type_.as_str()),
url,
username: c.username.clone(),
password: c.password.clone(),
url: url.clone(),
username: c.config.username.clone(),
password: c.config.password.clone(),
})
}
@@ -116,8 +120,16 @@ where
self.short_name.clone(),
stream::Source::Rtsp {
url: self.url.clone(),
username: self.username.clone(),
password: self.password.clone(),
username: if self.username.is_empty() {
None
} else {
Some(self.username.clone())
},
password: if self.password.is_empty() {
None
} else {
Some(self.password.clone())
},
transport: self.transport,
},
)?