Skip to main content

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}