use mechutil::ipc::CommandMessage;
use serde_json::Value;
use crate::CommandClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetWatchTrigger {
InitialSync,
InitialSyncEmpty,
Created,
CalibrationAdded,
NameplateUpdated,
StatusChanged,
LocationChanged,
Other(&'static str),
}
impl AssetWatchTrigger {
fn from_str(s: &str) -> Self {
match s {
"created" => AssetWatchTrigger::Created,
"calibration_added" => AssetWatchTrigger::CalibrationAdded,
"nameplate_updated" => AssetWatchTrigger::NameplateUpdated,
"status_changed" => AssetWatchTrigger::StatusChanged,
"location_changed" => AssetWatchTrigger::LocationChanged,
_ => AssetWatchTrigger::Other("other"),
}
}
}
#[derive(Debug, Clone)]
pub struct AssetUpdate {
pub trigger: AssetWatchTrigger,
pub raw_trigger: String,
pub location: String,
pub asset_id: String,
pub asset_type: String,
pub asset: Value,
pub current_calibration: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetWatchStatus {
Missing,
Active,
Retired,
OutForService,
}
impl AssetWatchStatus {
fn from_asset_status_str(s: &str) -> Self {
match s {
"active" => AssetWatchStatus::Active,
"retired" => AssetWatchStatus::Retired,
"out_for_service" => AssetWatchStatus::OutForService,
_ => AssetWatchStatus::Missing,
}
}
}
pub struct AssetWatch {
location: String,
initial_tid: Option<u32>,
initial_sent: bool,
status: AssetWatchStatus,
asset_id: Option<String>,
}
impl AssetWatch {
pub fn new(location: &str, client: &mut CommandClient) -> Self {
let topic = format!("ams.asset_updated.{}", location);
client.subscribe(&topic);
let tid = client.send(
"ams.list_assets",
serde_json::json!({ "location": location, "status": "active" }),
);
Self {
location: location.to_string(),
initial_tid: Some(tid),
initial_sent: false,
status: AssetWatchStatus::Missing,
asset_id: None,
}
}
pub fn location(&self) -> &str {
&self.location
}
pub fn initialised(&self) -> bool {
self.initial_sent
}
pub fn is_active(&self) -> bool {
self.status == AssetWatchStatus::Active
}
pub fn active_asset_id(&self) -> Option<&str> {
self.asset_id.as_deref()
}
pub fn active_status(&self) -> AssetWatchStatus {
self.status
}
fn fold_event(&mut self, update: &AssetUpdate) {
if matches!(update.trigger, AssetWatchTrigger::LocationChanged) {
self.status = AssetWatchStatus::Missing;
self.asset_id = None;
return;
}
if matches!(update.trigger, AssetWatchTrigger::InitialSyncEmpty) {
self.status = AssetWatchStatus::Missing;
self.asset_id = None;
return;
}
let raw_status = update.asset
.get("status").and_then(|v| v.as_str()).unwrap_or("");
self.status = AssetWatchStatus::from_asset_status_str(raw_status);
self.asset_id = if update.asset_id.is_empty() {
None
} else {
Some(update.asset_id.clone())
};
}
pub fn pump(&mut self, client: &mut CommandClient) -> Vec<AssetUpdate> {
let mut out: Vec<AssetUpdate> = Vec::new();
if let Some(tid) = self.initial_tid {
if let Some(resp) = client.take_response(tid) {
self.initial_tid = None;
self.initial_sent = true;
if resp.success {
let first = resp.data.get("assets")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.cloned();
out.push(self.bootstrap_event(first, client));
} else {
out.push(AssetUpdate {
trigger: AssetWatchTrigger::InitialSyncEmpty,
raw_trigger: "initial_sync_empty".to_string(),
location: self.location.clone(),
asset_id: String::new(),
asset_type: String::new(),
asset: Value::Null,
current_calibration: Value::Null,
});
}
}
}
let topic = format!("ams.asset_updated.{}", self.location);
for msg in client.take_broadcasts(&topic) {
out.push(AssetUpdate::from_broadcast(msg));
}
for update in &out {
self.fold_event(update);
}
out
}
fn bootstrap_event(&mut self, asset_row: Option<Value>, client: &mut CommandClient) -> AssetUpdate {
let Some(row) = asset_row else {
return AssetUpdate {
trigger: AssetWatchTrigger::InitialSyncEmpty,
raw_trigger: "initial_sync_empty".to_string(),
location: self.location.clone(),
asset_id: String::new(),
asset_type: String::new(),
asset: Value::Null,
current_calibration: Value::Null,
};
};
let _ = client;
AssetUpdate {
trigger: AssetWatchTrigger::InitialSync,
raw_trigger: "initial_sync".to_string(),
location: self.location.clone(),
asset_id: row.get("asset_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
asset_type: row.get("asset_type").and_then(|v| v.as_str()).unwrap_or("").to_string(),
asset: row,
current_calibration: Value::Null,
}
}
}
impl AssetUpdate {
fn from_broadcast(msg: CommandMessage) -> Self {
let raw_trigger = msg.data.get("trigger")
.and_then(|v| v.as_str()).unwrap_or("")
.to_string();
let trigger = AssetWatchTrigger::from_str(&raw_trigger);
let asset = msg.data.get("asset").cloned().unwrap_or(Value::Null);
let current_calibration = msg.data.get("current_calibration").cloned().unwrap_or(Value::Null);
let location = msg.data.get("location").and_then(|v| v.as_str()).unwrap_or("").to_string();
let asset_id = msg.data.get("asset_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let asset_type = msg.data.get("asset_type").and_then(|v| v.as_str()).unwrap_or("").to_string();
Self {
trigger,
raw_trigger,
location,
asset_id,
asset_type,
asset,
current_calibration,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mechutil::ipc::{CommandMessage, MessageType};
use serde_json::json;
use tokio::sync::mpsc;
fn fresh_client() -> (
CommandClient,
mpsc::UnboundedSender<CommandMessage>,
mpsc::UnboundedReceiver<String>,
) {
let (write_tx, write_rx) = mpsc::unbounded_channel();
let (response_tx, response_rx) = mpsc::unbounded_channel();
let client = CommandClient::new(write_tx, response_rx);
(client, response_tx, write_rx)
}
fn broadcast(topic: &str, data: Value) -> CommandMessage {
let mut m = CommandMessage::default();
m.topic = topic.to_string();
m.message_type = MessageType::Broadcast;
m.data = data;
m
}
#[test]
fn subscription_and_initial_sync_request_are_sent_on_new() {
let (mut client, _response_tx, mut write_rx) = fresh_client();
let _watch = AssetWatch::new("tsdr_fx", &mut client);
let outgoing = write_rx.try_recv().expect("expected list_assets send");
let sent: CommandMessage = serde_json::from_str(&outgoing).unwrap();
assert_eq!(sent.topic, "ams.list_assets");
assert_eq!(sent.data.get("location").and_then(|v| v.as_str()), Some("tsdr_fx"));
assert_eq!(sent.data.get("status").and_then(|v| v.as_str()), Some("active"));
let mut probe = broadcast("ams.asset_updated.tsdr_fx", json!({ "trigger": "created" }));
probe.message_type = MessageType::Broadcast;
let _ = probe;
}
#[test]
fn initial_sync_returns_asset_row_when_present() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({
"assets": [{
"asset_id": "LC-20260301T091548",
"asset_type": "load_cell",
"location": "tsdr_fx",
"status": "active",
"current_calibration_id": "20260301T091548"
}]
});
response_tx.send(resp).unwrap();
client.poll();
let events = watch.pump(&mut client);
assert_eq!(events.len(), 1);
assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSync);
assert_eq!(events[0].asset_id, "LC-20260301T091548");
assert_eq!(events[0].asset_type, "load_cell");
assert_eq!(events[0].location, "tsdr_fx");
assert!(watch.initialised());
}
#[test]
fn initial_sync_empty_when_no_asset_at_location() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("nope", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [] });
response_tx.send(resp).unwrap();
client.poll();
let events = watch.pump(&mut client);
assert_eq!(events.len(), 1);
assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSyncEmpty);
assert_eq!(events[0].asset, Value::Null);
assert!(watch.initialised());
}
#[test]
fn broadcasts_parse_trigger_and_payload() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [] });
response_tx.send(resp).unwrap();
client.poll();
let _ = watch.pump(&mut client);
response_tx.send(broadcast(
"ams.asset_updated.tsdr_fx",
json!({
"trigger": "calibration_added",
"location": "tsdr_fx",
"asset_id": "LC-1",
"asset_type":"load_cell",
"asset": { "custom": { "capacity": 5000.0 } },
"current_calibration": {
"cal_id": "20260301T091548",
"values": { "scale": 9.81234, "offset": -0.0042 }
}
}),
)).unwrap();
client.poll();
let events = watch.pump(&mut client);
assert_eq!(events.len(), 1);
assert_eq!(events[0].trigger, AssetWatchTrigger::CalibrationAdded);
assert_eq!(events[0].raw_trigger, "calibration_added");
assert_eq!(
events[0].current_calibration.pointer("/values/scale").and_then(|v| v.as_f64()),
Some(9.81234),
);
assert_eq!(
events[0].asset.pointer("/custom/capacity").and_then(|v| v.as_f64()),
Some(5000.0),
);
}
#[test]
fn broadcasts_for_other_locations_are_ignored() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [] });
response_tx.send(resp).unwrap();
response_tx.send(broadcast(
"ams.asset_updated.tsdr_fy",
json!({ "trigger": "calibration_added" }),
)).unwrap();
client.poll();
let events = watch.pump(&mut client);
assert_eq!(events.len(), 1, "only the initial-sync event should land");
assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSyncEmpty);
}
#[test]
fn is_active_starts_false_and_flips_after_initial_sync_with_active_asset() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
assert!(!watch.is_active());
assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
assert!(watch.active_asset_id().is_none());
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({
"assets": [{
"asset_id": "LC-NEW",
"asset_type": "load_cell",
"location": "tsdr_fx",
"status": "active",
}]
});
response_tx.send(resp).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(watch.is_active(), "active asset → is_active() should be true");
assert_eq!(watch.active_asset_id(), Some("LC-NEW"));
assert_eq!(watch.active_status(), AssetWatchStatus::Active);
}
#[test]
fn is_active_stays_false_after_initial_sync_empty() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("nope", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [] });
response_tx.send(resp).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(!watch.is_active());
assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
assert!(watch.active_asset_id().is_none());
}
#[test]
fn retire_broadcast_flips_is_active_to_false() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [{
"asset_id": "LC-1", "asset_type": "load_cell",
"location": "tsdr_fx", "status": "active",
}] });
response_tx.send(resp).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(watch.is_active());
response_tx.send(broadcast(
"ams.asset_updated.tsdr_fx",
json!({
"trigger": "status_changed",
"location": "tsdr_fx",
"asset_id": "LC-1",
"asset_type": "load_cell",
"asset": { "asset_id": "LC-1", "status": "retired" },
}),
)).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(!watch.is_active(),
"after retirement is_active should be false");
assert_eq!(watch.active_status(), AssetWatchStatus::Retired);
assert_eq!(watch.active_asset_id(), Some("LC-1"));
}
#[test]
fn location_changed_event_flips_is_active_to_missing() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [{
"asset_id": "LC-1", "asset_type": "load_cell",
"location": "tsdr_fx", "status": "active",
}] });
response_tx.send(resp).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(watch.is_active());
response_tx.send(broadcast(
"ams.asset_updated.tsdr_fx",
json!({
"trigger": "location_changed",
"location": "tsdr_fx",
"asset_id": "LC-1",
"asset_type": "load_cell",
"asset": { "asset_id": "LC-1", "status": "retired" }
}),
)).unwrap();
client.poll();
let _ = watch.pump(&mut client);
assert!(!watch.is_active());
assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
assert!(watch.active_asset_id().is_none());
}
#[test]
fn broadcasts_queued_before_initial_sync_response_arrive_after_it() {
let (mut client, response_tx, _write_rx) = fresh_client();
let mut watch = AssetWatch::new("tsdr_fx", &mut client);
response_tx.send(broadcast(
"ams.asset_updated.tsdr_fx",
json!({ "trigger": "calibration_added" }),
)).unwrap();
let tid = watch.initial_tid.unwrap();
let mut resp = CommandMessage::default();
resp.transaction_id = tid;
resp.message_type = MessageType::Response;
resp.success = true;
resp.data = json!({ "assets": [{
"asset_id": "LC-1", "asset_type": "load_cell",
"location": "tsdr_fx", "status": "active"
}] });
response_tx.send(resp).unwrap();
client.poll();
let events = watch.pump(&mut client);
assert_eq!(events.len(), 2);
assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSync);
assert_eq!(events[1].trigger, AssetWatchTrigger::CalibrationAdded);
}
}