Split device registration out of the update path

Add dedicated db::devices::register/unregister methods so registration
state (is_registered, owner) is owned by the API endpoints, and make
update sighting-only. This stops scanner re-sightings from wiping a
device's registration. Also preserve an existing mDNS hostname instead
of overwriting it on re-sighting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-29 14:15:29 -04:00
co-authored by Claude Opus 4.7
parent 98a23aa7fd
commit 561fbdb548
3 changed files with 122 additions and 19 deletions
+113 -8
View File
@@ -183,18 +183,18 @@ 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> {
let conn = db::get_db_connection();
let mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?, is_registered=?, owner=?".to_string();
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
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
.into(),
device.is_registered.into(),
device.owner.clone().into(),
];
// Only write the vendor (and its derived device_type) when deduced, so a sighting that
@@ -227,6 +227,42 @@ pub fn update(device: Device) -> Result<(), DbError> {
}
}
pub fn register(mac_address: String, owner: String, device_type: String) -> Result<(), DbError> {
let conn = db::get_db_connection();
match conn.execute(
"UPDATE devices SET is_registered=1, owner=?1, device_type=?2 WHERE mac_address=?3",
params![owner, device_type, mac_address],
) {
Ok(_) => {
debug!("Device registered in database: {mac_address}");
Ok(())
}
Err(error) => {
error!("Error registering device ({mac_address}) in database: {error}");
Err(DbError::from(error))
}
}
}
pub fn unregister(mac_address: String) -> Result<(), DbError> {
let conn = db::get_db_connection();
match conn.execute(
"UPDATE devices SET is_registered=0, owner='' WHERE mac_address=?1",
params![mac_address],
) {
Ok(_) => {
debug!("Device unregistered in database: {mac_address}");
Ok(())
}
Err(error) => {
error!("Error unregistering device ({mac_address}) in database: {error}");
Err(DbError::from(error))
}
}
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, TimeZone, Utc};
@@ -392,10 +428,22 @@ mod tests {
))
.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(
"uu:tt:tt:tt:tt:aa".to_string(),
"Grace".to_string(),
"Phone".to_string(),
)
.unwrap();
let new_last_seen = Utc::now();
let mut device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap();
device.is_registered = true;
device.owner = "Grace".to_string();
device.device_type = "Phone".to_string();
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();
@@ -405,9 +453,9 @@ mod tests {
device,
"uu:tt:tt:tt:tt:aa".to_string(),
"Phone".to_string(),
"192.168.200.1".to_string(),
"192.168.200.50".to_string(),
true,
last_seen,
new_last_seen,
"Grace".to_string(),
"Test vendor".to_string(),
);
@@ -521,6 +569,63 @@ mod tests {
assert_eq!(device.device_type, "tablet".to_string());
}
#[tokio::test]
async fn test_register() {
tests_common::setup().await;
// A device with no deduced vendor must still persist the device_type chosen at registration.
let last_seen = Utc::now();
insert(Device::new(
"rr:rr:rr:rr:rr:01".to_string(),
"192.168.230.1".to_string(),
"".to_string(),
last_seen,
))
.unwrap();
register(
"rr:rr:rr:rr:rr:01".to_string(),
"Grace".to_string(),
"Phone".to_string(),
)
.unwrap();
let device = read("rr:rr:rr:rr:rr:01".to_string()).unwrap();
assert!(device.is_registered, "Device should be registered");
assert_eq!(device.owner, "Grace".to_string());
assert_eq!(device.device_type, "Phone".to_string());
assert_eq!(device.vendor, "".to_string());
}
#[tokio::test]
async fn test_unregister() {
tests_common::setup().await;
let last_seen = Utc::now();
insert(Device::new(
"rr:rr:rr:rr:rr:02".to_string(),
"192.168.230.2".to_string(),
"Test vendor".to_string(),
last_seen,
))
.unwrap();
register(
"rr:rr:rr:rr:rr:02".to_string(),
"Grace".to_string(),
"Phone".to_string(),
)
.unwrap();
unregister("rr:rr:rr:rr:rr:02".to_string()).unwrap();
let device = read("rr:rr:rr:rr:rr:02".to_string()).unwrap();
assert!(!device.is_registered, "Device should not be registered");
assert_eq!(device.owner, "".to_string());
// device_type is left untouched by unregister
assert_eq!(device.device_type, "Phone".to_string());
}
#[tokio::test]
async fn test_insert() {
tests_common::setup().await;
+5
View File
@@ -88,6 +88,11 @@ async fn process_announcement(
match db::devices::read(mac.clone()) {
Some(recorded) => {
debug!("mDNS sighting of known device {mac}; updating");
// Keep the previously stored hostname rather than overwriting it with this
// announcement's hostname.
if recorded.name.is_some() {
device.name = recorded.name.clone();
}
if let Err(err) = db::devices::update(device.clone()) {
error!("Failed to update mDNS device {mac}: {err}");
return;
+4 -11
View File
@@ -105,7 +105,7 @@ pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoRe
payload.mac_address, payload.owner, payload.device_type
);
let mut device = match db::devices::read(payload.mac_address) {
let device = match db::devices::read(payload.mac_address.clone()) {
Some(value) => value,
None => {
return (
@@ -122,11 +122,7 @@ pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoRe
);
}
device.is_registered = true;
device.owner = payload.owner;
device.device_type = payload.device_type;
match db::devices::update(device) {
match db::devices::register(payload.mac_address, payload.owner, payload.device_type) {
Ok(_) => (axum::http::StatusCode::CREATED, "Device registered"),
Err(err) => {
error!("Error registering device in the database: {}", err);
@@ -154,7 +150,7 @@ pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoRe
security(("bearer_auth" = []))
)]
pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
let mut device = match db::devices::read(mac_address) {
let device = match db::devices::read(mac_address.clone()) {
Some(value) => value,
None => {
return (
@@ -171,10 +167,7 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
);
}
device.is_registered = false;
device.owner = "".to_string();
match db::devices::update(device) {
match db::devices::unregister(mac_address) {
Ok(_) => (axum::http::StatusCode::OK, "Device un-registered"),
Err(err) => {
error!("Error updating device in the database: {}", err);