Redesign triggered notification titles and bodies

Make each Pushover alert triageable from a lock-screen preview: titles now
carry the device's identity (hostname, vendor, or MAC suffix) and a concise
verb, and bodies are sectioned (Device / Status / Activity or Changes) with
the registration line and device type so a user can decide whether to act
without opening the app. Adds a security hint on new-unregistered devices
and on vendor-change-on-same-MAC (a MAC-spoofing tell); IP-only changes
stay quiet since DHCP rotation is normal. Rendering moves into pure helpers
covered by unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-01 08:40:32 -04:00
co-authored by Claude Opus 4.7
parent 55a6b29d1c
commit 3122ab1f0f
2 changed files with 294 additions and 73 deletions
+289 -68
View File
@@ -2,6 +2,7 @@ mod error;
mod pushover;
use std::error::Error;
use std::fmt::Write;
use std::time::Duration;
use crate::db;
@@ -24,6 +25,45 @@ fn display_name(device: &Device) -> &str {
.unwrap_or("(unknown)")
}
// Identifier used in notification titles, so a Pushover preview is triageable
// without opening the notification. Prefers the hostname, then the vendor, then a
// short MAC suffix as a last resort.
fn title_identity(device: &Device) -> String {
if let Some(name) = device.name.as_deref().filter(|name| !name.is_empty()) {
return name.to_string();
}
if !device.vendor.is_empty() {
return device.vendor.clone();
}
let mac = &device.mac_address;
let suffix = if mac.len() > 8 {
&mac[mac.len() - 8..]
} else {
mac
};
format!("MAC ...{suffix}")
}
fn device_type_or_unknown(device: &Device) -> &str {
if device.device_type.is_empty() {
"Unknown"
} else {
&device.device_type
}
}
fn registration_line(device: &Device) -> String {
if device.is_registered {
if device.owner.is_empty() {
"Registered".to_string()
} else {
format!("Registered to {}", device.owner)
}
} else {
"Not registered".to_string()
}
}
// Whether a re-sighting represents a real vendor change. A scanner that cannot deduce a vendor
// reports an empty string; that is not a change (db::devices::seen keeps the known vendor), so
// it must not raise a "vendor changed" notification either. Likewise, first deducing a vendor for a
@@ -32,6 +72,111 @@ fn vendor_changed(existing: &str, new: &str) -> bool {
!existing.is_empty() && !new.is_empty() && existing != new
}
fn render_new_device(device: &Device) -> (String, String) {
let title = format!("New device on your network: {}", title_identity(device));
let mut body = String::new();
writeln!(
body,
"A device that has not been seen before joined your network."
)
.unwrap();
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(device)).unwrap();
writeln!(body, " MAC address: {}", device.mac_address).unwrap();
writeln!(body, " IP address: {}", device.ipv4_address).unwrap();
writeln!(body, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(device)).unwrap();
writeln!(body).unwrap();
write!(
body,
"If you do not recognise this device, consider investigating before \
granting it continued access."
)
.unwrap();
(title, body)
}
fn render_device_back_online(device: &Device, duration_text: &str) -> (String, String) {
let title = format!(
"Device back online after {}: {}",
duration_text,
title_identity(device)
);
let mut body = String::new();
writeln!(
body,
"A known device returned to your network after being absent."
)
.unwrap();
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(device)).unwrap();
writeln!(body, " MAC address: {}", device.mac_address).unwrap();
writeln!(body, " IP address: {}", device.ipv4_address).unwrap();
writeln!(body, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(device)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Activity").unwrap();
write!(body, " Absent for: {duration_text}").unwrap();
(title, body)
}
fn render_device_changed(
existing: &Device,
new: &Device,
ip_changed: bool,
vendor_changed_flag: bool,
) -> (String, String) {
let identity = title_identity(new);
let title = match (ip_changed, vendor_changed_flag) {
(true, true) => format!("Device changed IP and vendor: {identity}"),
(true, false) => format!("Device changed IP: {identity}"),
(false, true) => format!("Device changed vendor: {identity}"),
// Caller guards against calling with both flags false.
(false, false) => format!("Device changed: {identity}"),
};
let mut body = String::new();
writeln!(body, "An existing device's network details changed.").unwrap();
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(new)).unwrap();
writeln!(body, " MAC address: {} (unchanged)", new.mac_address).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(new)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(new)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Changes").unwrap();
if ip_changed {
writeln!(
body,
" IP address: {} -> {}",
existing.ipv4_address, new.ipv4_address
)
.unwrap();
}
if vendor_changed_flag {
writeln!(body, " Vendor: {} -> {}", existing.vendor, new.vendor).unwrap();
}
if vendor_changed_flag {
writeln!(body).unwrap();
write!(
body,
"A vendor change on the same MAC address is unusual and may indicate \
MAC spoofing."
)
.unwrap();
}
(title, body)
}
// Private helper function to deliver messages
fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
debug!("About to record notification in database");
@@ -71,17 +216,12 @@ pub fn trigger_new_device(
);
}
let (title, body) = render_new_device(&device);
let notification = Notification::new(
Utc::now(),
NotificationType::NewDeviceFound,
"New device found in your network".to_string(),
format!(
"A new device was found in your network:\n\nName: {}\nMAC address: {}\nIP address: {}\nVendor: {}",
display_name(&device),
device.mac_address,
device.ipv4_address,
device.vendor
),
title,
body,
true,
Some(device.mac_address.clone()),
);
@@ -118,20 +258,15 @@ pub fn trigger_existing_device(
if elapsed_since_last_seen
>= Duration::from(get_settings().notifications.notify_when_not_seen_for)
{
let duration_text = String::from(DurationString::from(Duration::from_secs(
elapsed_since_last_seen.as_secs(),
)));
let (title, body) = render_device_back_online(&new_device, &duration_text);
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceOnlineAfterTime,
"A device came back online after a while".to_string(),
format!(
"Device:\n\nName: {}\nMAC address: {}\nIP address: {}\nVendor: {}\n\ncame back online after {}.",
display_name(&new_device),
new_device.mac_address,
new_device.ipv4_address,
new_device.vendor,
String::from(DurationString::from(Duration::from_secs(
elapsed_since_last_seen.as_secs()
)))
),
title,
body,
true,
Some(new_device.mac_address.clone()),
);
@@ -141,64 +276,22 @@ pub fn trigger_existing_device(
// Notify if the devices vendor and/or IP changed
let ip_changed = existing_device.ipv4_address != new_device.ipv4_address;
let vendor_changed = vendor_changed(&existing_device.vendor, &new_device.vendor);
let vendor_changed_flag = vendor_changed(&existing_device.vendor, &new_device.vendor);
if ip_changed && vendor_changed {
if 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,
"Device changed vendor and IP address".to_string(),
format!(
"Device {} (MAC address {}) has changed:\nIP address from {} to {}\nVendor from {} to {}.",
display_name(&new_device),
existing_device.mac_address,
existing_device.ipv4_address,
new_device.ipv4_address,
existing_device.vendor,
new_device.vendor,
),
title,
body,
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
} else if ip_changed {
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
"Device changed IP address".to_string(),
format!(
"Device {} (MAC address {}, {}) has changed:\nIP address from {} to {}.",
display_name(&new_device),
existing_device.mac_address,
existing_device.vendor,
existing_device.ipv4_address,
new_device.ipv4_address,
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
} else if vendor_changed {
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
"Device changed vendor".to_string(),
format!(
"Device {} (MAC address {}, {}) has changed:\nVendor from {} to {}.",
display_name(&new_device),
existing_device.mac_address,
existing_device.ipv4_address,
existing_device.vendor,
new_device.vendor,
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
};
}
Ok(())
}
@@ -206,6 +299,19 @@ pub fn trigger_existing_device(
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample_device(name: Option<&str>) -> Device {
let mut device = Device::new(
"aa:bb:cc:dd:ee:ff".to_string(),
"192.168.1.42".to_string(),
"Apple, Inc.".to_string(),
Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap(),
);
device.name = name.map(str::to_string);
device.device_type = "Smartphone".to_string();
device
}
#[test]
fn empty_new_vendor_is_not_a_change() {
@@ -226,4 +332,119 @@ mod tests {
fn newly_deduced_vendor_from_empty_is_not_a_change() {
assert!(!vendor_changed("", "Apple, Inc."));
}
#[test]
fn title_identity_prefers_name_then_vendor_then_mac_suffix() {
let mut device = sample_device(Some("bobs-iphone.local"));
assert_eq!(title_identity(&device), "bobs-iphone.local");
device.name = None;
assert_eq!(title_identity(&device), "Apple, Inc.");
device.vendor = "".to_string();
assert_eq!(title_identity(&device), "MAC ...dd:ee:ff");
}
#[test]
fn registration_line_covers_all_cases() {
let mut device = sample_device(None);
assert_eq!(registration_line(&device), "Not registered");
device.is_registered = true;
assert_eq!(registration_line(&device), "Registered");
device.owner = "Alice".to_string();
assert_eq!(registration_line(&device), "Registered to Alice");
}
#[test]
fn device_type_falls_back_to_unknown_when_empty() {
let mut device = sample_device(None);
assert_eq!(device_type_or_unknown(&device), "Smartphone");
device.device_type = "".to_string();
assert_eq!(device_type_or_unknown(&device), "Unknown");
}
#[test]
fn new_device_body_includes_all_fields_and_security_hint() {
let device = sample_device(Some("printer.local"));
let (title, body) = render_new_device(&device);
assert_eq!(title, "New device on your network: printer.local");
assert!(body.contains("Name: printer.local"));
assert!(body.contains("MAC address: aa:bb:cc:dd:ee:ff"));
assert!(body.contains("IP address: 192.168.1.42"));
assert!(body.contains("Vendor: Apple, Inc."));
assert!(body.contains("Type: Smartphone"));
assert!(body.contains("Not registered"));
assert!(body.contains("If you do not recognise this device"));
}
#[test]
fn new_device_body_uses_unknown_for_missing_name() {
let device = sample_device(None);
let (_, body) = render_new_device(&device);
assert!(body.contains("Name: (unknown)"));
}
#[test]
fn device_back_online_body_includes_duration_and_registration() {
let mut device = sample_device(Some("bobs-iphone.local"));
device.is_registered = true;
device.owner = "Bob".to_string();
let (title, body) = render_device_back_online(&device, "12d");
assert_eq!(
title,
"Device back online after 12d: bobs-iphone.local"
);
assert!(body.contains("Registered to Bob"));
assert!(body.contains("Absent for: 12d"));
}
#[test]
fn device_changed_ip_only_shows_ip_row_without_spoofing_hint() {
let existing = sample_device(Some("bobs-iphone.local"));
let mut new = existing.clone();
new.ipv4_address = "192.168.1.99".to_string();
let (title, body) = render_device_changed(&existing, &new, true, false);
assert_eq!(title, "Device changed IP: bobs-iphone.local");
assert!(body.contains("IP address: 192.168.1.42 -> 192.168.1.99"));
assert!(!body.contains("Vendor:"));
assert!(!body.contains("MAC spoofing"));
assert!(body.contains("MAC address: aa:bb:cc:dd:ee:ff (unchanged)"));
}
#[test]
fn device_changed_vendor_only_includes_spoofing_hint() {
let existing = sample_device(Some("bobs-iphone.local"));
let mut new = existing.clone();
new.vendor = "Samsung Electronics".to_string();
let (title, body) = render_device_changed(&existing, &new, false, true);
assert_eq!(title, "Device changed vendor: bobs-iphone.local");
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
assert!(!body.contains("IP address: 192."));
assert!(body.contains("MAC spoofing"));
}
#[test]
fn device_changed_both_shows_both_rows_and_spoofing_hint() {
let existing = sample_device(Some("bobs-iphone.local"));
let mut new = existing.clone();
new.ipv4_address = "192.168.1.99".to_string();
new.vendor = "Samsung Electronics".to_string();
let (title, body) = render_device_changed(&existing, &new, true, true);
assert_eq!(title, "Device changed IP and vendor: bobs-iphone.local");
assert!(body.contains("IP address: 192.168.1.42 -> 192.168.1.99"));
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
assert!(body.contains("MAC spoofing"));
}
}