Consolidate active-scan notifications and scrub private data

Active scanners (ARP, SNMP) now accumulate every change across a whole
scan and emit one notification per type via events::notify: a single
device produces the usual single-device notification (carrying its MAC),
while two or more produce one consolidated summary with an empty
mac_address. Device events are still recorded per device.

Notification bodies no longer include MAC or IP addresses; the title
MAC fallback is masked to the last two octets. Summaries list up to
three devices then "…and N more devices".

Split sighting handling so record_sighting persists + records the event
and returns Vec<DeviceChange>; passive listeners (mDNS, SSDP, DHCP) use
record_and_notify since they see one device per event.

Also fixes NotificationType::from_str never mapping "DeviceChanged",
which made those notifications round-trip from the DB as Other.

Frontend: the card already hides the device link when mac_address is
null; added widget tests for the present/absent link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-06 11:31:13 -04:00
co-authored by Claude Opus 4.8
parent 6eaaff457d
commit 42310a36e9
10 changed files with 468 additions and 104 deletions
+391 -81
View File
@@ -24,6 +24,28 @@ struct DeliveryRequest {
body: String, body: String,
} }
/// A notification-worthy change detected for one device during a sighting. The device event is
/// recorded when this is produced (see `classify_*`); sending the notification is deferred so
/// callers can either notify immediately (passive listeners, one device per event) or accumulate a
/// whole scan and send one consolidated notification per type (active scanners).
pub enum DeviceChange {
New(Device),
BackOnline {
device: Device,
absent_for: Duration,
},
Changed {
existing: Device,
new: Device,
ip_changed: bool,
vendor_changed: bool,
},
}
// At most this many devices are listed individually in a consolidated summary body; any beyond are
// rolled into an "…and N more devices" line so the notification stays short.
const SUMMARY_LIST_LIMIT: usize = 3;
// Bounded so a stuck delivery loop cannot grow memory without limit; on overflow we drop and warn // Bounded so a stuck delivery loop cannot grow memory without limit; on overflow we drop and warn
// (delivery is best-effort, matching the "never stop the loop" policy in the scanner pipeline). // (delivery is best-effort, matching the "never stop the loop" policy in the scanner pipeline).
const DELIVERY_QUEUE_CAPACITY: usize = 100; const DELIVERY_QUEUE_CAPACITY: usize = 100;
@@ -103,7 +125,7 @@ fn display_name(device: &Device) -> &str {
// Identifier used in notification titles, so a Pushover preview is triageable // Identifier used in notification titles, so a Pushover preview is triageable
// without opening the notification. Prefers the hostname, then the vendor, then a // without opening the notification. Prefers the hostname, then the vendor, then a
// short MAC suffix as a last resort. // masked MAC suffix (last two octets only) as a last resort, so no full MAC is ever exposed.
fn title_identity(device: &Device) -> String { fn title_identity(device: &Device) -> String {
if let Some(name) = device.name.as_deref().filter(|name| !name.is_empty()) { if let Some(name) = device.name.as_deref().filter(|name| !name.is_empty()) {
return name.to_string(); return name.to_string();
@@ -112,12 +134,12 @@ fn title_identity(device: &Device) -> String {
return device.vendor.clone(); return device.vendor.clone();
} }
let mac = &device.mac_address; let mac = &device.mac_address;
let suffix = if mac.len() > 8 { let suffix = if mac.len() > 5 {
&mac[mac.len() - 8..] &mac[mac.len() - 5..]
} else { } else {
mac mac
}; };
format!("MAC ...{suffix}") format!("device …{suffix}")
} }
fn device_type_or_unknown(device: &Device) -> &str { fn device_type_or_unknown(device: &Device) -> &str {
@@ -159,8 +181,6 @@ fn render_new_device(device: &Device) -> (String, String) {
writeln!(body).unwrap(); writeln!(body).unwrap();
writeln!(body, "Device").unwrap(); writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(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, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap(); writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body).unwrap(); writeln!(body).unwrap();
@@ -191,8 +211,6 @@ fn render_device_back_online(device: &Device, duration_text: &str) -> (String, S
writeln!(body).unwrap(); writeln!(body).unwrap();
writeln!(body, "Device").unwrap(); writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(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, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap(); writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body).unwrap(); writeln!(body).unwrap();
@@ -223,7 +241,6 @@ fn render_device_changed(
writeln!(body).unwrap(); writeln!(body).unwrap();
writeln!(body, "Device").unwrap(); writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(new)).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, " Type: {}", device_type_or_unknown(new)).unwrap();
writeln!(body).unwrap(); writeln!(body).unwrap();
writeln!(body, "Status").unwrap(); writeln!(body, "Status").unwrap();
@@ -231,12 +248,8 @@ fn render_device_changed(
writeln!(body).unwrap(); writeln!(body).unwrap();
writeln!(body, "Changes").unwrap(); writeln!(body, "Changes").unwrap();
if ip_changed { if ip_changed {
writeln!( // The address values are private and deliberately omitted; the change itself is reported.
body, writeln!(body, " IP address changed").unwrap();
" IP address: {} -> {}",
existing.ipv4_address, new.ipv4_address
)
.unwrap();
} }
if vendor_changed_flag { if vendor_changed_flag {
writeln!(body, " Vendor: {} -> {}", existing.vendor, new.vendor).unwrap(); writeln!(body, " Vendor: {} -> {}", existing.vendor, new.vendor).unwrap();
@@ -253,6 +266,104 @@ fn render_device_changed(
(title, body) (title, body)
} }
// Render the duration a device was absent for display (whole seconds, e.g. "12d").
fn duration_text(absent_for: Duration) -> String {
String::from(DurationString::from(Duration::from_secs(
absent_for.as_secs(),
)))
}
// One line describing a device in a consolidated summary: its name, plus vendor and type when
// known. No MAC or IP address is included.
fn summary_device_line(device: &Device) -> String {
let mut line = display_name(device).to_string();
let mut details = Vec::new();
if !device.vendor.is_empty() {
details.push(device.vendor.clone());
}
if !device.device_type.is_empty() {
details.push(device.device_type.clone());
}
if !details.is_empty() {
write!(line, " ({})", details.join(", ")).unwrap();
}
line
}
// Append the capped device list shared by every summary body: up to SUMMARY_LIST_LIMIT devices,
// then an "…and N more devices" line when there are more.
fn write_device_summary(body: &mut String, devices: &[&Device]) {
for device in devices.iter().take(SUMMARY_LIST_LIMIT) {
writeln!(body, " - {}", summary_device_line(device)).unwrap();
}
if devices.len() > SUMMARY_LIST_LIMIT {
writeln!(
body,
" …and {} more devices",
devices.len() - SUMMARY_LIST_LIMIT
)
.unwrap();
}
}
fn render_new_devices_summary(devices: &[&Device]) -> (String, String) {
let title = format!("{} new devices found on your network", devices.len());
let mut body = String::new();
writeln!(
body,
"{} devices that have not been seen before joined your network.",
devices.len()
)
.unwrap();
writeln!(body).unwrap();
writeln!(body, "Devices").unwrap();
write_device_summary(&mut body, devices);
writeln!(body).unwrap();
write!(
body,
"If you do not recognise these devices, consider investigating before \
granting them continued access."
)
.unwrap();
(title, body)
}
fn render_back_online_summary(devices: &[&Device]) -> (String, String) {
let title = format!("{} devices back online", devices.len());
let mut body = String::new();
writeln!(
body,
"{} known devices returned to your network after being absent.",
devices.len()
)
.unwrap();
writeln!(body).unwrap();
writeln!(body, "Devices").unwrap();
write_device_summary(&mut body, devices);
(title, body)
}
fn render_changed_summary(devices: &[&Device]) -> (String, String) {
let title = format!("{} devices changed on your network", devices.len());
let mut body = String::new();
writeln!(
body,
"{} existing devices changed their network details (IP address and/or vendor).",
devices.len()
)
.unwrap();
writeln!(body).unwrap();
writeln!(body, "Devices").unwrap();
write_device_summary(&mut body, devices);
writeln!(body).unwrap();
write!(
body,
"A vendor change on the same device is unusual and may indicate MAC spoofing."
)
.unwrap();
(title, body)
}
// Private helper function to record a notification and hand it off for delivery. Delivery happens // Private helper function to record a notification and hand it off for delivery. Delivery happens
// on a separate task (see `run_delivery`), so this returns as soon as the notification is persisted // on a separate task (see `run_delivery`), so this returns as soon as the notification is persisted
// and never blocks the caller on the (potentially slow) Pushover HTTP call. // and never blocks the caller on the (potentially slow) Pushover HTTP call.
@@ -296,10 +407,9 @@ fn record_event(event: DeviceEvent) {
} }
} }
pub fn trigger_new_device( /// Record the device event for a brand-new device and return the change to notify about. Sending
device: Device, /// is deferred to `notify` so an active scan can consolidate many new devices into one notification.
scanner: DeviceEventScanner, pub fn classify_new_device(device: Device, scanner: DeviceEventScanner) -> DeviceChange {
) -> Result<(), Box<dyn Error>> {
record_event(DeviceEvent::new( record_event(DeviceEvent::new(
device.mac_address.clone(), device.mac_address.clone(),
Utc::now(), Utc::now(),
@@ -309,25 +419,16 @@ pub fn trigger_new_device(
scanner, scanner,
)); ));
let (title, body) = render_new_device(&device); DeviceChange::New(device)
let notification = Notification::new(
Utc::now(),
NotificationType::NewDeviceFound,
title,
body,
true,
Some(device.mac_address.clone()),
);
send_notification(notification)?;
Ok(())
} }
pub fn trigger_existing_device( /// Record the device-seen event for a known device and return any notification-worthy changes (it
/// may return both a "back online" and a "changed" entry, or none). Sending is deferred to `notify`.
pub fn classify_existing_device(
existing_device: Device, existing_device: Device,
new_device: Device, new_device: Device,
scanner: DeviceEventScanner, scanner: DeviceEventScanner,
) -> Result<(), Box<dyn Error>> { ) -> Vec<DeviceChange> {
record_event(DeviceEvent::new( record_event(DeviceEvent::new(
new_device.mac_address.clone(), new_device.mac_address.clone(),
Utc::now(), Utc::now(),
@@ -337,54 +438,144 @@ pub fn trigger_existing_device(
scanner, scanner,
)); ));
// Notify if the device comes back online after not being seen for the configured period let mut changes = Vec::new();
// The device came back online after not being seen for the configured period.
let elapsed_since_last_seen: Duration = (Local::now().to_utc() - existing_device.last_seen) let elapsed_since_last_seen: Duration = (Local::now().to_utc() - existing_device.last_seen)
.to_std() .to_std()
.unwrap_or(Duration::from_secs(0)); .unwrap_or(Duration::from_secs(0));
if elapsed_since_last_seen if elapsed_since_last_seen
>= Duration::from(get_settings().notifications.notify_when_not_seen_for) >= Duration::from(get_settings().notifications.notify_when_not_seen_for)
{ {
let duration_text = String::from(DurationString::from(Duration::from_secs( changes.push(DeviceChange::BackOnline {
elapsed_since_last_seen.as_secs(), device: new_device.clone(),
))); absent_for: elapsed_since_last_seen,
let (title, body) = render_device_back_online(&new_device, &duration_text); });
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceOnlineAfterTime,
title,
body,
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
} }
// Notify if the devices vendor and/or IP changed // The device's vendor and/or IP changed.
let ip_changed = existing_device.ipv4_address != new_device.ipv4_address; let ip_changed = existing_device.ipv4_address != new_device.ipv4_address;
let vendor_changed_flag = 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_flag { if ip_changed || vendor_changed_flag {
let (title, body) = render_device_changed( changes.push(DeviceChange::Changed {
&existing_device, existing: existing_device,
&new_device, new: new_device,
ip_changed, ip_changed,
vendor_changed_flag, vendor_changed: vendor_changed_flag,
); });
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
title,
body,
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
} }
Ok(()) changes
}
/// Send notifications for the changes detected during a scan (or a single sighting). Changes are
/// grouped by notification type: a type with exactly one change produces the usual single-device
/// notification (carrying its MAC), while a type with two or more produces one consolidated summary
/// with an empty MAC. Recording happens here; delivery is handed to the delivery task.
pub fn notify(changes: Vec<DeviceChange>) {
let mut new_devices = Vec::new();
let mut back_online = Vec::new();
let mut changed = Vec::new();
for change in changes {
match change {
DeviceChange::New(device) => new_devices.push(device),
DeviceChange::BackOnline { device, absent_for } => {
back_online.push((device, absent_for))
}
DeviceChange::Changed {
existing,
new,
ip_changed,
vendor_changed,
} => changed.push((existing, new, ip_changed, vendor_changed)),
}
}
notify_new_devices(new_devices);
notify_back_online(back_online);
notify_changed(changed);
}
fn notify_new_devices(devices: Vec<Device>) {
match devices.as_slice() {
[] => {}
[device] => {
let (title, body) = render_new_device(device);
send(
NotificationType::NewDeviceFound,
title,
body,
Some(device.mac_address.clone()),
);
}
many => {
let refs: Vec<&Device> = many.iter().collect();
let (title, body) = render_new_devices_summary(&refs);
send(NotificationType::NewDeviceFound, title, body, None);
}
}
}
fn notify_back_online(devices: Vec<(Device, Duration)>) {
match devices.as_slice() {
[] => {}
[(device, absent_for)] => {
let (title, body) = render_device_back_online(device, &duration_text(*absent_for));
send(
NotificationType::DeviceOnlineAfterTime,
title,
body,
Some(device.mac_address.clone()),
);
}
many => {
let refs: Vec<&Device> = many.iter().map(|(device, _)| device).collect();
let (title, body) = render_back_online_summary(&refs);
send(NotificationType::DeviceOnlineAfterTime, title, body, None);
}
}
}
fn notify_changed(devices: Vec<(Device, Device, bool, bool)>) {
match devices.as_slice() {
[] => {}
[(existing, new, ip_changed, vendor_changed)] => {
let (title, body) = render_device_changed(existing, new, *ip_changed, *vendor_changed);
send(
NotificationType::DeviceChanged,
title,
body,
Some(new.mac_address.clone()),
);
}
many => {
let refs: Vec<&Device> = many.iter().map(|(_, new, _, _)| new).collect();
let (title, body) = render_changed_summary(&refs);
send(NotificationType::DeviceChanged, title, body, None);
}
}
}
// Build and record a notification, logging (rather than propagating) a persistence failure: a scan
// loop must keep running even if a single notification cannot be stored.
fn send(
notification_type: NotificationType,
title: String,
body: String,
mac_address: Option<String>,
) {
let notification = Notification::new(
Utc::now(),
notification_type,
title,
body,
true,
mac_address,
);
if let Err(err) = send_notification(notification) {
error!("Failed to record notification: {err}");
}
} }
#[cfg(test)] #[cfg(test)]
@@ -425,15 +616,17 @@ mod tests {
} }
#[test] #[test]
fn title_identity_prefers_name_then_vendor_then_mac_suffix() { fn title_identity_prefers_name_then_vendor_then_masked_mac_suffix() {
let mut device = sample_device(Some("bobs-iphone.local")); let mut device = sample_device(Some("bobs-iphone.local"));
assert_eq!(title_identity(&device), "bobs-iphone.local"); assert_eq!(title_identity(&device), "bobs-iphone.local");
device.name = None; device.name = None;
assert_eq!(title_identity(&device), "Apple, Inc."); assert_eq!(title_identity(&device), "Apple, Inc.");
// No name and no vendor: fall back to a masked suffix (last two octets only), never the
// full MAC.
device.vendor = "".to_string(); device.vendor = "".to_string();
assert_eq!(title_identity(&device), "MAC ...dd:ee:ff"); assert_eq!(title_identity(&device), "device …ee:ff");
} }
#[test] #[test]
@@ -458,18 +651,19 @@ mod tests {
} }
#[test] #[test]
fn new_device_body_includes_all_fields_and_security_hint() { fn new_device_body_includes_fields_and_security_hint_without_private_data() {
let device = sample_device(Some("printer.local")); let device = sample_device(Some("printer.local"));
let (title, body) = render_new_device(&device); let (title, body) = render_new_device(&device);
assert_eq!(title, "New device on your network: printer.local"); assert_eq!(title, "New device on your network: printer.local");
assert!(body.contains("Name: 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("Vendor: Apple, Inc."));
assert!(body.contains("Type: Smartphone")); assert!(body.contains("Type: Smartphone"));
assert!(body.contains("Not registered")); assert!(body.contains("Not registered"));
assert!(body.contains("If you do not recognise this device")); assert!(body.contains("If you do not recognise this device"));
// Private data must never appear in the body.
assert!(!body.contains("aa:bb:cc:dd:ee:ff"));
assert!(!body.contains("192.168.1.42"));
} }
#[test] #[test]
@@ -501,10 +695,13 @@ mod tests {
let (title, body) = render_device_changed(&existing, &new, true, false); let (title, body) = render_device_changed(&existing, &new, true, false);
assert_eq!(title, "Device changed IP: bobs-iphone.local"); 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("IP address changed"));
assert!(!body.contains("Vendor:")); assert!(!body.contains("Vendor:"));
assert!(!body.contains("MAC spoofing")); assert!(!body.contains("MAC spoofing"));
assert!(body.contains("MAC address: aa:bb:cc:dd:ee:ff (unchanged)")); // The changed IP values and the MAC are private and must not appear.
assert!(!body.contains("192.168.1.42"));
assert!(!body.contains("192.168.1.99"));
assert!(!body.contains("aa:bb:cc:dd:ee:ff"));
} }
#[test] #[test]
@@ -531,9 +728,11 @@ mod tests {
let (title, body) = render_device_changed(&existing, &new, true, true); let (title, body) = render_device_changed(&existing, &new, true, true);
assert_eq!(title, "Device changed IP and vendor: bobs-iphone.local"); 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("IP address changed"));
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics")); assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
assert!(body.contains("MAC spoofing")); assert!(body.contains("MAC spoofing"));
assert!(!body.contains("192.168.1.42"));
assert!(!body.contains("192.168.1.99"));
} }
#[tokio::test] #[tokio::test]
@@ -550,8 +749,8 @@ mod tests {
let mac = device.mac_address.clone(); let mac = device.mac_address.clone();
// Two sightings from the same scanner within the dedup window: only one event recorded. // Two sightings from the same scanner within the dedup window: only one event recorded.
trigger_existing_device(device.clone(), device.clone(), DeviceEventScanner::Arp).unwrap(); classify_existing_device(device.clone(), device.clone(), DeviceEventScanner::Arp);
trigger_existing_device(device.clone(), device.clone(), DeviceEventScanner::Arp).unwrap(); classify_existing_device(device.clone(), device.clone(), DeviceEventScanner::Arp);
let after_arp = db::device_events::list(Some(mac.clone()), None, None, None).unwrap(); let after_arp = db::device_events::list(Some(mac.clone()), None, None, None).unwrap();
assert_eq!( assert_eq!(
@@ -561,7 +760,7 @@ mod tests {
); );
// A sighting from a different scanner is not a duplicate and is recorded. // A sighting from a different scanner is not a duplicate and is recorded.
trigger_existing_device(device.clone(), device.clone(), DeviceEventScanner::Mdns).unwrap(); classify_existing_device(device.clone(), device.clone(), DeviceEventScanner::Mdns);
let after_mdns = db::device_events::list(Some(mac), None, None, None).unwrap(); let after_mdns = db::device_events::list(Some(mac), None, None, None).unwrap();
assert_eq!( assert_eq!(
@@ -572,7 +771,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn triggering_a_new_device_records_a_notification() { async fn notifying_one_new_device_persists_a_single_device_notification() {
crate::tests_common::setup().await; crate::tests_common::setup().await;
let mac = "fa:ce:fa:ce:00:02".to_string(); let mac = "fa:ce:fa:ce:00:02".to_string();
@@ -581,14 +780,125 @@ mod tests {
// The delivery loop is not running in tests, so delivery is a no-op; the notification must // The delivery loop is not running in tests, so delivery is a no-op; the notification must
// still be persisted regardless of whether it is ever delivered. // still be persisted regardless of whether it is ever delivered.
trigger_new_device(device, DeviceEventScanner::Arp).unwrap(); notify(vec![classify_new_device(device, DeviceEventScanner::Arp)]);
let notifications = db::notifications::list(None, None, None).unwrap(); let notifications = db::notifications::list(None, None, None).unwrap();
assert!( assert!(
notifications notifications
.iter() .iter()
.any(|n| n.mac_address.as_deref() == Some(mac.as_str())), .any(|n| n.mac_address.as_deref() == Some(mac.as_str())),
"A new-device sighting should persist a notification" "A single new-device sighting should persist a notification carrying its MAC"
); );
} }
fn new_device_change(mac: &str, name: &str) -> DeviceChange {
let mut device = sample_device(Some(name));
device.mac_address = mac.to_string();
DeviceChange::New(device)
}
// The test DB is shared across tests, so assertions scope to unique device names rather than
// global notification counts.
#[tokio::test]
async fn notifying_multiple_new_devices_consolidates_into_one_summary() {
crate::tests_common::setup().await;
notify(vec![
new_device_change("fa:ce:fa:ce:01:01", "consolidate-printer"),
new_device_change("fa:ce:fa:ce:01:02", "consolidate-laptop"),
]);
let summaries: Vec<_> = db::notifications::list(None, None, None)
.unwrap()
.into_iter()
.filter(|n| {
n.notification_type == NotificationType::NewDeviceFound
&& n.body.contains("consolidate-printer")
})
.collect();
assert_eq!(
summaries.len(),
1,
"Two new devices in one scan should produce a single consolidated notification"
);
let summary = &summaries[0];
assert!(
summary.mac_address.is_none(),
"A multi-device notification must have an empty MAC address"
);
assert!(summary.title.contains('2'));
assert!(summary.body.contains("consolidate-laptop"));
// No private data, even in the consolidated body.
assert!(!summary.body.contains("fa:ce:fa:ce:01:01"));
assert!(!summary.body.contains("fa:ce:fa:ce:01:02"));
}
#[tokio::test]
async fn notify_groups_changes_by_type() {
crate::tests_common::setup().await;
let mut existing = sample_device(Some("group-server"));
existing.mac_address = "fa:ce:fa:ce:02:09".to_string();
let mut changed = existing.clone();
changed.ipv4_address = "192.168.1.250".to_string();
// Two new devices (consolidated) plus one changed device (single) in the same scan.
notify(vec![
new_device_change("fa:ce:fa:ce:02:01", "group-printer"),
new_device_change("fa:ce:fa:ce:02:02", "group-laptop"),
DeviceChange::Changed {
existing,
new: changed,
ip_changed: true,
vendor_changed: false,
},
]);
let notifications = db::notifications::list(None, None, None).unwrap();
let new_summaries = notifications
.iter()
.filter(|n| {
n.notification_type == NotificationType::NewDeviceFound
&& n.body.contains("group-printer")
})
.count();
let changed_notifications: Vec<_> = notifications
.iter()
.filter(|n| {
n.notification_type == NotificationType::DeviceChanged
&& n.mac_address.as_deref() == Some("fa:ce:fa:ce:02:09")
})
.collect();
assert_eq!(
new_summaries, 1,
"Both new devices collapse into one notification"
);
assert_eq!(changed_notifications.len(), 1);
assert!(
changed_notifications[0].mac_address.is_some(),
"A single changed device keeps its MAC address"
);
}
#[test]
fn summary_lists_at_most_three_devices_then_counts_the_rest() {
let devices: Vec<Device> = (0..5)
.map(|i| {
let mut device = sample_device(Some(&format!("device-{i}")));
device.mac_address = format!("aa:bb:cc:00:00:0{i}");
device
})
.collect();
let refs: Vec<&Device> = devices.iter().collect();
let (_, body) = render_new_devices_summary(&refs);
assert!(body.contains("device-0"));
assert!(body.contains("device-1"));
assert!(body.contains("device-2"));
assert!(!body.contains("device-3"));
assert!(body.contains("…and 2 more devices"));
}
} }
+1
View File
@@ -106,6 +106,7 @@ impl FromStr for NotificationType {
match s { match s {
"NewDeviceFound" => Ok(NotificationType::NewDeviceFound), "NewDeviceFound" => Ok(NotificationType::NewDeviceFound),
"DeviceOnlineAfterTime" => Ok(NotificationType::DeviceOnlineAfterTime), "DeviceOnlineAfterTime" => Ok(NotificationType::DeviceOnlineAfterTime),
"DeviceChanged" => Ok(NotificationType::DeviceChanged),
_ => Ok(NotificationType::Other), _ => Ok(NotificationType::Other),
} }
} }
+9 -2
View File
@@ -1,5 +1,6 @@
use super::finder; use super::finder;
use super::status; use super::status;
use crate::events;
use crate::model::device_events::DeviceEventScanner; use crate::model::device_events::DeviceEventScanner;
use crate::scanners::common::pipeline; use crate::scanners::common::pipeline;
use crate::settings::get_settings; use crate::settings::get_settings;
@@ -23,11 +24,17 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
info!("Found {} online devices", devices.len()); info!("Found {} online devices", devices.len());
status::STATUS.record_scan(&devices); status::STATUS.record_scan(&devices);
// Process found devices // Process found devices, accumulating every change so the whole scan emits one
// consolidated notification per type (see events::notify) rather than one per device.
let mut changes = Vec::new();
for device in devices.iter() { for device in devices.iter() {
debug!("Online device found {}", device); debug!("Online device found {}", device);
pipeline::record_sighting(device.clone(), DeviceEventScanner::Arp); changes.extend(pipeline::record_sighting(
device.clone(),
DeviceEventScanner::Arp,
));
} }
events::notify(changes);
let wait = Duration::from(get_settings().arp_scanner.wait_between_scans); let wait = Duration::from(get_settings().arp_scanner.wait_between_scans);
let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap(); let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap();
+19 -12
View File
@@ -1,21 +1,23 @@
use log::{debug, error}; use log::{debug, error};
use crate::db; use crate::db;
use crate::events; use crate::events::{self, DeviceChange};
use crate::model::device_events::DeviceEventScanner; use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device; use crate::model::devices::Device;
/// Persist a device sighting and emit the matching event/notification, feeding every scanner /// Persist a device sighting and record its device event, then return any notification-worthy
/// (ARP, mDNS, SSDP, DHCP, SNMP) through one code path. /// changes it produced (without sending). This feeds every scanner (ARP, mDNS, SSDP, DHCP, SNMP)
/// through one code path. Active scanners accumulate the changes across a whole scan and pass them
/// to `events::notify` once, consolidating per type; passive listeners use `record_and_notify`.
/// ///
/// When the device is already known, the sighting is reconciled with the stored record: /// When the device is already known, the sighting is reconciled with the stored record:
/// - a previously stored hostname is kept rather than overwritten by this sighting's name; /// - a previously stored hostname is kept rather than overwritten by this sighting's name;
/// - a known IP address is kept when this sighting carries none (an empty string), so a DHCP /// - a known IP address is kept when this sighting carries none (an empty string), so a DHCP
/// DISCOVER (which has no assigned IP) never clobbers a good address. /// DISCOVER (which has no assigned IP) never clobbers a good address.
/// ///
/// Errors are logged and swallowed: a scan/listen loop must never stop because a single sighting /// Errors are logged and swallowed (an empty list is returned): a scan/listen loop must never stop
/// failed to persist or a notification could not be delivered. /// because a single sighting failed to persist.
pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) { pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) -> Vec<DeviceChange> {
match db::devices::read(device.mac_address.clone()) { match db::devices::read(device.mac_address.clone()) {
Some(recorded) => { Some(recorded) => {
debug!("Sighting of known device {}; updating", device.mac_address); debug!("Sighting of known device {}; updating", device.mac_address);
@@ -33,23 +35,28 @@ pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) {
device.name.clone(), device.name.clone(),
) { ) {
error!("Failed to update device {}: {err}", device.mac_address); error!("Failed to update device {}: {err}", device.mac_address);
return; return Vec::new();
} }
// Ignoring errors: do not stop the loop if notification delivery fails. events::classify_existing_device(recorded, device, scanner)
events::trigger_existing_device(recorded, device, scanner).ok();
} }
None => { None => {
debug!("New device {} discovered; inserting", device.mac_address); debug!("New device {} discovered; inserting", device.mac_address);
if let Err(err) = db::devices::insert(device.clone()) { if let Err(err) = db::devices::insert(device.clone()) {
error!("Failed to insert device {}: {err}", device.mac_address); error!("Failed to insert device {}: {err}", device.mac_address);
return; return Vec::new();
} }
// Ignoring errors: do not stop the loop if notification delivery fails. vec![events::classify_new_device(device, scanner)]
events::trigger_new_device(device, scanner).ok();
} }
} }
} }
/// Record a single sighting and immediately send any resulting notification. Used by the passive
/// listeners (mDNS, SSDP, DHCP), which process one device per event and so have nothing to
/// consolidate across a scan.
pub fn record_and_notify(device: Device, scanner: DeviceEventScanner) {
events::notify(record_sighting(device, scanner));
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -1
View File
@@ -53,6 +53,6 @@ fn process_discovery(discovery: finder::DhcpDiscovery) {
.unwrap_or_default(); .unwrap_or_default();
let device = build_device(mac.clone(), ipv4, &[], discovery.hostname); let device = build_device(mac.clone(), ipv4, &[], discovery.hostname);
pipeline::record_sighting(device, DeviceEventScanner::Dhcp); pipeline::record_and_notify(device, DeviceEventScanner::Dhcp);
status::STATUS.record_discovery(&mac); status::STATUS.record_discovery(&mac);
} }
+1 -1
View File
@@ -82,6 +82,6 @@ async fn process_announcement(
Some(hostname), Some(hostname),
); );
pipeline::record_sighting(device, DeviceEventScanner::Mdns); pipeline::record_and_notify(device, DeviceEventScanner::Mdns);
status::STATUS.record_discovery(&mac); status::STATUS.record_discovery(&mac);
} }
+9 -1
View File
@@ -1,5 +1,6 @@
use super::finder; use super::finder;
use super::status; use super::status;
use crate::events;
use crate::model::device_events::DeviceEventScanner; use crate::model::device_events::DeviceEventScanner;
use crate::scanners::common::pipeline; use crate::scanners::common::pipeline;
use crate::settings::get_settings; use crate::settings::get_settings;
@@ -33,9 +34,16 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
Ok(devices) => { Ok(devices) => {
info!("SNMP poll found {} devices in the ARP cache", devices.len()); info!("SNMP poll found {} devices in the ARP cache", devices.len());
status::STATUS.record_scan(&devices); status::STATUS.record_scan(&devices);
// Accumulate every change so the whole poll emits one consolidated notification
// per type (see events::notify) rather than one per device.
let mut changes = Vec::new();
for device in devices.iter() { for device in devices.iter() {
pipeline::record_sighting(device.clone(), DeviceEventScanner::Snmp); changes.extend(pipeline::record_sighting(
device.clone(),
DeviceEventScanner::Snmp,
));
} }
events::notify(changes);
} }
Err(err) => error!("SNMP poll of {} failed: {err}", config.target), Err(err) => error!("SNMP poll of {} failed: {err}", config.target),
} }
+4 -1
View File
@@ -193,7 +193,10 @@ mod tests {
USN: uuid:abcd::upnp:rootdevice\r\n\r\n"; USN: uuid:abcd::upnp:rootdevice\r\n\r\n";
let announcement = parse_announcement(body.as_bytes()).expect("alive packet"); let announcement = parse_announcement(body.as_bytes()).expect("alive packet");
assert!(announcement.server.is_none()); assert!(announcement.server.is_none());
assert_eq!(announcement.device_types, vec!["upnp:rootdevice".to_string()]); assert_eq!(
announcement.device_types,
vec!["upnp:rootdevice".to_string()]
);
} }
#[test] #[test]
+1 -1
View File
@@ -76,6 +76,6 @@ async fn process_announcement(
// mirrors the mDNS scanner so build_device can still deduce a vendor for privacy MACs. // mirrors the mDNS scanner so build_device can still deduce a vendor for privacy MACs.
let device = build_device(mac.clone(), src_ip.to_string(), &device_types, server_hint); let device = build_device(mac.clone(), src_ip.to_string(), &device_types, server_hint);
pipeline::record_sighting(device, DeviceEventScanner::Ssdp); pipeline::record_and_notify(device, DeviceEventScanner::Ssdp);
status::STATUS.record_discovery(&mac); status::STATUS.record_discovery(&mac);
} }
@@ -5,22 +5,27 @@ import 'package:frontend/model/notification.dart' as oott_model;
import 'package:frontend/model/notification_type.dart'; import 'package:frontend/model/notification_type.dart';
import 'package:frontend/utils/friendly_date_formatter.dart'; import 'package:frontend/utils/friendly_date_formatter.dart';
oott_model.Notification _sampleNotification() => oott_model.Notification( oott_model.Notification _sampleNotification({
String? macAddress = 'AA:BB:CC:DD:EE:FF',
}) => oott_model.Notification(
id: 1, id: 1,
title: 'New device found', title: 'New device found',
body: 'A long body that wraps across multiple lines when expanded.', body: 'A long body that wraps across multiple lines when expanded.',
notificationType: NotificationType.newDeviceFound, notificationType: NotificationType.newDeviceFound,
createdOn: DateTime(2026, 6, 4, 12), createdOn: DateTime(2026, 6, 4, 12),
isNew: true, isNew: true,
macAddress: 'AA:BB:CC:DD:EE:FF', macAddress: macAddress,
); );
Future<void> _pumpCard(WidgetTester tester) async { Future<void> _pumpCard(
WidgetTester tester, {
String? macAddress = 'AA:BB:CC:DD:EE:FF',
}) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: NotificationCard( body: NotificationCard(
item: _sampleNotification(), item: _sampleNotification(macAddress: macAddress),
formatter: FriendlyDateFormatter(), formatter: FriendlyDateFormatter(),
onSetRead: (_) async => true, onSetRead: (_) async => true,
), ),
@@ -67,4 +72,27 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Mark as read'), findsNothing); expect(find.text('Mark as read'), findsNothing);
}); });
testWidgets('shows a device link when the notification has a MAC address', (
tester,
) async {
await _pumpCard(tester);
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(find.text('View device'), findsOneWidget);
});
testWidgets(
'omits the device link for a multi-device notification (no MAC address)',
(tester) async {
await _pumpCard(tester, macAddress: null);
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(find.text('View device'), findsNothing);
},
);
} }