Make [notifications.pushover] optional for non-pushover methods

The pushover config section is now only required when notifications.method
is "pushover". Validation at startup rejects the missing-section case so a
misconfiguration fails fast instead of erroring on every notification.

Updates the sample TOML, README and nix module for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 17:45:02 -04:00
co-authored by Claude Opus 4.8
parent 6c4f8c22bb
commit be2a8fce58
6 changed files with 123 additions and 29 deletions
+3 -2
View File
@@ -47,6 +47,7 @@ api_key = "CHANGE_ME" # API key the app uses to talk to the backend — change t
[notifications]
method = "pushover" # Use "none" to only log notifications instead of sending them
# Only needed when method = "pushover"; omit this section for any other method.
[notifications.pushover]
token = "" # Your Pushover application token
user_key = "" # Your Pushover user key
@@ -179,8 +180,8 @@ Options marked **Required** have no built-in default and must be set in your con
|`snmp_scanner.timeout`|`5s`|Per-poll SNMP request timeout.|
|`notifications.method`|**Required**|For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)|
|`notifications.notify_when_not_seen_for`|`1w`|Send a notification if a device comes back online after not being seen for this timeframe (you can use hours, weeks, etc.)|
|`notifications.pushover.token`|**Required**|Your pushover token goes here, just copy&paste from their website after creating the app (may be left empty when `method` is `none`)|
|`notifications.pushover.user_key`|**Required**|User key goes here, this is the account wide code for pushover (may be left empty when `method` is `none`)|
|`notifications.pushover.token`|**Required when `method` is `pushover`**|Your pushover token goes here, just copy&paste from their website after creating the app. The whole `[notifications.pushover]` section may be omitted when `method` is anything other than `pushover`|
|`notifications.pushover.user_key`|**Required when `method` is `pushover`**|User key goes here, this is the account wide code for pushover. The whole `[notifications.pushover]` section may be omitted when `method` is anything other than `pushover`|
|`retention.window`|`365d`|How long to retain device events and notifications. Records older than this are purged daily. Accepts duration strings (e.g. `90d`, `1y`, `6m`).|
|`device_events.deduplication_window`|`1m`|Suppress duplicate device events: if the same scanner sees the same device (same MAC and IPv4) again within this window, only one event is recorded. Accepts duration strings (e.g. `30s`, `1m`, `5m`).|
+18 -9
View File
@@ -185,9 +185,17 @@ fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
debug!("About to send notification ({notification}).");
match get_settings().notifications.method.as_str() {
"pushover" => {
pushover::send_message(notification.title, notification.body)?;
}
"pushover" => match &get_settings().notifications.pushover {
Some(pushover) => {
pushover::send_message(pushover, notification.title, notification.body)?;
}
None => {
error!(
"Notification method is 'pushover' but no [notifications.pushover] section is \
configured; cannot deliver notification."
);
}
},
other => {
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
info!("Notification: {}", notification.body);
@@ -294,8 +302,12 @@ pub fn trigger_existing_device(
let vendor_changed_flag = vendor_changed(&existing_device.vendor, &new_device.vendor);
if ip_changed || vendor_changed_flag {
let (title, body) =
render_device_changed(&existing_device, &new_device, ip_changed, vendor_changed_flag);
let (title, body) = render_device_changed(
&existing_device,
&new_device,
ip_changed,
vendor_changed_flag,
);
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
@@ -411,10 +423,7 @@ mod tests {
let (title, body) = render_device_back_online(&device, "12d");
assert_eq!(
title,
"Device back online after 12d: bobs-iphone.local"
);
assert_eq!(title, "Device back online after 12d: bobs-iphone.local");
assert!(body.contains("Registered to Bob"));
assert!(body.contains("Absent for: 12d"));
}
+3 -7
View File
@@ -3,17 +3,13 @@ use pushover::API;
use pushover::requests::message::SendMessage;
use crate::events::error::DeliveryError;
use crate::settings::get_settings;
use crate::settings::Pushover;
pub fn send_message(title: String, body: String) -> Result<(), DeliveryError> {
pub fn send_message(config: &Pushover, title: String, body: String) -> Result<(), DeliveryError> {
debug!("About to send message via pushover ({body})");
let api = API::new();
let mut msg = SendMessage::new(
get_settings().notifications.pushover.token.as_str(),
get_settings().notifications.pushover.user_key.as_str(),
body,
);
let mut msg = SendMessage::new(config.token.as_str(), config.user_key.as_str(), body);
msg.set_title(title);
+80 -2
View File
@@ -180,7 +180,8 @@ pub struct Pushover {
#[derive(Debug, Deserialize, Clone)]
pub struct Notifications {
pub method: String,
pub pushover: Pushover,
// Only required when `method` is "pushover"; other methods leave this section out.
pub pushover: Option<Pushover>,
#[serde(default = "default_notify_when_not_seen_for")]
pub notify_when_not_seen_for: DurationString,
}
@@ -255,7 +256,22 @@ impl Settings {
.add_source(File::with_name(config_path.as_str()))
.build()?;
local_settings.try_deserialize()
let settings: Settings = local_settings.try_deserialize()?;
settings.validate()?;
Ok(settings)
}
// Cross-field checks that serde can't express on its own. Run once at load time so a
// misconfiguration fails fast at startup rather than on every notification attempt.
fn validate(&self) -> Result<(), ConfigError> {
if self.notifications.method == "pushover" && self.notifications.pushover.is_none() {
return Err(ConfigError::Message(
"notifications.method is \"pushover\" but the [notifications.pushover] section is \
missing; add it with your token and user_key, or change notifications.method."
.to_string(),
));
}
Ok(())
}
}
@@ -533,6 +549,68 @@ mod tests {
);
}
#[test]
fn validate_rejects_pushover_method_without_section() {
// method = "pushover" but no [notifications.pushover] section: must fail at load time.
const PUSHOVER_NO_SECTION: &str = r#"
[database]
path = "./oott.db"
[networking]
[log]
level = "info"
[notifications]
method = "pushover"
[web_server]
api_key = "test"
"#;
let settings = parse(PUSHOVER_NO_SECTION);
assert!(settings.validate().is_err());
}
#[test]
fn validate_accepts_non_pushover_method_without_section() {
let settings = parse(NO_PUSHOVER_CONFIG);
assert!(settings.validate().is_ok());
}
#[test]
fn validate_accepts_pushover_method_with_section() {
let settings = parse(BASE_CONFIG);
assert!(settings.validate().is_ok());
}
const NO_PUSHOVER_CONFIG: &str = r#"
[database]
path = "./oott.db"
[networking]
[log]
level = "info"
[notifications]
method = "none"
[web_server]
api_key = "test"
"#;
#[test]
fn pushover_section_is_optional_for_non_pushover_methods() {
// With a non-pushover method, the `[notifications.pushover]` section may be omitted
// entirely; it should deserialize to `None` rather than fail parsing.
let settings = parse(NO_PUSHOVER_CONFIG);
assert_eq!(settings.notifications.method, "none");
assert!(settings.notifications.pushover.is_none());
}
#[test]
fn pushover_section_is_parsed_when_present() {
let settings = parse(BASE_CONFIG);
let pushover = settings
.notifications
.pushover
.expect("pushover section should be parsed when present");
assert_eq!(pushover.token, "");
assert_eq!(pushover.user_key, "");
}
#[test]
fn networking_section_is_optional() {
// The `[networking]` section has no mandatory fields, so omitting it entirely
+2
View File
@@ -39,6 +39,8 @@ enabled=true # Set to false to disable the DHCP scanner
method="pushover" # For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)
notify_when_not_seen_for="1w" # Send a notification if a device comes back online after not being seen for this timeframe. Defaults to "1w" if omitted
# The [notifications.pushover] section is only required when method="pushover". For any other
# method (e.g. "none") you can omit this whole section.
[notifications.pushover]
token="" # Your pushover token goes here, just copy&paste from their website after creating the app
user_key="" # User key goes here, this is the account wide code for pushover
+17 -9
View File
@@ -111,15 +111,23 @@ in {
description = "Send a notification if a device comes back online after not being seen for this timeframe.";
default = "1w";
};
notifications.pushover.token = mkOption {
type = types.str;
description = "Your pushover token goes here, just copy&paste from their website after creating the app.";
default = "";
};
notifications.pushover.user_key = mkOption {
type = types.str;
description = "User key goes here, this is the account wide code for pushover.";
default = "";
notifications.pushover = mkOption {
type = types.nullOr (types.submodule {
options = {
token = mkOption {
type = types.str;
description = "Your pushover token goes here, just copy&paste from their website after creating the app.";
default = "";
};
user_key = mkOption {
type = types.str;
description = "User key goes here, this is the account wide code for pushover.";
default = "";
};
};
});
description = "Pushover credentials. Only required when notifications.method is \"pushover\"; leave it null (the default) for any other method.";
default = null;
};
retention.window = mkOption {
type = types.str;