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] 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
- [ ] 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
+7 -1
View File
@@ -32,7 +32,13 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
"Device found in database {}. Updating to {}.",
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
}
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
// owned by register/unregister and are intentionally never touched here.
pub fn update(device: Device) -> Result<(), DbError> {
// Records a sighting of an existing device by a scanner. last_seen is stamped here with the
// current time. Registration fields (is_registered, owner) are owned by register/unregister
// 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 mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?".to_string();
let mut params: Vec<rusqlite::types::Value> = vec![
device.ipv4_address.clone().into(),
device
.last_seen
ipv4_address.into(),
Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
.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.
// 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.
if !device.vendor.is_empty() {
if !vendor.is_empty() {
sql.push_str(", vendor=?, device_type=CASE WHEN device_type='' THEN ? ELSE device_type END");
params.push(device.vendor.clone().into());
params.push(device.device_type.clone().into());
params.push(vendor.into());
params.push(device_type.into());
}
// 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.
if let Some(name) = &device.name {
if let Some(name) = name {
sql.push_str(", name=?");
params.push(name.clone().into());
params.push(name.into());
}
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())) {
Ok(_) => {
debug!("Device updated in database: {}", device);
debug!("Device sighting recorded in database: {mac_address}");
Ok(())
}
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))
}
}
@@ -416,23 +447,22 @@ mod tests {
}
#[tokio::test]
async fn test_update() {
async fn test_seen() {
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(
"uu:tt:tt:tt:tt:aa".to_string(),
"192.168.200.1".to_string(),
"Test vendor".to_string(),
last_seen,
initial_last_seen,
))
.unwrap();
// Register the device, then update it with a Device carrying the scanner defaults
// (is_registered=false, owner=""). The update must NOT clobber the registration while
// still refreshing sighting fields like ipv4_address and last_seen.
// Register the device, then record a sighting. seen() must NOT clobber the
// registration while still refreshing sighting fields (ipv4_address, last_seen).
register(
"uu:tt:tt:tt:tt:aa".to_string(),
"Grace".to_string(),
@@ -440,56 +470,44 @@ mod tests {
)
.unwrap();
let new_last_seen = Utc::now();
let mut device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap();
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,
let before = Utc::now();
seen(
"uu:tt:tt:tt:tt:aa".to_string(),
"Phone".to_string(),
"192.168.200.50".to_string(),
true,
new_last_seen,
"Grace".to_string(),
"Test vendor".to_string(),
);
// 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,
))
"".to_string(),
None,
)
.unwrap();
let mut device = read("uu:tt:tt:tt:tt:bb".to_string()).unwrap();
device.mac_address = "uu:tt:tt:tt:tt:cc".to_string();
update(device).unwrap();
let device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap();
assert_eq!(device.ipv4_address, "192.168.200.50".to_string());
assert!(
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!(
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]
async fn test_update_name_set_and_preserve() {
async fn test_seen_name_set_and_preserve() {
tests_common::setup().await;
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);
// mDNS sets the name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap();
device.name = Some("host.local".to_string());
update(device).unwrap();
seen(
"nn:nn:nn:nn:nn:01".to_string(),
"192.168.210.1".to_string(),
"Test vendor".to_string(),
"".to_string(),
Some("host.local".to_string()),
)
.unwrap();
assert_eq!(
read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name,
Some("host.local".to_string())
);
// An ARP rescan (name: None) must NOT clobber the stored name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap();
device.name = None;
device.ipv4_address = "192.168.210.99".to_string();
update(device).unwrap();
seen(
"nn:nn:nn:nn:nn:01".to_string(),
"192.168.210.99".to_string(),
"Test vendor".to_string(),
"".to_string(),
None,
)
.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.ipv4_address, "192.168.210.99".to_string());
// A later mDNS sighting updates the name
let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap();
device.name = Some("renamed.local".to_string());
update(device).unwrap();
seen(
"nn:nn:nn:nn:nn:01".to_string(),
"192.168.210.99".to_string(),
"Test vendor".to_string(),
"".to_string(),
Some("renamed.local".to_string()),
)
.unwrap();
assert_eq!(
read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name,
Some("renamed.local".to_string())
@@ -533,7 +565,7 @@ mod tests {
}
#[tokio::test]
async fn test_update_vendor_set_and_preserve() {
async fn test_seen_vendor_set_and_preserve() {
tests_common::setup().await;
let last_seen = Utc::now();
@@ -545,17 +577,25 @@ mod tests {
))
.unwrap();
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.device_type = "phone".to_string();
update(device).unwrap();
seen(
"vv:vv:vv:vv:vv:01".to_string(),
"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,
// nor its derived device_type, but other fields still update.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.vendor = "".to_string();
device.device_type = "".to_string();
device.ipv4_address = "192.168.220.99".to_string();
update(device).unwrap();
seen(
"vv:vv:vv:vv:vv:01".to_string(),
"192.168.220.99".to_string(),
"".to_string(),
"".to_string(),
None,
)
.unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Apple, Inc.".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
// overwrite a device_type that is already set.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.vendor = "Google, Inc.".to_string();
device.device_type = "tablet".to_string();
update(device).unwrap();
seen(
"vv:vv:vv:vv:vv:01".to_string(),
"192.168.220.99".to_string(),
"Google, Inc.".to_string(),
"tablet".to_string(),
None,
)
.unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Google, Inc.".to_string());
assert_eq!(device.device_type, "phone".to_string());
}
#[tokio::test]
async fn test_update_preserves_registered_device_type() {
async fn test_seen_preserves_registered_device_type() {
tests_common::setup().await;
// A device registered with a user-chosen device_type must not have it overwritten
@@ -594,16 +638,58 @@ mod tests {
)
.unwrap();
let mut device = read("pp:pp:pp:pp:pp:01".to_string()).unwrap();
device.vendor = "Apple, Inc.".to_string();
device.device_type = "Phone".to_string();
update(device).unwrap();
seen(
"pp:pp:pp:pp:pp:01".to_string(),
"192.168.240.1".to_string(),
"Apple, Inc.".to_string(),
"Phone".to_string(),
None,
)
.unwrap();
let device = read("pp:pp:pp:pp:pp:01".to_string()).unwrap();
assert_eq!(device.vendor, "Apple, Inc.".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]
async fn test_register() {
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
// 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
// device that previously had none is not a change worth notifying about.
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() {
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}");
return;
}
+4 -1
View File
@@ -5,7 +5,7 @@ use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings;
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 axum::Json;
use axum::extract::Request;
@@ -43,6 +43,7 @@ pub mod utils;
devices::summary,
devices::read,
devices::register,
devices::update,
devices::unregister,
notifications::list,
notifications::read,
@@ -59,6 +60,7 @@ pub mod utils;
Notification,
NotificationType,
RegisterDevicePayload,
UpdateDevicePayload,
DeviceEvent,
DeviceEventType,
ArpScannerStatusResponse,
@@ -109,6 +111,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
.route("/api/devices/summary", get(devices::summary))
.route("/api/devices/{mac_address}", delete(devices::unregister))
.route("/api/devices/{mac_address}", get(devices::read))
.route("/api/devices/{mac_address}", put(devices::update))
.route(
"/api/devices/{mac_address}/events",
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(
delete,
path = "/api/devices/{mac_address}",
@@ -206,3 +260,10 @@ pub struct RegisterDevicePayload {
owner: String,
device_type: String,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateDevicePayload {
owner: String,
device_type: String,
vendor: String,
}