Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 208 additions & 20 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ struct StateResponse {
node: NodeStatus,
/// Timestamp of the most recent alarm (millis since epoch), or 0 if none.
last_alarm_ts: u64,
/// Rules-engine tuning knobs (Gateway-Edge Hybrid Rules Engine, Sections A/D).
motion_sensitivity: f32,
human_auto_disarm_minutes: u32,
rain_suppression_enabled: bool,
/// 0 unless something has written it (nothing does yet — Section E, human
/// detection, is an open research item; this field is read-ready ahead of it).
auto_disarm_until: u64,
}

/// Control command body for `POST /api/control`.
Expand Down Expand Up @@ -857,6 +864,105 @@ fn write_flags(armed: bool, override_stop: bool, gid: &str) -> Result<(), Error>
Ok(())
}

/// Pure parsing for the rules-engine settings — each arg is the raw KV lookup
/// result (`Option<String>`), already fetched by the caller. Unreadable/absent
/// values fall back to the spec's defaults. Testable without a KVStore.
fn parse_settings_kv(
motion_sensitivity: Option<String>,
human_auto_disarm_minutes: Option<String>,
rain_suppression_enabled: Option<String>,
auto_disarm_until: Option<String>,
) -> (f32, u32, bool, u64) {
let motion_sensitivity = motion_sensitivity
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| (0.0..=1.0).contains(v))
.unwrap_or(0.3);
let human_auto_disarm_minutes = human_auto_disarm_minutes
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(10);
let rain_suppression_enabled = parse_flag(rain_suppression_enabled, true);
let auto_disarm_until = auto_disarm_until
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
(
motion_sensitivity,
human_auto_disarm_minutes,
rain_suppression_enabled,
auto_disarm_until,
)
}

/// Reads the rules-engine settings for garden `gid` from `garden_state`, defaulting
/// to the spec's safe values when the store or any individual key is missing.
fn read_settings(gid: &str) -> (f32, u32, bool, u64) {
match KVStore::open(STATE_STORE) {
Ok(Some(store)) => parse_settings_kv(
store
.lookup_str(&key(gid, "settings/motion_sensitivity"))
.ok()
.flatten(),
store
.lookup_str(&key(gid, "settings/human_auto_disarm_minutes"))
.ok()
.flatten(),
store
.lookup_str(&key(gid, "settings/rain_suppression_enabled"))
.ok()
.flatten(),
store
.lookup_str(&key(gid, "state/auto_disarm_until"))
.ok()
.flatten(),
),
_ => parse_settings_kv(None, None, None, None),
}
}

/// `POST /api/settings` body.
#[derive(Deserialize)]
struct SettingsRequest {
motion_sensitivity: f32,
human_auto_disarm_minutes: u32,
rain_suppression_enabled: bool,
}

/// `motion_sensitivity` must be a valid fraction (spec: 0.0-1.0). No bounds are
/// enforced on `human_auto_disarm_minutes`/`rain_suppression_enabled` — any
/// value that parses to the right type is acceptable.
fn validate_settings_body(v: &SettingsRequest) -> bool {
(0.0..=1.0).contains(&v.motion_sensitivity)
}

/// Persists the 3 admin-configurable settings for garden `gid`. `auto_disarm_until`
/// is edge-internal state (set only by the human-detection path, Section E), not
/// admin-writable, so it has no write path here.
fn write_settings(
gid: &str,
motion_sensitivity: f32,
human_auto_disarm_minutes: u32,
rain_suppression_enabled: bool,
) -> Result<(), Error> {
let mut store = KVStore::open(STATE_STORE)?
.ok_or_else(|| Error::msg("KV Store 'garden_state' not found"))?;
store.insert(
&key(gid, "settings/motion_sensitivity"),
motion_sensitivity.to_string(),
)?;
store.insert(
&key(gid, "settings/human_auto_disarm_minutes"),
human_auto_disarm_minutes.to_string(),
)?;
store.insert(
&key(gid, "settings/rain_suppression_enabled"),
if rain_suppression_enabled {
"true"
} else {
"false"
},
)?;
Ok(())
}

/// Per-event smart-Stop state (RFC: three-mode + per-event abort). Two per-garden
/// STRING fields in `garden_state`, keyed via the SAME `key` helper as the flags:
/// * `abort_cid` — the cid of the spray the user aborted via Stop (one-shot;
Expand Down Expand Up @@ -1069,26 +1175,33 @@ fn garden_summary(store: &KVStore, gid: &str) -> (serde_json::Value, serde_json:
/// resolves to the legacy flat keys, so single-tenant behavior is unchanged.
fn build_state(gid: &str) -> StateResponse {
let cfg = config_flags();
let (armed, override_stop, latest_event, telemetry, last_alarm_ts) = match KVStore::open(STATE_STORE) {
Ok(Some(store)) => {
let (a, o) = flags_from(&store, cfg, gid);
let (ev, tel) = garden_summary(&store, gid);
let last_ts = store
.lookup_str(&key(gid, "last_alarm_ts"))
.ok()
.flatten()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
(a, o, ev, tel, last_ts)
}
_ => (
cfg.0,
cfg.1,
serde_json::Value::Null,
serde_json::Value::Null,
0,
),
};
let (armed, override_stop, latest_event, telemetry, last_alarm_ts) =
match KVStore::open(STATE_STORE) {
Ok(Some(store)) => {
let (a, o) = flags_from(&store, cfg, gid);
let (ev, tel) = garden_summary(&store, gid);
let last_ts = store
.lookup_str(&key(gid, "last_alarm_ts"))
.ok()
.flatten()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
(a, o, ev, tel, last_ts)
}
_ => (
cfg.0,
cfg.1,
serde_json::Value::Null,
serde_json::Value::Null,
0,
),
};
let (
motion_sensitivity,
human_auto_disarm_minutes,
rain_suppression_enabled,
auto_disarm_until,
) = read_settings(gid);
StateResponse {
mode: derive_mode(armed, override_stop).as_str(),
armed,
Expand All @@ -1097,6 +1210,10 @@ fn build_state(gid: &str) -> StateResponse {
latest_event,
node: build_node_status(telemetry, now_ms()),
last_alarm_ts,
motion_sensitivity,
human_auto_disarm_minutes,
rain_suppression_enabled,
auto_disarm_until,
}
}

Expand Down Expand Up @@ -1442,6 +1559,7 @@ fn main(req: Request) -> Result<Response, Error> {
(&Method::POST, "/api/archive/prune") => handle_archive_prune(req, &trace_id),
(&Method::POST, "/api/archive/wipe") => handle_archive_wipe(req, &trace_id),
(&Method::POST, "/api/control") => handle_control(req, &trace_id),
(&Method::POST, "/api/settings") => routes::handle_settings(req, &trace_id),
// Alarms: the list + per-species recommendations (GET /api/alarms), one alarm for an
// event key (GET /api/alarm), and tagging/management. Tagging a NEW alarm is viewer-
// gated; CHANGING a tag, DELETING an alarm, and the retention prune/wipe require a valid
Expand Down Expand Up @@ -4023,4 +4141,74 @@ mod tests {
let m2 = node_down_message("backyard", "n2", Some("last seen 200s ago"));
assert!(m2.ends_with("last seen 200s ago"));
}

#[test]
fn test_parse_settings_kv_defaults_when_absent() {
assert_eq!(
parse_settings_kv(None, None, None, None),
(0.3, 10, true, 0)
);
}

#[test]
fn test_parse_settings_kv_parses_valid_values() {
assert_eq!(
parse_settings_kv(
Some("0.6".to_string()),
Some("15".to_string()),
Some("false".to_string()),
Some("123456".to_string()),
),
(0.6, 15, false, 123456)
);
}

#[test]
fn test_parse_settings_kv_out_of_range_sensitivity_falls_back_to_default() {
let (sensitivity, _, _, _) = parse_settings_kv(Some("1.5".to_string()), None, None, None);
assert_eq!(sensitivity, 0.3);
let (sensitivity, _, _, _) = parse_settings_kv(Some("-0.2".to_string()), None, None, None);
assert_eq!(sensitivity, 0.3);
}

#[test]
fn test_parse_settings_kv_garbage_strings_fall_back_to_defaults() {
assert_eq!(
parse_settings_kv(
Some("not-a-float".to_string()),
Some("not-a-u32".to_string()),
Some("maybe".to_string()),
Some("nope".to_string()),
),
(0.3, 10, true, 0)
);
}

#[test]
fn test_validate_settings_body_accepts_boundary_values() {
assert!(validate_settings_body(&SettingsRequest {
motion_sensitivity: 0.0,
human_auto_disarm_minutes: 10,
rain_suppression_enabled: true,
}));
assert!(validate_settings_body(&SettingsRequest {
motion_sensitivity: 1.0,
human_auto_disarm_minutes: 10,
rain_suppression_enabled: true,
}));
}

#[test]
fn test_validate_settings_body_rejects_out_of_range_sensitivity() {
assert!(!validate_settings_body(&SettingsRequest {
motion_sensitivity: 1.1,
human_auto_disarm_minutes: 10,
rain_suppression_enabled: true,
}));
assert!(!validate_settings_body(&SettingsRequest {
motion_sensitivity: -0.1,
human_auto_disarm_minutes: 10,
rain_suppression_enabled: true,
}));
}
}
72 changes: 70 additions & 2 deletions backend/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ use crate::{
event_header_html, help_header_html, history_header_html, is_ip_locked_out, latest_event_from,
latest_telemetry_from, load_alarm_log, log_evt, now_ms, now_secs, read_devices,
record_failed_attempt, reset_failed_attempts, resolve_safety_id, timelapse_header_html,
viewer_login_html, ALARMS_HTML, DASHBOARD_HTML, EVENT_HTML, FAVICON_ICO, GARDEN_AUTH_HEADER,
HELP_HTML, STATE_STORE, TIMELAPSE_HTML, VIEWER_COOKIE, VIEWER_SESSION_TTL_SECS,
viewer_login_html, SettingsRequest, ALARMS_HTML, DASHBOARD_HTML, EVENT_HTML, FAVICON_ICO,
GARDEN_AUTH_HEADER, HELP_HTML, STATE_STORE, TIMELAPSE_HTML, VIEWER_COOKIE,
VIEWER_SESSION_TTL_SECS,
};
use crate::{validate_settings_body, write_settings};
use fastly::http::StatusCode;
use fastly::kv_store::KVStore;
use fastly::{Error, Request, Response};
Expand Down Expand Up @@ -1381,6 +1383,72 @@ pub fn handle_alarm_delete(mut req: Request, trace_id: &str) -> Result<Response,
.with_body_json(&serde_json::json!({"ok": true, "deleted": deleted}))?)
}

/// `POST /api/settings` — save the rules-engine tuning knobs (motion sensitivity,
/// human auto-disarm window, rain suppression toggle). ADMIN-ONLY (valid
/// `X-Garden-Auth` token), same gate as the alarms admin CRUD (`alarm_is_admin`).
/// Body: `{"motion_sensitivity":0.3,"human_auto_disarm_minutes":10,"rain_suppression_enabled":true}`.
/// Returns the saved values echoed back, or 400/401/503 on failure.
pub fn handle_settings(mut req: Request, trace_id: &str) -> Result<Response, Error> {
let gid_owned = browser_garden_id();
let gid = resolve_safety_id(&gid_owned, trace_id, "settings", "garden_id");
if !alarm_is_admin(&req, gid) {
log_evt(
trace_id,
"settings",
"auth",
"warn",
&format!("garden={} unauthorized -> 401", gid),
);
return Ok(Response::from_status(StatusCode::UNAUTHORIZED)
.with_body("Unauthorized: saving settings requires a valid X-Garden-Auth token"));
}
let body = req.take_body().into_bytes();
let settings_req: SettingsRequest = match serde_json::from_slice(&body) {
Ok(s) => s,
Err(_) => {
return Ok(Response::from_status(StatusCode::BAD_REQUEST).with_body(
"Expected JSON {\"motion_sensitivity\":0.0-1.0,\"human_auto_disarm_minutes\":<u32>,\"rain_suppression_enabled\":<bool>}",
));
}
};
if !validate_settings_body(&settings_req) {
return Ok(Response::from_status(StatusCode::BAD_REQUEST)
.with_body("motion_sensitivity must be between 0.0 and 1.0"));
}
if write_settings(
gid,
settings_req.motion_sensitivity,
settings_req.human_auto_disarm_minutes,
settings_req.rain_suppression_enabled,
)
.is_err()
{
return Ok(Response::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_body("State store unavailable; settings not persisted"));
}
log_evt(
trace_id,
"settings",
"write",
"ok",
&format!(
"garden={} motion_sensitivity={} human_auto_disarm_minutes={} rain_suppression_enabled={}",
gid,
settings_req.motion_sensitivity,
settings_req.human_auto_disarm_minutes,
settings_req.rain_suppression_enabled
),
);
Ok(
Response::from_status(StatusCode::OK).with_body_json(&serde_json::json!({
"ok": true,
"motion_sensitivity": settings_req.motion_sensitivity,
"human_auto_disarm_minutes": settings_req.human_auto_disarm_minutes,
"rain_suppression_enabled": settings_req.rain_suppression_enabled,
}))?,
)
}

/// `POST /api/alarms/prune?mode=days&keep=N` (or `mode=count`) — retention sweep over the alarm
/// log. ADMIN-ONLY (valid token; the portal scheduler + "Clean up now" forward it). `days` drops
/// alarms older than `keep` days; `count` keeps the newest `keep`. One KV write. Returns
Expand Down
Loading