Record originating scanner on device events

So that consumers of the events API can tell whether a sighting came
from the ARP scanner or the mDNS listener. Adds a DeviceEventScanner
enum (Arp / Mdns) plumbed from each scanner's call site through to a
new scanner column on device_events, exposed via the existing events
endpoint and OpenAPI schema. Pre-existing rows are backfilled with
'ARP' since that was the only scanner before mDNS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-01 08:09:34 -04:00
co-authored by Claude Opus 4.7
parent 0e43194492
commit d6cf9be412
8 changed files with 110 additions and 19 deletions
@@ -0,0 +1 @@
ALTER TABLE device_events ADD COLUMN scanner TEXT NOT NULL DEFAULT 'ARP' COLLATE NOCASE;
+24 -5
View File
@@ -12,13 +12,14 @@ pub fn insert(event: DeviceEvent) -> Result<i64, DbError> {
let mac_address = normalize_mac(&event.mac_address);
match conn.execute(
"INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor) VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor, scanner) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
mac_address,
event.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false),
event.event_type,
event.ipv4_address,
event.vendor
event.vendor,
event.scanner
],
) {
Ok(_) => {
@@ -42,7 +43,7 @@ pub fn list(
let conn = db::get_db_connection();
let mut sql_statement =
"SELECT id, mac_address, created_on, event_type, ipv4_address, vendor FROM device_events WHERE 1=1"
"SELECT id, mac_address, created_on, event_type, ipv4_address, vendor, scanner FROM device_events WHERE 1=1"
.to_string();
let mut params: Vec<rusqlite::types::Value> = Vec::new();
@@ -83,6 +84,7 @@ pub fn list(
event_type: row.get(3)?,
ipv4_address: row.get(4)?,
vendor: row.get(5)?,
scanner: row.get(6)?,
})
})?
.collect::<Result<_, _>>()?;
@@ -113,7 +115,7 @@ fn read(id: i64) -> Option<DeviceEvent> {
let conn = db::get_db_connection();
let result: Result<DeviceEvent, rusqlite::Error> = conn.query_one(
"SELECT id, mac_address, created_on, event_type, ipv4_address, vendor FROM device_events WHERE id=?1",
"SELECT id, mac_address, created_on, event_type, ipv4_address, vendor, scanner FROM device_events WHERE id=?1",
params![id],
|row| {
Ok(DeviceEvent {
@@ -123,6 +125,7 @@ fn read(id: i64) -> Option<DeviceEvent> {
event_type: row.get(3)?,
ipv4_address: row.get(4)?,
vendor: row.get(5)?,
scanner: row.get(6)?,
})
},
);
@@ -146,7 +149,10 @@ mod tests {
use chrono::Utc;
use super::*;
use crate::{model::device_events::DeviceEventType, tests_common};
use crate::{
model::device_events::{DeviceEventScanner, DeviceEventType},
tests_common,
};
#[tokio::test]
async fn test_insert() {
@@ -159,6 +165,7 @@ mod tests {
DeviceEventType::NewDevice,
"192.168.0.1".to_string(),
"Vendor 1".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
@@ -173,6 +180,7 @@ mod tests {
assert_eq!(event.event_type, DeviceEventType::NewDevice);
assert_eq!(event.ipv4_address, "192.168.0.1");
assert_eq!(event.vendor, "Vendor 1");
assert_eq!(event.scanner, DeviceEventScanner::Arp);
let inserted_id = insert(DeviceEvent::new(
"bb:bb:bb:bb:bb:bb".to_string(),
@@ -180,11 +188,13 @@ mod tests {
DeviceEventType::DeviceSeen,
"192.168.0.2".to_string(),
"Vendor 2".to_string(),
DeviceEventScanner::Mdns,
))
.unwrap();
let event = read(inserted_id).unwrap();
assert_eq!(event.event_type, DeviceEventType::DeviceSeen);
assert_eq!(event.scanner, DeviceEventScanner::Mdns);
}
#[tokio::test]
@@ -243,6 +253,7 @@ mod tests {
DeviceEventType::NewDevice,
"10.0.0.200".to_string(),
"Old Vendor".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
@@ -252,6 +263,7 @@ mod tests {
DeviceEventType::DeviceSeen,
"10.0.0.200".to_string(),
"Old Vendor".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
@@ -285,6 +297,7 @@ mod tests {
DeviceEventType::NewDevice,
"10.0.0.1".to_string(),
"Vendor C".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
insert(DeviceEvent::new(
@@ -295,6 +308,7 @@ mod tests {
DeviceEventType::DeviceSeen,
"10.0.0.1".to_string(),
"Vendor C".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
insert(DeviceEvent::new(
@@ -305,6 +319,7 @@ mod tests {
DeviceEventType::DeviceSeen,
"10.0.0.1".to_string(),
"Vendor C".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
@@ -337,6 +352,7 @@ mod tests {
DeviceEventType::NewDevice,
"10.0.0.1".to_string(),
"Vendor F".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
insert(DeviceEvent::new(
@@ -347,6 +363,7 @@ mod tests {
DeviceEventType::DeviceSeen,
"10.0.0.1".to_string(),
"Vendor F".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
@@ -379,6 +396,7 @@ mod tests {
DeviceEventType::NewDevice,
"10.0.0.1".to_string(),
"Vendor D".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
insert(DeviceEvent::new(
@@ -387,6 +405,7 @@ mod tests {
DeviceEventType::DeviceSeen,
"10.0.0.1".to_string(),
"Vendor D".to_string(),
DeviceEventScanner::Arp,
))
.unwrap();
+8 -2
View File
@@ -5,7 +5,7 @@ use std::error::Error;
use std::time::Duration;
use crate::db;
use crate::model::device_events::{DeviceEvent, DeviceEventType};
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
use crate::model::devices::Device;
use crate::model::notifications::Notification;
use crate::model::notifications::NotificationType;
@@ -52,13 +52,17 @@ fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
Ok(())
}
pub fn trigger_new_device(device: Device) -> Result<(), Box<dyn Error>> {
pub fn trigger_new_device(
device: Device,
scanner: DeviceEventScanner,
) -> Result<(), Box<dyn Error>> {
let event = DeviceEvent::new(
device.mac_address.clone(),
Utc::now(),
DeviceEventType::NewDevice,
device.ipv4_address.clone(),
device.vendor.clone(),
scanner,
);
if let Err(err) = db::device_events::insert(event) {
error!(
@@ -89,6 +93,7 @@ pub fn trigger_new_device(device: Device) -> Result<(), Box<dyn Error>> {
pub fn trigger_existing_device(
existing_device: Device,
new_device: Device,
scanner: DeviceEventScanner,
) -> Result<(), Box<dyn Error>> {
let event = DeviceEvent::new(
new_device.mac_address.clone(),
@@ -96,6 +101,7 @@ pub fn trigger_existing_device(
DeviceEventType::DeviceSeen,
new_device.ipv4_address.clone(),
new_device.vendor.clone(),
scanner,
);
if let Err(err) = db::device_events::insert(event) {
error!(
+58 -1
View File
@@ -15,6 +15,7 @@ pub struct DeviceEvent {
pub event_type: DeviceEventType,
pub ipv4_address: String,
pub vendor: String,
pub scanner: DeviceEventScanner,
}
impl DeviceEvent {
@@ -24,6 +25,7 @@ impl DeviceEvent {
event_type: DeviceEventType,
ipv4_address: String,
vendor: String,
scanner: DeviceEventScanner,
) -> Self {
Self {
id: -1,
@@ -32,6 +34,7 @@ impl DeviceEvent {
event_type,
ipv4_address,
vendor,
scanner,
}
}
}
@@ -40,13 +43,14 @@ impl fmt::Display for DeviceEvent {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"id={}, mac_address={}, created_on={}, event_type={}, ipv4_address={}, vendor={}",
"id={}, mac_address={}, created_on={}, event_type={}, ipv4_address={}, vendor={}, scanner={}",
self.id,
self.mac_address,
self.created_on,
self.event_type,
self.ipv4_address,
self.vendor,
self.scanner,
)
}
}
@@ -109,3 +113,56 @@ impl FromSql for DeviceEventType {
.map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub enum DeviceEventScanner {
Arp,
Mdns,
}
impl fmt::Display for DeviceEventScanner {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Arp => write!(f, "ARP"),
Self::Mdns => write!(f, "mDNS"),
}
}
}
#[derive(Debug)]
pub struct DeviceEventScannerParseError;
impl fmt::Display for DeviceEventScannerParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Error parsing device event scanner")
}
}
impl Error for DeviceEventScannerParseError {}
impl FromStr for DeviceEventScanner {
type Err = DeviceEventScannerParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"ARP" => Ok(DeviceEventScanner::Arp),
"mDNS" => Ok(DeviceEventScanner::Mdns),
_ => Err(DeviceEventScannerParseError),
}
}
}
impl ToSql for DeviceEventScanner {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.to_string().into())
}
}
impl FromSql for DeviceEventScanner {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
value
.as_str()?
.parse()
.map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
+8 -2
View File
@@ -2,6 +2,7 @@ use super::finder;
use super::status;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::settings::get_settings;
use chrono::Utc;
use log::{debug, info};
@@ -38,7 +39,12 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
device.device_type.clone(),
device.name.clone(),
)?;
events::trigger_existing_device(recorded_device, device.clone()).ok(); // Ignoring errors here, do not stop loop if notification delivery fails
events::trigger_existing_device(
recorded_device,
device.clone(),
DeviceEventScanner::Arp,
)
.ok(); // Ignoring errors here, do not stop loop if notification delivery fails
}
None => {
// If it doesn't exist insert it
@@ -48,7 +54,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
);
db::devices::insert(device.clone())?;
events::trigger_new_device(device.clone()).ok(); // Ignoring errors here, do not stop loop if notification delivery fails
events::trigger_new_device(device.clone(), DeviceEventScanner::Arp).ok(); // Ignoring errors here, do not stop loop if notification delivery fails
}
};
}
+3 -2
View File
@@ -10,6 +10,7 @@ use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device;
use crate::settings::get_settings;
@@ -105,7 +106,7 @@ async fn process_announcement(
return;
}
// Ignoring errors: do not stop the listener if notification delivery fails
events::trigger_existing_device(recorded, device).ok();
events::trigger_existing_device(recorded, device, DeviceEventScanner::Mdns).ok();
}
None => {
debug!("New device {mac} discovered via mDNS; inserting");
@@ -113,7 +114,7 @@ async fn process_announcement(
error!("Failed to insert mDNS device {mac}: {err}");
return;
}
events::trigger_new_device(device).ok();
events::trigger_new_device(device, DeviceEventScanner::Mdns).ok();
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
use std::error::Error;
use crate::model::device_events::{DeviceEvent, DeviceEventType};
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings;
@@ -63,6 +63,7 @@ pub mod utils;
UpdateDevicePayload,
DeviceEvent,
DeviceEventType,
DeviceEventScanner,
ArpScannerStatusResponse,
MdnsScannerStatusResponse,
)),
@@ -1,6 +1,6 @@
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor)
VALUES (1, 'aa:aa:aa:aa:aa:aa', '2026-01-01T11:11:11+00:00', 'NewDevice', '192.168.0.1', 'Vendor 1');
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor)
VALUES (2, 'bb:bb:bb:bb:bb:bb', '2026-02-03T13:14:15+00:00', 'DeviceSeen', '192.168.0.2', 'Vendor 2');
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor)
VALUES (3, 'aa:aa:aa:aa:aa:aa', '2026-03-10T09:00:00+00:00', 'DeviceSeen', '192.168.0.1', 'Vendor 1');
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor, scanner)
VALUES (1, 'aa:aa:aa:aa:aa:aa', '2026-01-01T11:11:11+00:00', 'NewDevice', '192.168.0.1', 'Vendor 1', 'ARP');
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor, scanner)
VALUES (2, 'bb:bb:bb:bb:bb:bb', '2026-02-03T13:14:15+00:00', 'DeviceSeen', '192.168.0.2', 'Vendor 2', 'mDNS');
INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor, scanner)
VALUES (3, 'aa:aa:aa:aa:aa:aa', '2026-03-10T09:00:00+00:00', 'DeviceSeen', '192.168.0.1', 'Vendor 1', 'ARP');