autocore_std/asset_watch.rs
1//! Subscription helper for `ams.asset_updated.<location>` broadcasts.
2//!
3//! Control programs use this when they need to react to an AMS asset
4//! changing — typically to re-fire CoE SDOs on an EL3356-0010 after a
5//! load cell is recalibrated, but the same pattern works for any
6//! "something on disk just changed, write fresh bytes to hardware"
7//! flow.
8//!
9//! # Lifecycle
10//!
11//! 1. Construct via `AssetWatch::new("tsdr_fx", client)`. This
12//! subscribes the `CommandClient` to the per-location topic and
13//! sends a one-shot `ams.list_assets` request scoped to the
14//! location so the watcher starts from a consistent snapshot —
15//! regardless of whether the asset was registered before or after
16//! the control program connected.
17//! 2. Per tick the control program calls `watch.pump(client)` and
18//! receives `Vec<AssetUpdate>` to act on. Updates are returned in
19//! arrival order; the first one after construction is the initial
20//! sync (trigger = `InitialSync`).
21//! 3. Acting on an update is the caller's responsibility. The typical
22//! EL3356 pattern is to issue `ethercat.write_sdo` IPC for each
23//! affected CoE object inside the loop that consumed `pump()`'s
24//! return value.
25//!
26//! # Why poll-based instead of callbacks
27//!
28//! `CommandClient` is passed `&mut` per tick. Holding `Box<dyn FnMut>`
29//! handlers across ticks would force the helper to own them, which
30//! conflicts with the borrow patterns control programs already use
31//! (sharing `&mut client` between subsystems). A `pump()` that
32//! returns owned `Vec<AssetUpdate>` keeps the caller in charge of
33//! when work happens.
34//!
35//! # Initial-sync semantics
36//!
37//! On construction the watcher sends `ams.list_assets` with the
38//! location filter. The response is then consumed by `pump()` and
39//! converted into an `AssetUpdate { trigger: InitialSync, .. }`. If
40//! no active asset exists at this location, the first `pump()`
41//! returns an `InitialSyncEmpty` so the control program knows the
42//! resolver would have refused to start a hardware module here and
43//! can put its own state machine in a safe mode.
44
45use mechutil::ipc::CommandMessage;
46use serde_json::Value;
47
48use crate::CommandClient;
49
50/// What caused this update to be published.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AssetWatchTrigger {
53 /// First payload after `AssetWatch::new`, derived from a one-shot
54 /// `ams.list_assets` query. Tells the control program "this is
55 /// the current state of the role; act as if you'd just received
56 /// every broadcast since the asset was registered."
57 InitialSync,
58 /// First payload after `AssetWatch::new` and the location has no
59 /// active asset registered. Control programs should react by
60 /// disabling the hardware feature this asset feeds (e.g., refuse
61 /// to enter a test that depends on the role).
62 InitialSyncEmpty,
63 /// `ams.create_asset` registered a new asset at this location.
64 Created,
65 /// `ams.add_calibration` recorded a new calibration on the asset.
66 /// This is the most common operational trigger — recal events
67 /// during a service window.
68 CalibrationAdded,
69 /// `ams.update_asset` changed nameplate fields (capacity,
70 /// compression_sensitivity_mv_v, bridge_resistance_ohm, etc.) or the
71 /// asset's sub_locations.
72 NameplateUpdated,
73 /// `ams.update_asset` changed the asset's lifecycle status
74 /// (active ↔ retired / out_for_service). On a retirement the
75 /// payload's `asset.status` reflects the new state — control
76 /// programs typically refuse to run tests against a retired role.
77 StatusChanged,
78 /// `ams.update_asset` moved the asset out of this role. The
79 /// payload's `asset.status` shows `retired` from this location's
80 /// POV — the asset itself is fine, just elsewhere now. Same
81 /// reaction as `StatusChanged` in most cases.
82 LocationChanged,
83 /// A trigger label the server published that this client doesn't
84 /// know about. Carried as a string so future server versions can
85 /// add triggers without forcing a control-program rebuild.
86 Other(&'static str),
87}
88
89impl AssetWatchTrigger {
90 fn from_str(s: &str) -> Self {
91 match s {
92 "created" => AssetWatchTrigger::Created,
93 "calibration_added" => AssetWatchTrigger::CalibrationAdded,
94 "nameplate_updated" => AssetWatchTrigger::NameplateUpdated,
95 "status_changed" => AssetWatchTrigger::StatusChanged,
96 "location_changed" => AssetWatchTrigger::LocationChanged,
97 // Boxing/leak avoided — most "other" values are short server
98 // tokens that fit in &'static str literals if we ever add
99 // them. For runtime-supplied trigger strings, callers can
100 // inspect `update.raw_trigger` if they need the original.
101 _ => AssetWatchTrigger::Other("other"),
102 }
103 }
104}
105
106/// One delivery from `AssetWatch::pump`.
107#[derive(Debug, Clone)]
108pub struct AssetUpdate {
109 /// The semantic trigger; see [`AssetWatchTrigger`].
110 pub trigger: AssetWatchTrigger,
111 /// The exact string the server published in `payload.trigger` (or
112 /// `"initial_sync"` / `"initial_sync_empty"` for the bootstrap
113 /// deliveries). Useful when `trigger == Other`.
114 pub raw_trigger: String,
115 /// `asset.location` from the broadcast — always matches the
116 /// location this watcher was constructed for, except for
117 /// `LocationChanged` events where it's the *old* location.
118 pub location: String,
119 /// `asset.asset_id`. Empty when `trigger == InitialSyncEmpty`.
120 pub asset_id: String,
121 /// `asset.asset_type`. Empty when `trigger == InitialSyncEmpty`.
122 pub asset_type: String,
123 /// The full `asset` object from the broadcast — `null` when the
124 /// trigger is `InitialSyncEmpty`. Custom nameplate fields live at
125 /// `asset.custom.<name>`.
126 pub asset: Value,
127 /// The asset's active calibration record (full JSON including
128 /// `values.scale`, `values.offset`, `expires_at`, `cert_ref`).
129 /// `null` when there's no calibration on record yet.
130 pub current_calibration: Value,
131}
132
133/// Latest known lifecycle state of the asset at this location, as
134/// observed by `AssetWatch::pump`. Mirrors the on-disk `AssetStatus`
135/// enum plus a `Missing` variant for "no asset registered here at
136/// all." Control programs gate auto on `is_active()` (which is
137/// `state == Active`), or pattern-match on `active_status()` for
138/// finer-grained policy.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum AssetWatchStatus {
141 /// No asset is registered at the location (or the only one was
142 /// deleted / had its location moved elsewhere).
143 Missing,
144 /// Active asset present at the location.
145 Active,
146 /// Asset present but marked Retired — control programs should
147 /// refuse to run tests against it.
148 Retired,
149 /// Asset present but marked OutForService — same posture as
150 /// Retired, separate label so the operator's UI can distinguish.
151 OutForService,
152}
153
154impl AssetWatchStatus {
155 fn from_asset_status_str(s: &str) -> Self {
156 match s {
157 "active" => AssetWatchStatus::Active,
158 "retired" => AssetWatchStatus::Retired,
159 "out_for_service" => AssetWatchStatus::OutForService,
160 _ => AssetWatchStatus::Missing,
161 }
162 }
163}
164
165/// Subscription handle for one location. One watcher per location;
166/// construct multiple instances if the control program cares about
167/// multiple roles. Hold onto the `AssetWatch` for the lifetime of the
168/// control program — dropping it leaves the underlying topic
169/// subscribed but discards any queued events.
170pub struct AssetWatch {
171 location: String,
172 /// Transaction ID of the in-flight `ams.list_assets` call used for
173 /// the initial sync; set to `Some` between construction and the
174 /// first `pump()` that observes the response.
175 initial_tid: Option<u32>,
176 /// True once the InitialSync (or InitialSyncEmpty) has been emitted.
177 /// Broadcasts that arrive before this point are still queued by
178 /// `CommandClient` (since we subscribed at construction time) so
179 /// the operator can't miss a recal that fires between
180 /// `AssetWatch::new` and the list_assets response.
181 initial_sent: bool,
182 /// Latest known lifecycle state. Starts `Missing` and is updated
183 /// from each event the watcher observes. `is_active()` reads this.
184 status: AssetWatchStatus,
185 /// `asset_id` of the asset currently filling this role, or `None`
186 /// when `status == Missing`. Useful for logging and for the
187 /// "Asset X took over from asset Y on this rig" trail.
188 asset_id: Option<String>,
189}
190
191impl AssetWatch {
192 /// Subscribe to `ams.asset_updated.<location>` and issue the
193 /// initial-sync query. Returns the handle the control program
194 /// holds for the duration of the run.
195 ///
196 /// `client` is the `CommandClient` from `TickContext::client`. The
197 /// subscription persists past this call — `CommandClient` owns
198 /// the buffer.
199 pub fn new(location: &str, client: &mut CommandClient) -> Self {
200 let topic = format!("ams.asset_updated.{}", location);
201 client.subscribe(&topic);
202
203 // Initial sync: one-shot ams.list_assets restricted to this
204 // location, active status only. The response will surface
205 // through `client.take_response(tid)` on the next poll cycle.
206 let tid = client.send(
207 "ams.list_assets",
208 serde_json::json!({ "location": location, "status": "active" }),
209 );
210
211 Self {
212 location: location.to_string(),
213 initial_tid: Some(tid),
214 initial_sent: false,
215 status: AssetWatchStatus::Missing,
216 asset_id: None,
217 }
218 }
219
220 /// The location this watcher is bound to.
221 pub fn location(&self) -> &str {
222 &self.location
223 }
224
225 /// True once an `InitialSync` (or `InitialSyncEmpty`) has been
226 /// returned from `pump()`. Until this flips, subsequent broadcasts
227 /// are still surfaced — initial-sync just tells the operator
228 /// "you've seen the baseline."
229 pub fn initialised(&self) -> bool {
230 self.initial_sent
231 }
232
233 /// `true` if the watcher's latest observation is that an active
234 /// asset is registered at this location. The common control-loop
235 /// gate: refuse to enter auto when this is `false`.
236 ///
237 /// Initially `false` until the first `pump()` consumes the
238 /// initial-sync result. After that it tracks broadcasts in
239 /// real time.
240 pub fn is_active(&self) -> bool {
241 self.status == AssetWatchStatus::Active
242 }
243
244 /// `asset_id` currently filling this role, or `None` when
245 /// `status` is `Missing`. Useful for logging "running against
246 /// LC-2026..." and for the per-cycle trail in TIS records.
247 pub fn active_asset_id(&self) -> Option<&str> {
248 self.asset_id.as_deref()
249 }
250
251 /// Finer-grained version of `is_active`. Use when a control
252 /// program needs to distinguish (e.g.) `Retired` from
253 /// `OutForService` to drive different operator messaging.
254 pub fn active_status(&self) -> AssetWatchStatus {
255 self.status
256 }
257
258 /// Internal — refresh `status` + `asset_id` from one observed
259 /// event. Called from `pump()` for every event before it's
260 /// returned to the caller. Centralised here so the state machine
261 /// is one place: any trigger that says "I'm here now" sets
262 /// Active; any "I'm gone" sets Missing.
263 fn fold_event(&mut self, update: &AssetUpdate) {
264 // For LocationChanged at this location the trigger is the
265 // synthetic departure event — status comes through as Retired
266 // (the server marks the ghost). Treat that as Missing locally
267 // since the asset is no longer here, even if it's alive
268 // elsewhere.
269 if matches!(update.trigger, AssetWatchTrigger::LocationChanged) {
270 self.status = AssetWatchStatus::Missing;
271 self.asset_id = None;
272 return;
273 }
274 if matches!(update.trigger, AssetWatchTrigger::InitialSyncEmpty) {
275 self.status = AssetWatchStatus::Missing;
276 self.asset_id = None;
277 return;
278 }
279 // For everything else — InitialSync, Created, CalibrationAdded,
280 // NameplateUpdated, StatusChanged, Other — read the status
281 // string off the asset payload. A retirement comes through as
282 // StatusChanged with asset.status == "retired"; that lands
283 // here and we record Retired (not Missing) so the caller can
284 // tell "asset deliberately retired" from "no asset ever."
285 let raw_status = update.asset
286 .get("status").and_then(|v| v.as_str()).unwrap_or("");
287 self.status = AssetWatchStatus::from_asset_status_str(raw_status);
288 self.asset_id = if update.asset_id.is_empty() {
289 None
290 } else {
291 Some(update.asset_id.clone())
292 };
293 }
294
295 /// Per-tick drain. Returns every event since the previous call,
296 /// in arrival order. Empty when no events are pending.
297 ///
298 /// The first non-empty return (assuming the `ams.list_assets`
299 /// response has arrived) starts with either `InitialSync` or
300 /// `InitialSyncEmpty`, followed by any broadcasts that landed
301 /// before the response did.
302 pub fn pump(&mut self, client: &mut CommandClient) -> Vec<AssetUpdate> {
303 let mut out: Vec<AssetUpdate> = Vec::new();
304
305 // Initial-sync handling. We only emit the bootstrap event once
306 // and only after the response lands. While it's pending, queued
307 // broadcasts stay in the CommandClient buffer — they're drained
308 // below so the initial-sync event always leads.
309 if let Some(tid) = self.initial_tid {
310 if let Some(resp) = client.take_response(tid) {
311 self.initial_tid = None;
312 self.initial_sent = true;
313 if resp.success {
314 // Response shape: { "assets": [ { ... } ] } from
315 // ams.list_assets. We take the first active match
316 // because location filtering should already have
317 // narrowed it; collisions are a registry bug.
318 let first = resp.data.get("assets")
319 .and_then(|v| v.as_array())
320 .and_then(|arr| arr.first())
321 .cloned();
322 out.push(self.bootstrap_event(first, client));
323 } else {
324 // list_assets failing during initial sync is rare
325 // (the AMS servelet returns empty arrays, not
326 // errors, for unknown locations). Treat as empty
327 // so the control program enters a safe-mode state.
328 out.push(AssetUpdate {
329 trigger: AssetWatchTrigger::InitialSyncEmpty,
330 raw_trigger: "initial_sync_empty".to_string(),
331 location: self.location.clone(),
332 asset_id: String::new(),
333 asset_type: String::new(),
334 asset: Value::Null,
335 current_calibration: Value::Null,
336 });
337 }
338 }
339 }
340
341 // Drain broadcasts that have arrived since the last pump.
342 let topic = format!("ams.asset_updated.{}", self.location);
343 for msg in client.take_broadcasts(&topic) {
344 out.push(AssetUpdate::from_broadcast(msg));
345 }
346 // Fold every event we're about to return into the watcher's
347 // internal status. Bootstrap events have already been folded
348 // inside bootstrap_event, but doing it here too is idempotent
349 // (fold reads only fields that don't change between successive
350 // applications of the same event).
351 for update in &out {
352 self.fold_event(update);
353 }
354 out
355 }
356
357 /// Build the initial-sync event from the `ams.list_assets`
358 /// response. When the response carries an asset row, fetch its
359 /// asset.json + current calibration via the same on-disk flow the
360 /// server uses for live broadcasts so the bootstrap payload has
361 /// the same shape as subsequent events.
362 fn bootstrap_event(&mut self, asset_row: Option<Value>, client: &mut CommandClient) -> AssetUpdate {
363 let Some(row) = asset_row else {
364 return AssetUpdate {
365 trigger: AssetWatchTrigger::InitialSyncEmpty,
366 raw_trigger: "initial_sync_empty".to_string(),
367 location: self.location.clone(),
368 asset_id: String::new(),
369 asset_type: String::new(),
370 asset: Value::Null,
371 current_calibration: Value::Null,
372 };
373 };
374
375 // The list_assets row is a flattened RegistryEntry, not the
376 // full asset.json. For the initial-sync payload we want the
377 // whole record so the control program reads
378 // `asset.custom.capacity` etc. Send a follow-up read_asset.
379 // We don't block on it here — instead the next pump will pick
380 // up that response and the FIRST event is a "thin" InitialSync
381 // carrying just the identity. Most callers want the full record
382 // before they act, so the bootstrap event flags this with a
383 // hint in `asset` being just the registry row.
384 //
385 // Trade-off note: making the bootstrap event hold the
386 // identity-only payload keeps `pump()` fully non-blocking and
387 // avoids a hidden second request. Callers that need the full
388 // record should match on `InitialSync` and follow up with
389 // their own `ams.read_asset` if they want the custom fields
390 // (or wait for the first real broadcast, which carries them).
391 let _ = client; // reserved for future enrichment
392
393 AssetUpdate {
394 trigger: AssetWatchTrigger::InitialSync,
395 raw_trigger: "initial_sync".to_string(),
396 location: self.location.clone(),
397 asset_id: row.get("asset_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
398 asset_type: row.get("asset_type").and_then(|v| v.as_str()).unwrap_or("").to_string(),
399 asset: row,
400 current_calibration: Value::Null,
401 }
402 }
403}
404
405impl AssetUpdate {
406 fn from_broadcast(msg: CommandMessage) -> Self {
407 let raw_trigger = msg.data.get("trigger")
408 .and_then(|v| v.as_str()).unwrap_or("")
409 .to_string();
410 let trigger = AssetWatchTrigger::from_str(&raw_trigger);
411 let asset = msg.data.get("asset").cloned().unwrap_or(Value::Null);
412 let current_calibration = msg.data.get("current_calibration").cloned().unwrap_or(Value::Null);
413 let location = msg.data.get("location").and_then(|v| v.as_str()).unwrap_or("").to_string();
414 let asset_id = msg.data.get("asset_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
415 let asset_type = msg.data.get("asset_type").and_then(|v| v.as_str()).unwrap_or("").to_string();
416 Self {
417 trigger,
418 raw_trigger,
419 location,
420 asset_id,
421 asset_type,
422 asset,
423 current_calibration,
424 }
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use mechutil::ipc::{CommandMessage, MessageType};
432 use serde_json::json;
433 use tokio::sync::mpsc;
434
435 fn fresh_client() -> (
436 CommandClient,
437 mpsc::UnboundedSender<CommandMessage>,
438 mpsc::UnboundedReceiver<String>,
439 ) {
440 let (write_tx, write_rx) = mpsc::unbounded_channel();
441 let (response_tx, response_rx) = mpsc::unbounded_channel();
442 let client = CommandClient::new(write_tx, response_rx);
443 (client, response_tx, write_rx)
444 }
445
446 fn broadcast(topic: &str, data: Value) -> CommandMessage {
447 let mut m = CommandMessage::default();
448 m.topic = topic.to_string();
449 m.message_type = MessageType::Broadcast;
450 m.data = data;
451 m
452 }
453
454 #[test]
455 fn subscription_and_initial_sync_request_are_sent_on_new() {
456 let (mut client, _response_tx, mut write_rx) = fresh_client();
457 let _watch = AssetWatch::new("tsdr_fx", &mut client);
458
459 // One outgoing request: ams.list_assets scoped to the location.
460 let outgoing = write_rx.try_recv().expect("expected list_assets send");
461 let sent: CommandMessage = serde_json::from_str(&outgoing).unwrap();
462 assert_eq!(sent.topic, "ams.list_assets");
463 assert_eq!(sent.data.get("location").and_then(|v| v.as_str()), Some("tsdr_fx"));
464 assert_eq!(sent.data.get("status").and_then(|v| v.as_str()), Some("active"));
465
466 // Topic is subscribed.
467 let mut probe = broadcast("ams.asset_updated.tsdr_fx", json!({ "trigger": "created" }));
468 probe.message_type = MessageType::Broadcast;
469 // Without subscription, poll would drop this; with subscription, it
470 // buffers. Indirectly verifies subscribe was called.
471 let _ = probe;
472 }
473
474 #[test]
475 fn initial_sync_returns_asset_row_when_present() {
476 let (mut client, response_tx, _write_rx) = fresh_client();
477 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
478
479 // Reply to the list_assets request the watcher sent.
480 let tid = watch.initial_tid.unwrap();
481 let mut resp = CommandMessage::default();
482 resp.transaction_id = tid;
483 resp.message_type = MessageType::Response;
484 resp.success = true;
485 resp.data = json!({
486 "assets": [{
487 "asset_id": "LC-20260301T091548",
488 "asset_type": "load_cell",
489 "location": "tsdr_fx",
490 "status": "active",
491 "current_calibration_id": "20260301T091548"
492 }]
493 });
494 response_tx.send(resp).unwrap();
495
496 client.poll();
497 let events = watch.pump(&mut client);
498 assert_eq!(events.len(), 1);
499 assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSync);
500 assert_eq!(events[0].asset_id, "LC-20260301T091548");
501 assert_eq!(events[0].asset_type, "load_cell");
502 assert_eq!(events[0].location, "tsdr_fx");
503 assert!(watch.initialised());
504 }
505
506 #[test]
507 fn initial_sync_empty_when_no_asset_at_location() {
508 let (mut client, response_tx, _write_rx) = fresh_client();
509 let mut watch = AssetWatch::new("nope", &mut client);
510
511 let tid = watch.initial_tid.unwrap();
512 let mut resp = CommandMessage::default();
513 resp.transaction_id = tid;
514 resp.message_type = MessageType::Response;
515 resp.success = true;
516 resp.data = json!({ "assets": [] });
517 response_tx.send(resp).unwrap();
518
519 client.poll();
520 let events = watch.pump(&mut client);
521 assert_eq!(events.len(), 1);
522 assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSyncEmpty);
523 assert_eq!(events[0].asset, Value::Null);
524 assert!(watch.initialised());
525 }
526
527 #[test]
528 fn broadcasts_parse_trigger_and_payload() {
529 let (mut client, response_tx, _write_rx) = fresh_client();
530 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
531
532 // Resolve the initial sync so it's out of the way.
533 let tid = watch.initial_tid.unwrap();
534 let mut resp = CommandMessage::default();
535 resp.transaction_id = tid;
536 resp.message_type = MessageType::Response;
537 resp.success = true;
538 resp.data = json!({ "assets": [] });
539 response_tx.send(resp).unwrap();
540 client.poll();
541 let _ = watch.pump(&mut client);
542
543 // Now a recal broadcast lands.
544 response_tx.send(broadcast(
545 "ams.asset_updated.tsdr_fx",
546 json!({
547 "trigger": "calibration_added",
548 "location": "tsdr_fx",
549 "asset_id": "LC-1",
550 "asset_type":"load_cell",
551 "asset": { "custom": { "capacity": 5000.0 } },
552 "current_calibration": {
553 "cal_id": "20260301T091548",
554 "values": { "scale": 9.81234, "offset": -0.0042 }
555 }
556 }),
557 )).unwrap();
558 client.poll();
559
560 let events = watch.pump(&mut client);
561 assert_eq!(events.len(), 1);
562 assert_eq!(events[0].trigger, AssetWatchTrigger::CalibrationAdded);
563 assert_eq!(events[0].raw_trigger, "calibration_added");
564 assert_eq!(
565 events[0].current_calibration.pointer("/values/scale").and_then(|v| v.as_f64()),
566 Some(9.81234),
567 );
568 // Capacity from the asset payload survives so the control
569 // program can rewrite SDO 0x8000:11 without a follow-up read.
570 assert_eq!(
571 events[0].asset.pointer("/custom/capacity").and_then(|v| v.as_f64()),
572 Some(5000.0),
573 );
574 }
575
576 #[test]
577 fn broadcasts_for_other_locations_are_ignored() {
578 let (mut client, response_tx, _write_rx) = fresh_client();
579 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
580
581 let tid = watch.initial_tid.unwrap();
582 let mut resp = CommandMessage::default();
583 resp.transaction_id = tid;
584 resp.message_type = MessageType::Response;
585 resp.success = true;
586 resp.data = json!({ "assets": [] });
587 response_tx.send(resp).unwrap();
588
589 // Wrong-location broadcast — different topic; CommandClient
590 // never buffers it because that topic isn't subscribed.
591 response_tx.send(broadcast(
592 "ams.asset_updated.tsdr_fy",
593 json!({ "trigger": "calibration_added" }),
594 )).unwrap();
595 client.poll();
596
597 let events = watch.pump(&mut client);
598 assert_eq!(events.len(), 1, "only the initial-sync event should land");
599 assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSyncEmpty);
600 }
601
602 #[test]
603 fn is_active_starts_false_and_flips_after_initial_sync_with_active_asset() {
604 let (mut client, response_tx, _write_rx) = fresh_client();
605 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
606 // Before pump: nothing observed.
607 assert!(!watch.is_active());
608 assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
609 assert!(watch.active_asset_id().is_none());
610
611 let tid = watch.initial_tid.unwrap();
612 let mut resp = CommandMessage::default();
613 resp.transaction_id = tid;
614 resp.message_type = MessageType::Response;
615 resp.success = true;
616 resp.data = json!({
617 "assets": [{
618 "asset_id": "LC-NEW",
619 "asset_type": "load_cell",
620 "location": "tsdr_fx",
621 "status": "active",
622 }]
623 });
624 response_tx.send(resp).unwrap();
625
626 client.poll();
627 let _ = watch.pump(&mut client);
628 assert!(watch.is_active(), "active asset → is_active() should be true");
629 assert_eq!(watch.active_asset_id(), Some("LC-NEW"));
630 assert_eq!(watch.active_status(), AssetWatchStatus::Active);
631 }
632
633 #[test]
634 fn is_active_stays_false_after_initial_sync_empty() {
635 let (mut client, response_tx, _write_rx) = fresh_client();
636 let mut watch = AssetWatch::new("nope", &mut client);
637
638 let tid = watch.initial_tid.unwrap();
639 let mut resp = CommandMessage::default();
640 resp.transaction_id = tid;
641 resp.message_type = MessageType::Response;
642 resp.success = true;
643 resp.data = json!({ "assets": [] });
644 response_tx.send(resp).unwrap();
645 client.poll();
646 let _ = watch.pump(&mut client);
647
648 assert!(!watch.is_active());
649 assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
650 assert!(watch.active_asset_id().is_none());
651 }
652
653 #[test]
654 fn retire_broadcast_flips_is_active_to_false() {
655 // Boot with an active asset, then receive a status_changed
656 // → retired broadcast. The watcher's state reflects the
657 // retirement; the control program will refuse to enter auto.
658 let (mut client, response_tx, _write_rx) = fresh_client();
659 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
660
661 let tid = watch.initial_tid.unwrap();
662 let mut resp = CommandMessage::default();
663 resp.transaction_id = tid;
664 resp.message_type = MessageType::Response;
665 resp.success = true;
666 resp.data = json!({ "assets": [{
667 "asset_id": "LC-1", "asset_type": "load_cell",
668 "location": "tsdr_fx", "status": "active",
669 }] });
670 response_tx.send(resp).unwrap();
671 client.poll();
672 let _ = watch.pump(&mut client);
673 assert!(watch.is_active());
674
675 // Now retire.
676 response_tx.send(broadcast(
677 "ams.asset_updated.tsdr_fx",
678 json!({
679 "trigger": "status_changed",
680 "location": "tsdr_fx",
681 "asset_id": "LC-1",
682 "asset_type": "load_cell",
683 "asset": { "asset_id": "LC-1", "status": "retired" },
684 }),
685 )).unwrap();
686 client.poll();
687 let _ = watch.pump(&mut client);
688 assert!(!watch.is_active(),
689 "after retirement is_active should be false");
690 assert_eq!(watch.active_status(), AssetWatchStatus::Retired);
691 // Asset is still _identified_ — operator may want to log "the
692 // retired cell was LC-1." It's just no longer suitable for
693 // testing.
694 assert_eq!(watch.active_asset_id(), Some("LC-1"));
695 }
696
697 #[test]
698 fn location_changed_event_flips_is_active_to_missing() {
699 let (mut client, response_tx, _write_rx) = fresh_client();
700 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
701
702 // Boot active.
703 let tid = watch.initial_tid.unwrap();
704 let mut resp = CommandMessage::default();
705 resp.transaction_id = tid;
706 resp.message_type = MessageType::Response;
707 resp.success = true;
708 resp.data = json!({ "assets": [{
709 "asset_id": "LC-1", "asset_type": "load_cell",
710 "location": "tsdr_fx", "status": "active",
711 }] });
712 response_tx.send(resp).unwrap();
713 client.poll();
714 let _ = watch.pump(&mut client);
715 assert!(watch.is_active());
716
717 // Asset relocated away — server publishes the ghost departure
718 // at the old location (this one). Treat as Missing locally
719 // because the asset is no longer here, even though it's alive
720 // somewhere else.
721 response_tx.send(broadcast(
722 "ams.asset_updated.tsdr_fx",
723 json!({
724 "trigger": "location_changed",
725 "location": "tsdr_fx",
726 "asset_id": "LC-1",
727 "asset_type": "load_cell",
728 "asset": { "asset_id": "LC-1", "status": "retired" }
729 }),
730 )).unwrap();
731 client.poll();
732 let _ = watch.pump(&mut client);
733 assert!(!watch.is_active());
734 assert_eq!(watch.active_status(), AssetWatchStatus::Missing);
735 assert!(watch.active_asset_id().is_none());
736 }
737
738 #[test]
739 fn broadcasts_queued_before_initial_sync_response_arrive_after_it() {
740 // Subtle: a broadcast can land while the list_assets response
741 // is still in flight. The bootstrap event still appears first
742 // when pump() runs.
743 let (mut client, response_tx, _write_rx) = fresh_client();
744 let mut watch = AssetWatch::new("tsdr_fx", &mut client);
745
746 // Broadcast arrives first.
747 response_tx.send(broadcast(
748 "ams.asset_updated.tsdr_fx",
749 json!({ "trigger": "calibration_added" }),
750 )).unwrap();
751 // Then the list_assets response.
752 let tid = watch.initial_tid.unwrap();
753 let mut resp = CommandMessage::default();
754 resp.transaction_id = tid;
755 resp.message_type = MessageType::Response;
756 resp.success = true;
757 resp.data = json!({ "assets": [{
758 "asset_id": "LC-1", "asset_type": "load_cell",
759 "location": "tsdr_fx", "status": "active"
760 }] });
761 response_tx.send(resp).unwrap();
762 client.poll();
763
764 let events = watch.pump(&mut client);
765 assert_eq!(events.len(), 2);
766 assert_eq!(events[0].trigger, AssetWatchTrigger::InitialSync);
767 assert_eq!(events[1].trigger, AssetWatchTrigger::CalibrationAdded);
768 }
769}