read-only signals support (#28)

This is mostly untested and useless by itself, but it's a starting
point. In particular:

* there's no way to set up signals or add/remove/update events yet
  except by manual changes to the database.
* if you associate a signal with a camera then remove the camera,
  hitting /api/ will error out.
This commit is contained in:
Scott Lamb
2019-06-06 16:18:13 -07:00
parent cb1bb5d810
commit 6f2c63ffac
9 changed files with 833 additions and 47 deletions

View File

@@ -58,6 +58,7 @@ use crate::dir;
use crate::raw;
use crate::recording::{self, TIME_UNITS_PER_SEC};
use crate::schema;
use crate::signal;
use failure::{Error, bail, format_err};
use fnv::{FnvHashMap, FnvHashSet};
use itertools::Itertools;
@@ -81,7 +82,7 @@ use time;
use uuid::Uuid;
/// Expected schema version. See `guide/schema.md` for more information.
pub const EXPECTED_VERSION: i32 = 3;
pub const EXPECTED_VERSION: i32 = 4;
const GET_RECORDING_PLAYBACK_SQL: &'static str = r#"
select
@@ -530,7 +531,7 @@ fn adjust_days(r: Range<recording::Time>, sign: i64,
let boundary_90k = boundary.sec * TIME_UNITS_PER_SEC;
// Adjust the first day.
let first_day_delta = StreamDayValue{
let first_day_delta = StreamDayValue {
recordings: sign,
duration: recording::Duration(sign * (cmp::min(r.end.0, boundary_90k) - r.start.0)),
};
@@ -550,7 +551,7 @@ fn adjust_days(r: Range<recording::Time>, sign: i64,
return;
}
};
let second_day_delta = StreamDayValue{
let second_day_delta = StreamDayValue {
recordings: sign,
duration: recording::Duration(sign * (r.end.0 - boundary_90k)),
};
@@ -611,6 +612,7 @@ pub struct LockedDatabase {
open_monotonic: recording::Time,
auth: auth::State,
signal: signal::State,
sample_file_dirs_by_id: BTreeMap<i32, SampleFileDir>,
cameras_by_id: BTreeMap<i32, Camera>,
@@ -1784,6 +1786,18 @@ impl LockedDatabase {
req: auth::Request, hash: &auth::SessionHash) -> Result<(), Error> {
self.auth.revoke_session(&self.conn, reason, detail, req, hash)
}
// ---- signal ----
pub fn signals_by_id(&self) -> &BTreeMap<u32, signal::Signal> { self.signal.signals_by_id() }
pub fn signal_types_by_uuid(&self) -> &FnvHashMap<Uuid, signal::Type> {
self.signal.types_by_uuid()
}
pub fn list_changes_by_time(
&self, desired_time: Range<recording::Time>, f: &mut FnMut(&signal::ListStateChangesRow))
-> Result<(), Error> {
self.signal.list_changes_by_time(desired_time, f)
}
}
/// Initializes a database.
@@ -1888,6 +1902,7 @@ impl<C: Clocks + Clone> Database<C> {
})
} else { None };
let auth = auth::State::init(&conn)?;
let signal = signal::State::init(&conn)?;
let db = Database {
db: Some(Mutex::new(LockedDatabase {
conn,
@@ -1896,6 +1911,7 @@ impl<C: Clocks + Clone> Database<C> {
open,
open_monotonic,
auth,
signal,
sample_file_dirs_by_id: BTreeMap::new(),
cameras_by_id: BTreeMap::new(),
cameras_by_uuid: BTreeMap::new(),
@@ -2154,20 +2170,20 @@ mod tests {
fn test_version_too_old() {
testutil::init();
let c = setup_conn();
c.execute_batch("delete from version; insert into version values (2, 0, '');").unwrap();
c.execute_batch("delete from version; insert into version values (3, 0, '');").unwrap();
let e = Database::new(clock::RealClocks {}, c, false).err().unwrap();
assert!(e.to_string().starts_with(
"Database schema version 2 is too old (expected 3)"), "got: {:?}", e);
"Database schema version 3 is too old (expected 4)"), "got: {:?}", e);
}
#[test]
fn test_version_too_new() {
testutil::init();
let c = setup_conn();
c.execute_batch("delete from version; insert into version values (4, 0, '');").unwrap();
c.execute_batch("delete from version; insert into version values (5, 0, '');").unwrap();
let e = Database::new(clock::RealClocks {}, c, false).err().unwrap();
assert!(e.to_string().starts_with(
"Database schema version 4 is too new (expected 3)"), "got: {:?}", e);
"Database schema version 5 is too new (expected 4)"), "got: {:?}", e);
}
/// Basic test of running some queries on a fresh database.

View File

@@ -38,6 +38,7 @@ pub mod dir;
mod raw;
pub mod recording;
mod schema;
pub mod signal;
pub mod upgrade;
pub mod writer;
@@ -46,3 +47,4 @@ pub mod writer;
pub mod testutil;
pub use crate::db::*;
pub use crate::signal::Signal;

View File

@@ -396,5 +396,88 @@ create table user_session (
create index user_session_uid on user_session (user_id);
create table signal (
id integer primary key,
-- a uuid describing the originating object, such as the uuid of the camera
-- for built-in motion detection. There will be a JSON interface for adding
-- events; it will require this UUID to be supplied. An external uuid might
-- indicate "my house security system's zone 23".
source_uuid blob not null check (length(source_uuid) = 16),
-- a uuid describing the type of event. A registry (TBD) will list built-in
-- supported types, such as "Hikvision on-camera motion detection", or
-- "ONVIF on-camera motion detection". External programs can use their own
-- uuids, such as "Elk security system watcher".
type_uuid blob not null check (length(type_uuid) = 16),
-- a short human-readable description of the event to use in mouseovers or event
-- lists, such as "driveway motion" or "front door open".
short_name not null,
unique (source_uuid, type_uuid)
);
-- e.g. "moving/still", "disarmed/away/stay", etc.
-- TODO: just do a protobuf for each type? might be simpler, more flexible.
create table signal_type_enum (
type_uuid blob not null check (length(type_uuid) = 16),
value integer not null check (value > 0 and value < 16),
name text not null,
-- true/1 iff this signal value should be considered "motion" for directly associated cameras.
motion int not null check (motion in (0, 1)) default 0,
color text
);
-- Associations between event sources and cameras.
-- For example, if two cameras have overlapping fields of view, they might be
-- configured such that each camera is associated with both its own motion and
-- the other camera's motion.
create table signal_camera (
signal_id integer references signal (id),
camera_id integer references camera (id),
-- type:
--
-- 0 means direct association, as if the event source if the camera's own
-- motion detection. Here are a couple ways this could be used:
--
-- * when viewing the camera, hotkeys to go to the start of the next or
-- previous event should respect this event.
-- * a list of events might include the recordings associated with the
-- camera in the same timespan.
--
-- 1 means indirect association. A screen associated with the camera should
-- given some indication of this event, but there should be no assumption
-- that the camera will have a direct view of the event. For example, all
-- cameras might be indirectly associated with a doorknob press. Cameras at
-- the back of the house shouldn't be expected to have a direct view of this
-- event, but motion events shortly afterward might warrant extra scrutiny.
type integer not null,
primary key (signal_id, camera_id)
) without rowid;
-- State of signals as of a given timestamp.
create table signal_state (
-- seconds since 1970-01-01 00:00:00 UTC.
time_sec integer primary key,
-- Changes at this timestamp.
--
-- It's possible for a single signal to have multiple states; this means
-- that the signal held a state only momentarily.
--
-- A blob of varints representing a list of (signal number delta, state)
-- pairs. For example,
-- input signals: 1 3 3 200 (must be sorted)
-- deltas: 1 2 0 197 (must be non-negative)
-- states: 1 1 0 2
-- varint: \x01 \x01 \x02 \x01 \x00 \x00 \xc5 \x01 \x02
changes blob
);
insert into version (id, unix_time, notes)
values (3, cast(strftime('%s', 'now') as int), 'db creation');
values (4, cast(strftime('%s', 'now') as int), 'db creation');

360
db/signal.rs Normal file
View File

@@ -0,0 +1,360 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2019 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/>.
use crate::coding;
use crate::db::FromSqlUuid;
use crate::recording;
use failure::{Error, bail, format_err};
use fnv::FnvHashMap;
use rusqlite::{Connection, types::ToSql};
use std::collections::BTreeMap;
use std::ops::Range;
use uuid::Uuid;
/// All state associated with signals. This is the entry point to this module.
pub(crate) struct State {
signals_by_id: BTreeMap<u32, Signal>,
types_by_uuid: FnvHashMap<Uuid, Type>,
points_by_time: BTreeMap<u32, Point>,
}
struct Point {
data: Vec<u8>,
changes_off: usize,
}
impl Point {
fn new(cur: &BTreeMap<u32, u16>, changes: &[u8]) -> Self {
let mut data = Vec::with_capacity(changes.len());
let mut last_signal = 0;
for (&signal, &state) in cur {
let delta = (signal - last_signal) as u32;
coding::append_varint32(delta, &mut data);
coding::append_varint32(state as u32, &mut data);
last_signal = signal;
}
let changes_off = data.len();
data.extend(changes);
Point {
data,
changes_off,
}
}
fn cur(&self) -> PointDataIterator {
PointDataIterator::new(&self.data[0..self.changes_off])
}
fn changes(&self) -> PointDataIterator {
PointDataIterator::new(&self.data[self.changes_off..])
}
}
struct PointDataIterator<'a> {
data: &'a [u8],
cur_pos: usize,
cur_signal: u32,
cur_state: u16,
}
impl<'a> PointDataIterator<'a> {
fn new(data: &'a [u8]) -> Self {
PointDataIterator {
data,
cur_pos: 0,
cur_signal: 0,
cur_state: 0,
}
}
fn next(&mut self) -> Result<Option<(u32, u16)>, Error> {
if self.cur_pos == self.data.len() {
return Ok(None);
}
let (signal_delta, p) = coding::decode_varint32(self.data, self.cur_pos)
.map_err(|()| format_err!("varint32 decode failure; data={:?} pos={}",
self.data, self.cur_pos))?;
let (state, p) = coding::decode_varint32(self.data, p)
.map_err(|()| format_err!("varint32 decode failure; data={:?} pos={}",
self.data, p))?;
let signal = self.cur_signal.checked_add(signal_delta)
.ok_or_else(|| format_err!("signal overflow: {} + {}",
self.cur_signal, signal_delta))?;
if state > u16::max_value() as u32 {
bail!("state overflow: {}", state);
}
self.cur_pos = p;
self.cur_signal = signal;
self.cur_state = state as u16;
Ok(Some((signal, self.cur_state)))
}
}
/// Representation of a `signal_camera` row.
/// `signal_id` is implied by the `Signal` which owns this struct.
#[derive(Debug)]
pub struct SignalCamera {
pub camera_id: i32,
pub type_: SignalCameraType,
}
/// Representation of the `type` field in a `signal_camera` row.
#[derive(Debug)]
pub enum SignalCameraType {
Direct = 0,
Indirect = 1,
}
#[derive(Debug)]
pub struct ListStateChangesRow {
pub when: recording::Time,
pub signal: u32,
pub state: u16,
}
impl State {
pub fn init(conn: &Connection) -> Result<Self, Error> {
let mut signals_by_id = State::init_signals(conn)?;
State::fill_signal_cameras(conn, &mut signals_by_id)?;
Ok(State {
signals_by_id,
types_by_uuid: State::init_types(conn)?,
points_by_time: State::init_points(conn)?,
})
}
pub fn list_changes_by_time(
&self, desired_time: Range<recording::Time>, f: &mut FnMut(&ListStateChangesRow))
-> Result<(), Error> {
// Convert the desired range to seconds. Reducing precision of the end carefully.
let start = desired_time.start.unix_seconds() as u32;
let mut end = desired_time.end.unix_seconds();
end += ((end * recording::TIME_UNITS_PER_SEC) < desired_time.end.0) as i64;
let end = end as u32;
// First find the state immediately before. If it exists, include it.
if let Some((&t, p)) = self.points_by_time.range(..start).next_back() {
let mut cur = BTreeMap::new();
let mut it = p.cur();
while let Some((signal, state)) = it.next()? {
cur.insert(signal, state);
}
let mut it = p.changes();
while let Some((signal, state)) = it.next()? {
if state == 0 {
cur.remove(&signal);
} else {
cur.insert(signal, state);
}
}
for (&signal, &state) in &cur {
f(&ListStateChangesRow {
when: recording::Time(t as i64 * recording::TIME_UNITS_PER_SEC),
signal,
state,
});
}
}
// Then include changes up to (but not including) the end time.
for (&t, p) in self.points_by_time.range(start..end) {
let mut it = p.changes();
while let Some((signal, state)) = it.next()? {
f(&ListStateChangesRow {
when: recording::Time(t as i64 * recording::TIME_UNITS_PER_SEC),
signal,
state,
});
}
}
Ok(())
}
fn init_signals(conn: &Connection) -> Result<BTreeMap<u32, Signal>, Error> {
let mut signals = BTreeMap::new();
let mut stmt = conn.prepare(r#"
select
id,
source_uuid,
type_uuid,
short_name
from
signal
"#)?;
let mut rows = stmt.query(&[] as &[&ToSql])?;
while let Some(row) = rows.next()? {
let id = row.get(0)?;
let source: FromSqlUuid = row.get(1)?;
let type_: FromSqlUuid = row.get(2)?;
signals.insert(id, Signal {
id,
source: source.0,
type_: type_.0,
short_name: row.get(3)?,
cameras: Vec::new(),
});
}
Ok(signals)
}
fn init_points(conn: &Connection) -> Result<BTreeMap<u32, Point>, Error> {
let mut stmt = conn.prepare(r#"
select
time_sec,
changes
from
signal_state
order by time_sec
"#)?;
let mut rows = stmt.query(&[] as &[&ToSql])?;
let mut points = BTreeMap::new();
let mut cur = BTreeMap::new(); // latest signal -> state, where state != 0
while let Some(row) = rows.next()? {
let time_sec = row.get(0)?;
let changes = row.get_raw_checked(1)?.as_blob()?;
let mut it = PointDataIterator::new(changes);
while let Some((signal, state)) = it.next()? {
if state == 0 {
cur.remove(&signal);
} else {
cur.insert(signal, state);
}
}
points.insert(time_sec, Point::new(&cur, changes));
}
Ok(points)
}
/// Fills the `cameras` field of the `Signal` structs within the supplied `signals`.
fn fill_signal_cameras(conn: &Connection, signals: &mut BTreeMap<u32, Signal>)
-> Result<(), Error> {
let mut stmt = conn.prepare(r#"
select
signal_id,
camera_id,
type
from
signal_camera
order by signal_id, camera_id
"#)?;
let mut rows = stmt.query(&[] as &[&ToSql])?;
while let Some(row) = rows.next()? {
let signal_id = row.get(0)?;
let s = signals.get_mut(&signal_id)
.ok_or_else(|| format_err!("signal_camera row for unknown signal id {}",
signal_id))?;
let type_ = row.get(2)?;
s.cameras.push(SignalCamera {
camera_id: row.get(1)?,
type_: match type_ {
0 => SignalCameraType::Direct,
1 => SignalCameraType::Indirect,
_ => bail!("unknown signal_camera type {}", type_),
},
});
}
Ok(())
}
fn init_types(conn: &Connection) -> Result<FnvHashMap<Uuid, Type>, Error> {
let mut types = FnvHashMap::default();
let mut stmt = conn.prepare(r#"
select
type_uuid,
value,
name,
motion,
color
from
signal_type_enum
order by type_uuid, value
"#)?;
let mut rows = stmt.query(&[] as &[&ToSql])?;
while let Some(row) = rows.next()? {
let type_: FromSqlUuid = row.get(0)?;
types.entry(type_.0).or_insert_with(Type::default).states.push(TypeState {
value: row.get(1)?,
name: row.get(2)?,
motion: row.get(3)?,
color: row.get(4)?,
});
}
Ok(types)
}
pub fn signals_by_id(&self) -> &BTreeMap<u32, Signal> { &self.signals_by_id }
pub fn types_by_uuid(&self) -> &FnvHashMap<Uuid, Type> { & self.types_by_uuid }
}
/// Representation of a `signal` row.
#[derive(Debug)]
pub struct Signal {
pub id: u32,
pub source: Uuid,
pub type_: Uuid,
pub short_name: String,
/// The cameras this signal is associated with. Sorted by camera id, which is unique.
pub cameras: Vec<SignalCamera>,
}
/// Representation of a `signal_type_enum` row.
/// `type_uuid` is implied by the `Type` which owns this struct.
#[derive(Debug)]
pub struct TypeState {
pub value: u16,
pub name: String,
pub motion: bool,
pub color: String,
}
/// Representation of a signal type; currently this just gathers together the TypeStates.
#[derive(Debug, Default)]
pub struct Type {
/// The possible states associated with this type. They are sorted by value, which is unique.
pub states: Vec<TypeState>,
}
#[cfg(test)]
mod tests {
#[test]
fn test_point_data_it() {
// Example taken from the .sql file.
let data = b"\x01\x01\x02\x01\x00\x00\xc5\x01\x02";
let mut it = super::PointDataIterator::new(data);
assert_eq!(it.next().unwrap(), Some((1, 1)));
assert_eq!(it.next().unwrap(), Some((3, 1)));
assert_eq!(it.next().unwrap(), Some((3, 0)));
assert_eq!(it.next().unwrap(), Some((200, 2)));
assert_eq!(it.next().unwrap(), None);
}
}

View File

@@ -40,6 +40,7 @@ use rusqlite::types::ToSql;
mod v0_to_v1;
mod v1_to_v2;
mod v2_to_v3;
mod v3_to_v4;
const UPGRADE_NOTES: &'static str =
concat!("upgraded using moonfire-db ", env!("CARGO_PKG_VERSION"));
@@ -64,6 +65,7 @@ pub fn run(args: &Args, conn: &mut rusqlite::Connection) -> Result<(), Error> {
v0_to_v1::run,
v1_to_v2::run,
v2_to_v3::run,
v3_to_v4::run,
];
{

67
db/upgrade/v3_to_v4.rs Normal file
View File

@@ -0,0 +1,67 @@
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2018 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/>.
/// Upgrades a version 3 schema to a version 4 schema.
use failure::Error;
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
// These create statements match the schema.sql when version 4 was the latest.
tx.execute_batch(r#"
create table signal (
id integer primary key,
source_uuid blob not null check (length(source_uuid) = 16),
type_uuid blob not null check (length(type_uuid) = 16),
short_name not null,
unique (source_uuid, type_uuid)
);
create table signal_type_enum (
type_uuid blob not null check (length(type_uuid) = 16),
value integer not null check (value > 0 and value < 16),
name text not null,
motion int not null check (motion in (0, 1)) default 0,
color text
);
create table signal_camera (
signal_id integer references signal (id),
camera_id integer references camera (id),
type integer not null,
primary key (signal_id, camera_id)
) without rowid;
create table signal_state (
time_sec integer primary key,
changes blob
);
"#)?;
Ok(())
}