Split db::devices update into seen/update and expose PUT endpoint

Scanners now call seen() (sighting semantics: stamp last_seen, preserve
registration, conditional vendor/device_type/name writes). update() is
the user-editable mutation (owner, device_type, vendor) wired to
PUT /api/devices/{mac_address}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-30 09:27:44 -04:00
co-authored by Claude Opus 4.7
parent 792a309136
commit 54211c57d7
7 changed files with 254 additions and 92 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
- [x] One event for each device appearance - [x] One event for each device appearance
- [x] Add date filter to device event list method (from) - [x] Add date filter to device event list method (from)
- [x] Add a data set (json) to store maps from vendors -> device type; modify the device creation so that it uses it automatically - [x] Add a data set (json) to store maps from vendors -> device type; modify the device creation so that it uses it automatically
- [ ] Separate the devices "update" method into update and seen (one for API edit one for scanners) - [x] Separate the devices "update" method into update and seen (one for API edit one for scanners)
## Frontend ## Frontend
+7 -1
View File
@@ -32,7 +32,13 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
"Device found in database {}. Updating to {}.", "Device found in database {}. Updating to {}.",
recorded_device, device recorded_device, device
); );
db::devices::update(device.clone())?; db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
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()).ok(); // Ignoring errors here, do not stop loop if notification delivery fails
} }
None => { None => {
+173 -87
View File
@@ -183,16 +183,22 @@ pub fn get_summary() -> Result<DeviceSummary, DbError> {
}) })
} }
// Records a sighting of an existing device. Registration fields (is_registered, owner) are // Records a sighting of an existing device by a scanner. last_seen is stamped here with the
// owned by register/unregister and are intentionally never touched here. // current time. Registration fields (is_registered, owner) are owned by register/unregister
pub fn update(device: Device) -> Result<(), DbError> { // and are intentionally never touched here.
pub fn seen(
mac_address: String,
ipv4_address: String,
vendor: String,
device_type: String,
name: Option<String>,
) -> Result<(), DbError> {
let conn = db::get_db_connection(); let conn = db::get_db_connection();
let mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?".to_string(); let mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?".to_string();
let mut params: Vec<rusqlite::types::Value> = vec![ let mut params: Vec<rusqlite::types::Value> = vec![
device.ipv4_address.clone().into(), ipv4_address.into(),
device Utc::now()
.last_seen
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false) .to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
.into(), .into(),
]; ];
@@ -201,29 +207,54 @@ pub fn update(device: Device) -> Result<(), DbError> {
// could not determine a vendor (empty string) never clobbers a previously known one. // could not determine a vendor (empty string) never clobbers a previously known one.
// device_type is only written when the stored value is empty, so a value chosen by the // device_type is only written when the stored value is empty, so a value chosen by the
// user (via register) or previously deduced is never overwritten by a later sighting. // user (via register) or previously deduced is never overwritten by a later sighting.
if !device.vendor.is_empty() { if !vendor.is_empty() {
sql.push_str(", vendor=?, device_type=CASE WHEN device_type='' THEN ? ELSE device_type END"); sql.push_str(", vendor=?, device_type=CASE WHEN device_type='' THEN ? ELSE device_type END");
params.push(device.vendor.clone().into()); params.push(vendor.into());
params.push(device.device_type.clone().into()); params.push(device_type.into());
} }
// Only write the name column when it is set, so an ARP rescan (name: None) never // Only write the name column when it is set, so an ARP rescan (name: None) never
// clobbers a hostname previously stored by the mDNS scanner. // clobbers a hostname previously stored by the mDNS scanner.
if let Some(name) = &device.name { if let Some(name) = name {
sql.push_str(", name=?"); sql.push_str(", name=?");
params.push(name.clone().into()); params.push(name.into());
} }
sql.push_str(" WHERE mac_address=?"); sql.push_str(" WHERE mac_address=?");
params.push(device.mac_address.clone().into()); params.push(mac_address.clone().into());
match conn.execute(sql.as_str(), params_from_iter(params.iter())) { match conn.execute(sql.as_str(), params_from_iter(params.iter())) {
Ok(_) => { Ok(_) => {
debug!("Device updated in database: {}", device); debug!("Device sighting recorded in database: {mac_address}");
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
error!("Error updating device ({device}) in database: {error}"); error!("Error recording device sighting ({mac_address}) in database: {error}");
Err(DbError::from(error))
}
}
}
// Modifies user-editable fields of a device. Intended for the UI's "modify" feature; scanners
// must use seen() instead.
pub fn update(
mac_address: String,
owner: String,
device_type: String,
vendor: String,
) -> Result<(), DbError> {
let conn = db::get_db_connection();
match conn.execute(
"UPDATE devices SET owner=?1, device_type=?2, vendor=?3 WHERE mac_address=?4",
params![owner, device_type, vendor, mac_address],
) {
Ok(_) => {
debug!("Device updated in database: {mac_address}");
Ok(())
}
Err(error) => {
error!("Error updating device ({mac_address}) in database: {error}");
Err(DbError::from(error)) Err(DbError::from(error))
} }
} }
@@ -416,23 +447,22 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_update() { async fn test_seen() {
tests_common::setup().await; tests_common::setup().await;
// Insert a device, update it and validate it // Insert a device, record a sighting and validate it
let last_seen = Utc::now(); let initial_last_seen = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
insert(Device::new( insert(Device::new(
"uu:tt:tt:tt:tt:aa".to_string(), "uu:tt:tt:tt:tt:aa".to_string(),
"192.168.200.1".to_string(), "192.168.200.1".to_string(),
"Test vendor".to_string(), "Test vendor".to_string(),
last_seen, initial_last_seen,
)) ))
.unwrap(); .unwrap();
// Register the device, then update it with a Device carrying the scanner defaults // Register the device, then record a sighting. seen() must NOT clobber the
// (is_registered=false, owner=""). The update must NOT clobber the registration while // registration while still refreshing sighting fields (ipv4_address, last_seen).
// still refreshing sighting fields like ipv4_address and last_seen.
register( register(
"uu:tt:tt:tt:tt:aa".to_string(), "uu:tt:tt:tt:tt:aa".to_string(),
"Grace".to_string(), "Grace".to_string(),
@@ -440,56 +470,44 @@ mod tests {
) )
.unwrap(); .unwrap();
let new_last_seen = Utc::now(); let before = Utc::now();
let mut device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap(); seen(
device.is_registered = false;
device.owner = "".to_string();
device.ipv4_address = "192.168.200.50".to_string();
device.last_seen = new_last_seen;
update(device).unwrap();
let device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap();
validate_device(
device,
"uu:tt:tt:tt:tt:aa".to_string(), "uu:tt:tt:tt:tt:aa".to_string(),
"Phone".to_string(),
"192.168.200.50".to_string(), "192.168.200.50".to_string(),
true,
new_last_seen,
"Grace".to_string(),
"Test vendor".to_string(), "Test vendor".to_string(),
); "".to_string(),
None,
// Insert a device, change its MAC address and validate it hasnt changed (it cannot) )
let last_seen = Utc::now();
insert(Device::new(
"uu:tt:tt:tt:tt:bb".to_string(),
"192.168.200.3".to_string(),
"Test vendor".to_string(),
last_seen,
))
.unwrap(); .unwrap();
let mut device = read("uu:tt:tt:tt:tt:bb".to_string()).unwrap(); let device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap();
device.mac_address = "uu:tt:tt:tt:tt:cc".to_string(); assert_eq!(device.ipv4_address, "192.168.200.50".to_string());
assert!(
update(device).unwrap(); device.last_seen >= before,
"seen() should stamp last_seen with the current time"
);
assert!(device.is_registered);
assert_eq!(device.owner, "Grace".to_string());
assert_eq!(device.device_type, "Phone".to_string());
assert_eq!(device.vendor, "Test vendor".to_string());
// A seen() call for a MAC that doesn't exist is a no-op (no row matches)
seen(
"uu:tt:tt:tt:tt:cc".to_string(),
"192.168.200.99".to_string(),
"Test vendor".to_string(),
"".to_string(),
None,
)
.unwrap();
assert!( assert!(
read("uu:tt:tt:tt:tt:cc".to_string()).is_none(), read("uu:tt:tt:tt:tt:cc".to_string()).is_none(),
"Device should not exist" "seen() must not insert new devices"
); );
assert!(
read("uu:tt:tt:tt:tt:bb".to_string()).is_some(),
"Device should exist"
)
} }
#[tokio::test] #[tokio::test]
async fn test_update_name_set_and_preserve() { async fn test_seen_name_set_and_preserve() {
tests_common::setup().await; tests_common::setup().await;
let last_seen = Utc::now(); let last_seen = Utc::now();
@@ -505,27 +523,41 @@ mod tests {
assert_eq!(read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, None); assert_eq!(read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, None);
// mDNS sets the name // mDNS sets the name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); seen(
device.name = Some("host.local".to_string()); "nn:nn:nn:nn:nn:01".to_string(),
update(device).unwrap(); "192.168.210.1".to_string(),
"Test vendor".to_string(),
"".to_string(),
Some("host.local".to_string()),
)
.unwrap();
assert_eq!( assert_eq!(
read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name,
Some("host.local".to_string()) Some("host.local".to_string())
); );
// An ARP rescan (name: None) must NOT clobber the stored name // An ARP rescan (name: None) must NOT clobber the stored name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); seen(
device.name = None; "nn:nn:nn:nn:nn:01".to_string(),
device.ipv4_address = "192.168.210.99".to_string(); "192.168.210.99".to_string(),
update(device).unwrap(); "Test vendor".to_string(),
"".to_string(),
None,
)
.unwrap();
let device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); let device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap();
assert_eq!(device.name, Some("host.local".to_string())); assert_eq!(device.name, Some("host.local".to_string()));
assert_eq!(device.ipv4_address, "192.168.210.99".to_string()); assert_eq!(device.ipv4_address, "192.168.210.99".to_string());
// A later mDNS sighting updates the name // A later mDNS sighting updates the name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); seen(
device.name = Some("renamed.local".to_string()); "nn:nn:nn:nn:nn:01".to_string(),
update(device).unwrap(); "192.168.210.99".to_string(),
"Test vendor".to_string(),
"".to_string(),
Some("renamed.local".to_string()),
)
.unwrap();
assert_eq!( assert_eq!(
read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name,
Some("renamed.local".to_string()) Some("renamed.local".to_string())
@@ -533,7 +565,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_update_vendor_set_and_preserve() { async fn test_seen_vendor_set_and_preserve() {
tests_common::setup().await; tests_common::setup().await;
let last_seen = Utc::now(); let last_seen = Utc::now();
@@ -545,17 +577,25 @@ mod tests {
)) ))
.unwrap(); .unwrap();
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap(); seen(
device.device_type = "phone".to_string(); "vv:vv:vv:vv:vv:01".to_string(),
update(device).unwrap(); "192.168.220.1".to_string(),
"Apple, Inc.".to_string(),
"phone".to_string(),
None,
)
.unwrap();
// A re-sighting that could not deduce a vendor (empty) must NOT clobber the known one, // A re-sighting that could not deduce a vendor (empty) must NOT clobber the known one,
// nor its derived device_type, but other fields still update. // nor its derived device_type, but other fields still update.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap(); seen(
device.vendor = "".to_string(); "vv:vv:vv:vv:vv:01".to_string(),
device.device_type = "".to_string(); "192.168.220.99".to_string(),
device.ipv4_address = "192.168.220.99".to_string(); "".to_string(),
update(device).unwrap(); "".to_string(),
None,
)
.unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap(); let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Apple, Inc.".to_string()); assert_eq!(device.vendor, "Apple, Inc.".to_string());
assert_eq!(device.device_type, "phone".to_string()); assert_eq!(device.device_type, "phone".to_string());
@@ -563,17 +603,21 @@ mod tests {
// A later sighting that deduces a different vendor updates vendor but must NOT // A later sighting that deduces a different vendor updates vendor but must NOT
// overwrite a device_type that is already set. // overwrite a device_type that is already set.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap(); seen(
device.vendor = "Google, Inc.".to_string(); "vv:vv:vv:vv:vv:01".to_string(),
device.device_type = "tablet".to_string(); "192.168.220.99".to_string(),
update(device).unwrap(); "Google, Inc.".to_string(),
"tablet".to_string(),
None,
)
.unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap(); let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Google, Inc.".to_string()); assert_eq!(device.vendor, "Google, Inc.".to_string());
assert_eq!(device.device_type, "phone".to_string()); assert_eq!(device.device_type, "phone".to_string());
} }
#[tokio::test] #[tokio::test]
async fn test_update_preserves_registered_device_type() { async fn test_seen_preserves_registered_device_type() {
tests_common::setup().await; tests_common::setup().await;
// A device registered with a user-chosen device_type must not have it overwritten // A device registered with a user-chosen device_type must not have it overwritten
@@ -594,16 +638,58 @@ mod tests {
) )
.unwrap(); .unwrap();
let mut device = read("pp:pp:pp:pp:pp:01".to_string()).unwrap(); seen(
device.vendor = "Apple, Inc.".to_string(); "pp:pp:pp:pp:pp:01".to_string(),
device.device_type = "Phone".to_string(); "192.168.240.1".to_string(),
update(device).unwrap(); "Apple, Inc.".to_string(),
"Phone".to_string(),
None,
)
.unwrap();
let device = read("pp:pp:pp:pp:pp:01".to_string()).unwrap(); let device = read("pp:pp:pp:pp:pp:01".to_string()).unwrap();
assert_eq!(device.vendor, "Apple, Inc.".to_string()); assert_eq!(device.vendor, "Apple, Inc.".to_string());
assert_eq!(device.device_type, "Laptop".to_string()); assert_eq!(device.device_type, "Laptop".to_string());
} }
#[tokio::test]
async fn test_update() {
tests_common::setup().await;
// update() overwrites owner, device_type and vendor unconditionally, leaving sighting
// fields (ipv4_address, last_seen, name) and is_registered untouched.
let last_seen = Utc::now();
insert(Device {
mac_address: "mm:mm:mm:mm:mm:01".to_string(),
ipv4_address: "192.168.250.1".to_string(),
vendor: "Old Vendor".to_string(),
last_seen,
is_registered: true,
owner: "Alice".to_string(),
device_type: "Phone".to_string(),
name: Some("host.local".to_string()),
})
.unwrap();
update(
"mm:mm:mm:mm:mm:01".to_string(),
"Bob".to_string(),
"Laptop".to_string(),
"New Vendor".to_string(),
)
.unwrap();
let device = read("mm:mm:mm:mm:mm:01".to_string()).unwrap();
assert_eq!(device.owner, "Bob".to_string());
assert_eq!(device.device_type, "Laptop".to_string());
assert_eq!(device.vendor, "New Vendor".to_string());
// Sighting and registration fields are untouched
assert_eq!(device.ipv4_address, "192.168.250.1".to_string());
assert_eq!(device.last_seen, last_seen);
assert_eq!(device.name, Some("host.local".to_string()));
assert!(device.is_registered);
}
#[tokio::test] #[tokio::test]
async fn test_register() { async fn test_register() {
tests_common::setup().await; tests_common::setup().await;
+1 -1
View File
@@ -25,7 +25,7 @@ fn display_name(device: &Device) -> &str {
} }
// Whether a re-sighting represents a real vendor change. A scanner that cannot deduce a vendor // 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::update keeps the known vendor), so // 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 // it must not raise a "vendor changed" notification either. Likewise, first deducing a vendor for a
// device that previously had none is not a change worth notifying about. // device that previously had none is not a change worth notifying about.
fn vendor_changed(existing: &str, new: &str) -> bool { fn vendor_changed(existing: &str, new: &str) -> bool {
+7 -1
View File
@@ -93,7 +93,13 @@ async fn process_announcement(
if recorded.name.is_some() { if recorded.name.is_some() {
device.name = recorded.name.clone(); device.name = recorded.name.clone();
} }
if let Err(err) = db::devices::update(device.clone()) { if let Err(err) = db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
) {
error!("Failed to update mDNS device {mac}: {err}"); error!("Failed to update mDNS device {mac}: {err}");
return; return;
} }
+4 -1
View File
@@ -5,7 +5,7 @@ use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType}; use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings; use crate::settings::get_settings;
use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::arp_scanner::ArpScannerStatusResponse;
use crate::web_server::devices::RegisterDevicePayload; use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
use crate::web_server::mdns_scanner::MdnsScannerStatusResponse; use crate::web_server::mdns_scanner::MdnsScannerStatusResponse;
use axum::Json; use axum::Json;
use axum::extract::Request; use axum::extract::Request;
@@ -43,6 +43,7 @@ pub mod utils;
devices::summary, devices::summary,
devices::read, devices::read,
devices::register, devices::register,
devices::update,
devices::unregister, devices::unregister,
notifications::list, notifications::list,
notifications::read, notifications::read,
@@ -59,6 +60,7 @@ pub mod utils;
Notification, Notification,
NotificationType, NotificationType,
RegisterDevicePayload, RegisterDevicePayload,
UpdateDevicePayload,
DeviceEvent, DeviceEvent,
DeviceEventType, DeviceEventType,
ArpScannerStatusResponse, ArpScannerStatusResponse,
@@ -109,6 +111,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
.route("/api/devices/summary", get(devices::summary)) .route("/api/devices/summary", get(devices::summary))
.route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", delete(devices::unregister))
.route("/api/devices/{mac_address}", get(devices::read)) .route("/api/devices/{mac_address}", get(devices::read))
.route("/api/devices/{mac_address}", put(devices::update))
.route( .route(
"/api/devices/{mac_address}/events", "/api/devices/{mac_address}/events",
get(device_events::list), get(device_events::list),
+61
View File
@@ -134,6 +134,60 @@ pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoRe
} }
} }
#[utoipa::path(
put,
path = "/api/devices/{mac_address}",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
),
request_body = UpdateDevicePayload,
responses(
(status = 200, description = "Device updated"),
(status = 404, description = "Device not found"),
(status = 409, description = "Device is not registered"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update(
Path(mac_address): Path<String>,
Json(payload): Json<UpdateDevicePayload>,
) -> impl IntoResponse {
debug!(
"Device update received: mac_address={}, owner={}, device_type={}, vendor={}",
mac_address, payload.owner, payload.device_type, payload.vendor
);
let device = match db::devices::read(mac_address.clone()) {
Some(value) => value,
None => {
return (
axum::http::StatusCode::NOT_FOUND,
"Device not found or could not be read",
);
}
};
if !device.is_registered {
return (
axum::http::StatusCode::CONFLICT,
"Device is not registered, register it before modifying",
);
}
match db::devices::update(mac_address, payload.owner, payload.device_type, payload.vendor) {
Ok(_) => (axum::http::StatusCode::OK, "Device updated"),
Err(err) => {
error!("Error updating device in the database: {}", err);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Error updating device in the server, check your logs",
)
}
}
}
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/api/devices/{mac_address}", path = "/api/devices/{mac_address}",
@@ -206,3 +260,10 @@ pub struct RegisterDevicePayload {
owner: String, owner: String,
device_type: String, device_type: String,
} }
#[derive(Deserialize, ToSchema)]
pub struct UpdateDevicePayload {
owner: String,
device_type: String,
vendor: String,
}