Skip to main content

autocore_std/fb/beckhoff/
el3356.rs

1//! Function block for the Beckhoff EL3356 strain-gauge EtherCAT terminal.
2//!
3//! # Responsibilities
4//!
5//! - **Peak tracking**: maintains `peak_load` as the largest-magnitude `load`
6//!   value seen since construction or the last [`reset_peak`](El3356::reset_peak) /
7//!   [`tare`](El3356::tare) call.
8//! - **Tare pulse**: [`tare`](El3356::tare) sets the device's tare bit and
9//!   automatically clears it after 100 ms. Also resets the peak.
10//! - **Load cell configuration via SDO**: [`configure`](El3356::configure)
11//!   sequences three SDO writes to `0x8000`:
12//!     - sub `0x23` — sensitivity (mV/V)
13//!     - sub `0x24` — full-scale load
14//!     - sub `0x27` — scale factor
15//!
16//! # Wiring in project.json
17//!
18//! The FB is device-agnostic at construction; the EtherCAT device name is
19//! passed to [`new`](El3356::new) and used for SDO topic routing. The five
20//! PDO-linked GM variables (`{prefix}_load`, `_load_steady`, `_load_error`,
21//! `_load_overrange`, `_tare`) must exist and be linked to the terminal's
22//! corresponding FQDNs in `project.json`.
23//!
24//! # Example
25//!
26//! ```ignore
27//! use autocore_std::fb::beckhoff::El3356;
28//! use autocore_std::el3356_view;
29//!
30//! pub struct MyProgram {
31//!     load_cell: El3356,
32//!     manual_tare_edge: autocore_std::fb::RTrig,
33//! }
34//!
35//! impl MyProgram {
36//!     pub fn new() -> Self {
37//!         Self {
38//!             load_cell: El3356::new("EL3356_0"),
39//!             manual_tare_edge: Default::default(),
40//!         }
41//!     }
42//! }
43//!
44//! impl ControlProgram for MyProgram {
45//!     type Memory = GlobalMemory;
46//!
47//!     fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
48//!         // Edge-detect a manual tare button from an HMI
49//!         if self.manual_tare_edge.call(ctx.gm.manual_tare) {
50//!             self.load_cell.tare();
51//!         }
52//!
53//!         let mut view = el3356_view!(ctx.gm, impact);
54//!         self.load_cell.tick(&mut view, ctx.client);
55//!
56//!         // Peak load is exposed for display / logging
57//!         ctx.gm.impact_peak_load = self.load_cell.peak_load;
58//!     }
59//! }
60//! ```
61//! # Regarding Filtering on the EL3356
62//! 
63//! To start, Beckhoff implements an averager as a hardware quadruple averager (a 4-sample moving average filter). 
64//! Because this averager operates directly on the internal, high-speed conversion clock of the ADC rather than 
65//! the slower EtherCAT cycle time, its effect on latency is incredibly small.
66//! 
67//! A moving average delays a signal by roughly (N−1)/2 sample periods. Based on the terminal's 
68//! internal hardware sampling rates:
69//!     Mode 0 (10.5 kSps internal rate): The 4-sample averager adds roughly 0.14 ms of latency.
70//!     Mode 1 (105.5 kSps internal rate): The 4-sample averager adds roughly 0.014 ms of latency.
71//! When you compare this to the latencies of the software IIR filters (which range from 0.3 ms up to 3600 ms), 
72//! turning the averager on is practically "free" in terms of time penalty.
73//! Why Use Both a Filter and an Averager?
74//! It comes down to the order of operations in signal processing and the different types of noise each tool is 
75//! designed to eliminate. The signal chain in the EL3356 runs like this:
76//! Raw ADC → Hardware Averager → Software Filter (FIR/IIR) → Process Data Object (PDO)
77//! You use them together because they tackle entirely different problems:
78//! 1. Targeting Different Noise Profiles
79//!     The Averager: A moving average is the optimal tool for eliminating high-frequency, random Gaussian 
80//! "white noise" caused by electrical interference in your sensor wires or the internal electronics. 
81//! It smooths out the "fuzz."
82//!     The Software Filter: FIR and IIR filters are designed to eliminate specific, lower-frequency phenomena. 
83//! An FIR notch filter kills 50/60 Hz AC mains hum. An IIR low-pass filter damps mechanical vibrations 
84//! (like the swinging of a hopper or the physical ringing of a force plate after a sudden impact).
85//! 2. Protecting the IIR Filter
86//! IIR (Infinite Impulse Response) filters are highly sensitive to sudden, sharp spikes in data. 
87//! If a random spike of electrical noise hits an IIR filter, the filter's math causes that spike to "ring" 
88//! and decay slowly (an exponential tail), which subtly skews your weight value. By running the hardware averager 
89//! first, you clip off those random electrical spikes before they ever reach the sensitive math of the IIR filter.
90//! 3. Achieving Lower Overall Latency
91//! Because the averager cleans up the baseline signal without distorting the shape of your step response (it maintains a linear phase), 
92//! it feeds a much cleaner baseline into the software filter stage. 
93//! This often allows you to get away with using a weaker, faster IIR filter (e.g., using IIR3 instead of IIR5) to handle 
94//! your mechanical vibrations, ultimately saving you tens or hundreds of milliseconds of total system latency.
95//! 
96
97
98use crate::ethercat::{SdoClient, SdoResult};
99use crate::CommandClient;
100use serde_json::json;
101use strum_macros::FromRepr;
102use std::time::{Duration, Instant};
103
104use super::El3356View;
105
106/// Duration of the tare pulse. Matches the `T#100MS` preset in the original
107/// TwinCAT implementation.
108const TARE_PULSE: Duration = Duration::from_millis(100);
109
110/// Per-SDO timeout. SDO transfers against a functioning EL3356 complete in
111/// tens of milliseconds; a multi-second ceiling is generous.
112const SDO_TIMEOUT: Duration = Duration::from_secs(3);
113
114// SDO sub-indices on object 0x8000.
115const SDO_IDX: u16 = 0x8000;
116const SUB_MV_V: u8 = 0x23;
117const SUB_FULL_SCALE: u8 = 0x24;
118const SUB_SCALE_FACTOR: u8 = 0x27;
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum State {
122    Idle,
123    WritingMvV,
124    WritingFullScale,
125    WritingScaleFactor,
126    WaitWriteGeneralSdo,
127    ReadingMvV,
128    ReadingFullScale,
129    ReadingScaleFactor,
130    WaitReadGeneralSdo
131}
132
133
134/// Digital software filter settings for the Beckhoff EL3356/EP3356 load cell terminal.
135/// The selected filter processes the ADC data before it is output as a process value.
136/// Factory default for an EL3356 is FIR 50Hz
137#[repr(u16)]
138#[derive(Copy, Clone, Debug, FromRepr)]
139pub enum El3356Filters {
140    /// 50 Hz FIR (Finite Impulse Response) notch filter.
141    /// Acts as a non-recursive filter that suppresses 50 Hz mains frequency interference 
142    /// and its multiples. Select this in environments with 50 Hz AC power to eliminate electrical noise.
143    /// 
144    /// **Estimated Latency (10-90% step response):** ~13 ms.
145    FIR50Hz = 0,
146
147    /// 60 Hz FIR (Finite Impulse Response) notch filter.
148    /// Acts as a non-recursive filter that suppresses 60 Hz mains frequency interference 
149    /// and its multiples. Select this in environments with 60 Hz AC power to eliminate electrical noise.
150    /// 
151    /// **Estimated Latency (10-90% step response):** ~16 ms.
152    FIR60Hz = 1,
153
154    /// Weakest IIR (Infinite Impulse Response) low-pass filter (Level 1).
155    /// Cutoff frequency of roughly 2000 Hz. IIR filters run cycle-synchronously.
156    /// Select this when you need a very fast step response (highly dynamic measurement) 
157    /// and only minimal signal smoothing is required.
158    /// 
159    /// **Estimated Latency (10-90% step response):** ~0.3 ms.
160    IIR1 = 2,
161
162    /// IIR low-pass filter (Level 2).
163    /// Cutoff frequency of roughly 500 Hz. Provides very light smoothing.
164    /// 
165    /// **Estimated Latency (10-90% step response):** ~0.8 ms.
166    IIR2 = 3,
167
168    /// IIR low-pass filter (Level 3).
169    /// Cutoff frequency of roughly 125 Hz. Suitable for fast machinery tracking.
170    /// 
171    /// **Estimated Latency (10-90% step response):** ~3.5 ms.
172    IIR3 = 4,
173
174    /// IIR low-pass filter (Level 4).
175    /// Cutoff frequency of roughly 30 Hz. A good baseline for moderate mechanical vibrations.
176    /// 
177    /// **Estimated Latency (10-90% step response):** ~14 ms.
178    IIR4 = 5,
179
180    /// IIR low-pass filter (Level 5).
181    /// Cutoff frequency of roughly 8 Hz. Stronger smoothing for slower processes.
182    /// 
183    /// **Estimated Latency (10-90% step response):** ~56 ms.
184    IIR5 = 6,
185
186    /// IIR low-pass filter (Level 6).
187    /// Cutoff frequency of roughly 2 Hz. Heavy smoothing for mostly static loads.
188    /// 
189    /// **Estimated Latency (10-90% step response):** ~225 ms.
190    IIR6 = 7,
191
192    /// IIR low-pass filter (Level 7).
193    /// Cutoff frequency of roughly 0.5 Hz. Very heavy smoothing.
194    /// 
195    /// **Estimated Latency (10-90% step response):** ~900 ms.
196    IIR7 = 8,
197
198    /// Strongest IIR low-pass filter (Level 8).
199    /// Cutoff frequency of roughly 0.1 Hz. Select this for highly static measurements 
200    /// where maximum damping is needed to obtain a completely calm, stable weight/signal value.
201    /// 
202    /// **Estimated Latency (10-90% step response):** ~3600 ms.
203    IIR8 = 9,
204
205    /// Dynamically adjusts between IIR1 and IIR8 based on the rate of signal change.
206    /// When the input variable changes rapidly, the filter automatically "opens" (e.g., switches 
207    /// toward IIR1) to track the load quickly. When the signal stabilizes, it "closes" (towards IIR8) 
208    /// to provide maximum damping and high accuracy for static states. Select this for applications 
209    /// like dosing or filling where you need both fast tracking and high precision.
210    /// 
211    /// **Estimated Latency:** Variable, sweeping dynamically from ~0.3 ms up to ~3600 ms.
212    DynamicIIR = 10,
213
214    /// Variable FIR notch filter adjusted dynamically via Process Data Objects (PDO).
215    /// Allows the notch filter frequency to be set in 0.1 Hz increments during operation 
216    /// (from 0.1 Hz to 200 Hz) via an output data object. Select this to suppress mechanical 
217    /// vibrations or interference of a known, potentially variable frequency (e.g., compensating 
218    /// for oscillations from a driven screw conveyor where the rotational speed is known).
219    /// 
220    /// **Estimated Latency:** Variable, inherently dependent on the specific frequency dialed into the PDO.
221    PDOFilterFrequency = 11
222}
223
224/// Function block for the Beckhoff EL3356 strain-gauge terminal.
225pub struct El3356 {
226    // Configuration
227    sdo: SdoClient,
228
229    // Outputs — read by the control program each tick
230    /// Largest absolute load seen since construction, last reset, or last tare.
231    pub peak_load: f32,
232    /// True while a configuration read or write sequence is in progress.
233    pub busy: bool,
234    /// True after an SDO operation failed. Clear with [`clear_error`](El3356::clear_error).
235    pub error: bool,
236    /// Last error message, if any.
237    pub error_message: String,
238    /// Current sensitivity (mV/V) value on the device. Populated by the last
239    /// successful [`configure`](Self::configure) or
240    /// [`read_configuration`](Self::read_configuration). `None` until one of
241    /// those completes. Reset to `None` at the start of each call.
242    pub configured_mv_v: Option<f32>,
243    /// Current full-scale load value on the device. Populated by the last
244    /// successful [`configure`](Self::configure) or
245    /// [`read_configuration`](Self::read_configuration). `None` until one
246    /// completes. Reset to `None` at the start of each call.
247    pub configured_full_scale_load: Option<f32>,
248    /// Current scale factor on the device. Populated by the last successful
249    /// [`configure`](Self::configure) or
250    /// [`read_configuration`](Self::read_configuration). `None` until one
251    /// completes. Reset to `None` at the start of each call.
252    pub configured_scale_factor: Option<f32>,
253
254    // Internal state
255    state: State,
256    pending_tid: Option<u32>,
257    /// Values buffered for the current configure() sequence.
258    pending_full_scale: f32,
259    pending_mv_v: f32,
260    pending_scale_factor: f32,
261    /// When set, `tick()` will hold `view.tare = true` until this moment is
262    /// reached, at which point it clears the tare bit.
263    tare_release_at: Option<Instant>,
264
265
266    /// Resulting value of a general SDO read.
267    sdo_res_value : serde_json::Value
268}
269
270/// Parse a REAL32 value from an `ethercat.read_sdo` response.
271///
272/// The autocore-ethercat module reports the 4 raw bytes as a `u32` under the
273/// `value` field (little-endian assembly). The EL3356 stores its calibration
274/// parameters as IEEE-754 REAL32, so we reinterpret those bits as `f32` via
275/// `f32::from_bits`. As a fallback (e.g. future module revisions that return
276/// a numeric `value`), we also accept a direct `f64` and coerce.
277fn parse_sdo_real32(data: &serde_json::Value) -> Option<f32> {
278    let v = data.get("value")?;
279    if let Some(bits) = v.as_u64() {
280        // Canonical path: raw 4-byte u32 reinterpret as f32.
281        return Some(f32::from_bits(bits as u32));
282    }
283    if let Some(f) = v.as_f64() {
284        return Some(f as f32);
285    }
286    None
287}
288
289impl El3356 {
290    /// Create a new EL3356 function block for the given EtherCAT device name.
291    ///
292    /// `device` must match the name used in `project.json`'s ethercat device
293    /// list (it's the `device` field sent with every SDO request).
294    pub fn new(device: &str) -> Self {
295        Self {
296            sdo: SdoClient::new(device),
297            peak_load: 0.0,
298            busy: false,
299            error: false,
300            error_message: String::new(),
301            configured_mv_v: None,
302            configured_full_scale_load: None,
303            configured_scale_factor: None,
304            state: State::Idle,
305            pending_tid: None,
306            pending_full_scale: 0.0,
307            pending_mv_v: 0.0,
308            pending_scale_factor: 0.0,
309            tare_release_at: None,
310            sdo_res_value : serde_json::Value::Null
311        }
312    }
313
314    /// Call every control cycle.
315    ///
316    /// Performs three things, in order:
317    /// 1. Updates `peak_load` from `*view.load`.
318    /// 2. Releases the tare pulse after 100 ms.
319    /// 3. Progresses any in-flight SDO operation from [`configure`](Self::configure).
320    pub fn tick(&mut self, view: &mut El3356View, client: &mut CommandClient) {
321        // 1. Peak tracking
322        let abs_load = view.load.abs();
323        if abs_load > self.peak_load.abs() {
324            self.peak_load = *view.load;
325        }
326
327        // 2. Tare pulse release
328        if let Some(release_at) = self.tare_release_at {
329            if Instant::now() >= release_at {
330                *view.tare = false;
331                self.tare_release_at = None;
332            } else {
333                *view.tare = true;
334            }
335        }
336
337        // 3. SDO sequence
338        self.progress_sdo(client);
339    }
340
341    /// Begin an SDO configuration sequence: sensitivity (mV/V), full-scale
342    /// load, and scale factor written to object `0x8000` subs `0x23`, `0x24`,
343    /// and `0x27` respectively. Non-blocking — sets `busy = true` and returns
344    /// immediately. Poll `busy` / `error` on subsequent ticks.
345    ///
346    /// No-op (logs a warning) if the FB is already `busy`. Any existing error
347    /// flag is cleared at the start of a new sequence.
348    pub fn configure(
349        &mut self,
350        client: &mut CommandClient,
351        full_scale_load: f32,
352        sensitivity_mv_v: f32,
353        scale_factor: f32,
354    ) {
355        if self.busy {
356            log::warn!("El3356::configure called while busy; request ignored");
357            return;
358        }
359        self.error = false;
360        self.error_message.clear();
361        self.pending_full_scale = full_scale_load;
362        self.pending_mv_v = sensitivity_mv_v;
363        self.pending_scale_factor = scale_factor;
364
365        // Start with mV/V (sub 0x23)
366        let tid = self.sdo.write(client, SDO_IDX, SUB_MV_V, json!(sensitivity_mv_v));
367        self.pending_tid = Some(tid);
368        self.state = State::WritingMvV;
369        self.busy = true;
370    }
371
372    /// Begin an SDO read sequence that fetches the three calibration
373    /// parameters from the terminal's non-volatile memory.
374    ///
375    /// The EL3356 stores sensitivity, full-scale load, and scale factor
376    /// persistently, so the card may power up with values from a previous
377    /// configuration — not necessarily what the current control program
378    /// expects. Call `read_configuration` at startup (or whenever you need
379    /// to verify the sensor parameters) to populate the `configured_*`
380    /// fields with the device's current values.
381    ///
382    /// Non-blocking: sets `busy = true` and returns immediately. Poll `busy`
383    /// / `error` on subsequent ticks. No-op (logs a warning) if already busy.
384    /// Clears `error` and resets all three `configured_*` fields to `None`
385    /// at the start so intermediate values aren't mistaken for final ones.
386    pub fn read_configuration(&mut self, client: &mut CommandClient) {
387        if self.busy {
388            log::warn!("El3356::read_configuration called while busy; request ignored");
389            return;
390        }
391        self.error = false;
392        self.error_message.clear();
393        self.configured_mv_v = None;
394        self.configured_full_scale_load = None;
395        self.configured_scale_factor = None;
396
397        let tid = self.sdo.read(client, SDO_IDX, SUB_MV_V);
398        self.pending_tid = Some(tid);
399        self.state = State::ReadingMvV;
400        self.busy = true;
401    }
402
403    /// Reset `peak_load` to `0.0`. Immediate; no IPC.
404    pub fn reset_peak(&mut self) {
405        self.peak_load = 0.0;
406    }
407
408    /// Pulse the tare bit high for 100 ms, and reset the peak.
409    ///
410    /// The device-side bit (`view.tare`) is actually written by [`tick`](Self::tick),
411    /// so `tick` must be called every cycle. If `tare` is called while a
412    /// previous pulse is still in progress, the 100 ms window restarts.
413    pub fn tare(&mut self) {
414        self.peak_load = 0.0;
415        self.tare_release_at = Some(Instant::now() + TARE_PULSE);
416    }
417
418    /// Clear the error flag and message.
419    pub fn clear_error(&mut self) {
420        self.error = false;
421        self.error_message.clear();
422    }
423
424    /// Write an arbitrary SDO. Non-blocking; sets `busy = true` and tracks
425    /// the response through the FB's state machine like the other operations.
426    ///
427    /// Does **not** touch the `configured_*` calibration fields — generic
428    /// writes are orthogonal to the calibration cycle managed by
429    /// [`configure`](Self::configure) and [`read_configuration`](Self::read_configuration).
430    pub fn sdo_write(
431        &mut self,
432        client: &mut CommandClient,
433        index: u16,
434        sub_index: u8,
435        value: serde_json::Value,
436    ) {
437        if self.busy {
438            log::warn!("El3356::sdo_write called while busy; request ignored");
439            return;
440        }
441
442        self.error = false;
443        self.error_message.clear();
444
445        let tid = self.sdo.write(client, index, sub_index, value);
446        self.pending_tid = Some(tid);
447        self.state = State::WaitWriteGeneralSdo;
448        self.busy = true;
449    }
450
451    /// Read an arbitrary SDO. Non-blocking; the response lands in the
452    /// internal result buffer, retrievable via [`result`](Self::result),
453    /// [`result_as_f64`](Self::result_as_f64),
454    /// [`result_as_i64`](Self::result_as_i64), or
455    /// [`result_as_f32`](Self::result_as_f32) once `busy` clears.
456    ///
457    /// Does **not** touch the `configured_*` calibration fields — generic
458    /// reads are orthogonal to the calibration cycle managed by
459    /// [`configure`](Self::configure) and [`read_configuration`](Self::read_configuration).
460    pub fn sdo_read(
461        &mut self,
462        client: &mut CommandClient,
463        index: u16,
464        sub_index: u8,
465    ) {
466        if self.busy {
467            log::warn!("El3356::sdo_read called while busy; request ignored");
468            return;
469        }
470
471        self.error = false;
472        self.error_message.clear();
473
474        let tid = self.sdo.read(client, index, sub_index);
475        self.pending_tid = Some(tid);
476        self.state = State::WaitReadGeneralSdo;
477        self.busy = true;
478    }
479
480    /// Enable or disable the filter on Mode 0, which is the default, slower mode of the bridge input ADC.
481    /// Factory default is TRUE.
482    /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
483    pub fn set_mode0_filter_enabled(&mut self, client: &mut CommandClient, enable : bool) {
484        if self.busy {
485            log::warn!("El3356::set_mode0_filter_enabled called while busy; request ignored");
486            return;
487        }
488
489        self.sdo_write(client, 0x8000, 0x01, json!(enable));
490    }
491
492    /// Enable or disable the averager on Mode 0, which is the default, slower mode of the bridge input ADC.
493    /// The averager is low-latency and should usually be left on.
494    /// Mode 0 (10.5 kSps internal rate): The 4-sample averager adds roughly 0.14 ms of latency.
495    /// Factory default is TRUE.
496    /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
497    pub fn set_mode0_averager_enabled(&mut self, client: &mut CommandClient, enable : bool) {
498        if self.busy {
499            log::warn!("El3356::set_mode0_averager_enabled called while busy; request ignored");
500            return;
501        }
502
503        self.sdo_write(client, 0x8000, 0x03, json!(enable));
504    }    
505
506    /// Set the Mode 1 filter (CoE 0x8000:11). 
507    /// Mode 0, the default mode, is High Precision. 
508    /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
509    /// The ADC runs slower (yielding a hardware latency of around 7.2 ms) but delivers very high accuracy and low noise.     
510    /// Mode 0 is typically used paired with a stronger IIR filter—for highly accurate, static weighing where a completely calm value is required.
511    pub fn set_mode0_filter(&mut self, client: &mut CommandClient, filter : El3356Filters) {
512        if self.busy {
513            log::warn!("El3356::set_mode0_filter called while busy; request ignored");
514            return;
515        }
516
517        self.sdo_write(client, 0x8000, 0x11, json!(filter as u16));
518    }
519
520
521    /// Enable or disable the filter on Mode 1, the faster mode of the bridge input ADC.
522    /// Factory default is TRUE.
523    /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
524    pub fn set_mode1_filter_enabled(&mut self, client: &mut CommandClient, enable : bool) {
525        if self.busy {
526            log::warn!("El3356::set_mode1_filter_enabled called while busy; request ignored");
527            return;
528        }
529
530        self.sdo_write(client, 0x8000, 0x02, json!(enable));
531    }
532
533    /// Enable or disable the averager on Mode 1, the faster mode of the bridge input ADC.
534    /// The averager is low-latency and should usually be left on.
535    /// Mode 1 (105.5 kSps internal rate): The 4-sample averager adds roughly 0.014 ms of latency.
536    /// Factory default is TRUE.
537    /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
538    pub fn set_mode1_averager_enabled(&mut self, client: &mut CommandClient, enable : bool) {
539        if self.busy {
540            log::warn!("El3356::set_mode1_averager_enabled called while busy; request ignored");
541            return;
542        }
543
544        self.sdo_write(client, 0x8000, 0x05, json!(enable));
545    }
546
547    /// Set the Mode 1 filter (CoE 0x8000:12).
548    /// Mode 1 sacrifices a bit of accuracy for speed: the ADC runs much faster
549    /// (yielding a hardware latency around 0.72 ms).
550    /// Mode 1 is typically paired with a very weak filter (like IIR1 or disabled entirely)
551    /// to track fast transients — for example, capturing a rapid impact or
552    /// tracking a high-speed dosing cycle.
553    /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
554    pub fn set_mode1_filter(&mut self, client: &mut CommandClient, filter : El3356Filters) {
555        if self.busy {
556            log::warn!("El3356::set_mode1_filter called while busy; request ignored");
557            return;
558        }
559
560        self.sdo_write(client, 0x8000, 0x12, json!(filter as u16));
561    }
562
563
564
565    /// The FB encountered an error processing the last command.
566    pub fn is_error(&self) -> bool {
567        return self.error;
568    }
569
570    /// The FB is busy processing a command.
571    pub fn is_busy(&self) -> bool {
572        return self.busy;
573    }
574
575    /// Full reset: drop all transient state so the FB behaves as if
576    /// freshly constructed (except for the cached SDO device name).
577    ///
578    /// Clears the error flag, cancels any in-flight SDO operation, releases
579    /// the tare bit on the next [`tick`](Self::tick), and discards the last
580    /// `sdo_read` result. Does **not** zero `peak_load` — call
581    /// [`reset_peak`](Self::reset_peak) or [`tare`](Self::tare) if you need
582    /// that — and does **not** clear `configured_*`; those stay populated
583    /// from the last [`configure`](Self::configure) or
584    /// [`read_configuration`](Self::read_configuration) so the program's
585    /// view of the device's calibration survives a mid-op abort.
586    pub fn reset(&mut self) {
587        self.error = false;
588        self.error_message.clear();
589        self.pending_tid = None;
590        self.state = State::Idle;
591        self.busy = false;
592        self.tare_release_at = None;
593        self.sdo_res_value = serde_json::Value::Null;
594    }
595
596
597    /// Get the full response from the most recent [`sdo_read`](Self::sdo_read).
598    ///
599    /// The returned value is the complete `ethercat.read_sdo` payload — an
600    /// object with `value`, `value_hex`, `size`, `raw_bytes`, etc. Prefer the
601    /// typed accessors ([`result_as_f64`](Self::result_as_f64),
602    /// [`result_as_i64`](Self::result_as_i64),
603    /// [`result_as_f32`](Self::result_as_f32)) for scalar register reads.
604    pub fn result(&self) -> serde_json::Value {
605        self.sdo_res_value.clone()
606    }
607
608    /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
609    /// as an `f64`, if the SDO returned a number. Returns `None` if no read
610    /// has completed, the response had no `value` field, or the field is not
611    /// coercible to `f64`.
612    pub fn result_as_f64(&self) -> Option<f64> {
613        self.sdo_res_value.get("value").and_then(|v| v.as_f64())
614    }
615
616    /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
617    /// as an `i64`. See [`result_as_f64`](Self::result_as_f64) for `None`
618    /// semantics.
619    pub fn result_as_i64(&self) -> Option<i64> {
620        self.sdo_res_value.get("value").and_then(|v| v.as_i64())
621    }
622
623    /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
624    /// as an `f32`, interpreting the returned `u32` bit pattern as an
625    /// IEEE-754 single-precision float. This is the correct accessor for
626    /// REAL32 SDOs (e.g. the EL3356's calibration parameters).
627    pub fn result_as_f32(&self) -> Option<f32> {
628        parse_sdo_real32(&self.sdo_res_value)
629    }
630
631
632    // ──────────────────────────────────────────────────────────────
633    // Internal helpers
634    // ──────────────────────────────────────────────────────────────
635
636    fn progress_sdo(&mut self, client: &mut CommandClient) {
637        let tid = match self.pending_tid {
638            Some(t) => t,
639            None => return,
640        };
641
642        let result = self.sdo.result(client, tid, SDO_TIMEOUT);
643        match result {
644            SdoResult::Pending => {} // keep waiting
645            SdoResult::Ok(data) => match self.state {
646                State::WritingMvV => {
647                    self.configured_mv_v = Some(self.pending_mv_v);
648                    let next_tid = self.sdo.write(
649                        client, SDO_IDX, SUB_FULL_SCALE, json!(self.pending_full_scale),
650                    );
651                    self.pending_tid = Some(next_tid);
652                    self.state = State::WritingFullScale;
653                }
654                State::WritingFullScale => {
655                    self.configured_full_scale_load = Some(self.pending_full_scale);
656                    let next_tid = self.sdo.write(
657                        client, SDO_IDX, SUB_SCALE_FACTOR, json!(self.pending_scale_factor),
658                    );
659                    self.pending_tid = Some(next_tid);
660                    self.state = State::WritingScaleFactor;
661                }
662                State::WritingScaleFactor => {
663                    self.configured_scale_factor = Some(self.pending_scale_factor);
664                    self.pending_tid = None;
665                    self.state = State::Idle;
666                    self.busy = false;
667                },
668                State::WaitWriteGeneralSdo => {
669                    self.pending_tid = None;
670                    self.state = State::Idle;
671                    self.busy = false;
672                },
673                State::ReadingMvV => match parse_sdo_real32(&data) {
674                    Some(v) => {
675                        self.configured_mv_v = Some(v);
676                        let next_tid = self.sdo.read(client, SDO_IDX, SUB_FULL_SCALE);
677                        self.pending_tid = Some(next_tid);
678                        self.state = State::ReadingFullScale;
679                    }
680                    None => self.set_error(&format!(
681                        "SDO read 0x8000:0x{:02X} (mV/V) returned unparseable value: {}",
682                        SUB_MV_V, data,
683                    )),
684                },
685                State::ReadingFullScale => match parse_sdo_real32(&data) {
686                    Some(v) => {
687                        self.configured_full_scale_load = Some(v);
688                        let next_tid = self.sdo.read(client, SDO_IDX, SUB_SCALE_FACTOR);
689                        self.pending_tid = Some(next_tid);
690                        self.state = State::ReadingScaleFactor;
691                    }
692                    None => self.set_error(&format!(
693                        "SDO read 0x8000:0x{:02X} (full-scale) returned unparseable value: {}",
694                        SUB_FULL_SCALE, data,
695                    )),
696                },
697                State::ReadingScaleFactor => match parse_sdo_real32(&data) {
698                    Some(v) => {
699                        self.configured_scale_factor = Some(v);
700                        self.pending_tid = None;
701                        self.state = State::Idle;
702                        self.busy = false;
703                    }
704                    None => self.set_error(&format!(
705                        "SDO read 0x8000:0x{:02X} (scale factor) returned unparseable value: {}",
706                        SUB_SCALE_FACTOR, data,
707                    )),
708                },
709                State::WaitReadGeneralSdo => {
710                    self.sdo_res_value = data;
711                    self.pending_tid = None;
712                    self.state = State::Idle;
713                    self.busy = false;
714                },
715                State::Idle => {
716                    // Response arrived but we're not in a sequence; discard.
717                    self.pending_tid = None;
718                }
719            },
720            SdoResult::Err(e) => {
721                self.set_error(&format!("SDO {:?} failed: {}", self.state, e));
722            }
723            SdoResult::Timeout => {
724                self.set_error(&format!("SDO {:?} timed out after {:?}", self.state, SDO_TIMEOUT));
725            }
726        }
727    }
728
729    fn set_error(&mut self, message: &str) {
730        log::error!("El3356: {}", message);
731        self.error = true;
732        self.error_message = message.to_string();
733        self.pending_tid = None;
734        self.state = State::Idle;
735        self.busy = false;
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use mechutil::ipc::CommandMessage;
743    use tokio::sync::mpsc;
744
745    /// Local storage for PDO fields used in tests. Avoids depending on
746    /// auto-generated GlobalMemory field names.
747    #[derive(Default)]
748    struct TestPdo {
749        tare: bool,
750        load: f32,
751        load_steady: bool,
752        load_error: bool,
753        load_overrange: bool,
754    }
755
756    impl TestPdo {
757        fn view(&mut self) -> El3356View<'_> {
758            El3356View {
759                tare:           &mut self.tare,
760                load:           &self.load,
761                load_steady:    &self.load_steady,
762                load_error:     &self.load_error,
763                load_overrange: &self.load_overrange,
764            }
765        }
766    }
767
768    fn test_client() -> (
769        CommandClient,
770        mpsc::UnboundedSender<CommandMessage>,
771        mpsc::UnboundedReceiver<String>,
772    ) {
773        let (write_tx, write_rx) = mpsc::unbounded_channel();
774        let (response_tx, response_rx) = mpsc::unbounded_channel();
775        let client = CommandClient::new(write_tx, response_rx);
776        (client, response_tx, write_rx)
777    }
778
779    /// Read the transaction_id from the most recently sent IPC message.
780    fn last_sent_tid(rx: &mut mpsc::UnboundedReceiver<String>) -> u32 {
781        let msg_json = rx.try_recv().expect("expected a message on the wire");
782        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
783        msg.transaction_id
784    }
785
786    fn assert_last_sent(
787        rx: &mut mpsc::UnboundedReceiver<String>,
788        expected_topic: &str,
789        expected_sub: u8,
790    ) -> u32 {
791        let msg_json = rx.try_recv().expect("expected a message on the wire");
792        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
793        assert_eq!(msg.topic, expected_topic);
794        assert_eq!(msg.data["index"], format!("0x{:04X}", SDO_IDX));
795        assert_eq!(msg.data["sub"], expected_sub);
796        msg.transaction_id
797    }
798
799    // ── peak tracking ──
800
801    #[test]
802    fn peak_follows_largest_magnitude() {
803        let (mut client, _resp_tx, _write_rx) = test_client();
804        let mut fb = El3356::new("EL3356_0");
805        let mut pdo = TestPdo::default();
806
807        // Positive then larger negative: peak should track absolute magnitude,
808        // but store the signed value at the peak (matches TwinCAT code exactly).
809        pdo.load = 10.0;
810        fb.tick(&mut pdo.view(), &mut client);
811        assert_eq!(fb.peak_load, 10.0);
812
813        pdo.load = -25.0;
814        fb.tick(&mut pdo.view(), &mut client);
815        assert_eq!(fb.peak_load, -25.0);
816
817        pdo.load = 20.0; // smaller than |-25|, shouldn't overwrite
818        fb.tick(&mut pdo.view(), &mut client);
819        assert_eq!(fb.peak_load, -25.0);
820    }
821
822    #[test]
823    fn reset_peak_zeroes_it() {
824        let (mut client, _resp_tx, _write_rx) = test_client();
825        let mut fb = El3356::new("EL3356_0");
826        let mut pdo = TestPdo { load: 42.0, ..Default::default() };
827        fb.tick(&mut pdo.view(), &mut client);
828        assert_eq!(fb.peak_load, 42.0);
829        fb.reset_peak();
830        assert_eq!(fb.peak_load, 0.0);
831    }
832
833    // ── tare pulse ──
834
835    #[test]
836    fn tare_resets_peak_and_pulses_bit() {
837        let (mut client, _resp_tx, _write_rx) = test_client();
838        let mut fb = El3356::new("EL3356_0");
839        let mut pdo = TestPdo { load: 50.0, ..Default::default() };
840
841        fb.tick(&mut pdo.view(), &mut client);
842        assert_eq!(fb.peak_load, 50.0);
843
844        fb.tare();
845        // Tare itself sets peak=0 immediately
846        assert_eq!(fb.peak_load, 0.0);
847
848        // tick() writes the tare bit high while within the pulse window
849        fb.tick(&mut pdo.view(), &mut client);
850        assert!(pdo.tare, "tare bit should be high within pulse window");
851
852        // Wait past the 100 ms window (small margin)
853        std::thread::sleep(TARE_PULSE + Duration::from_millis(20));
854        fb.tick(&mut pdo.view(), &mut client);
855        assert!(!pdo.tare, "tare bit should be cleared after pulse window");
856    }
857
858    // ── configure state machine ──
859
860    #[test]
861    fn configure_sequences_three_sdo_writes() {
862        let (mut client, resp_tx, mut write_rx) = test_client();
863        let mut fb = El3356::new("EL3356_0");
864        let mut pdo = TestPdo::default();
865
866        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
867        assert!(fb.busy);
868        assert_eq!(fb.state, State::WritingMvV);
869
870        // 1st write: mV/V
871        let tid1 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_MV_V);
872        resp_tx.send(CommandMessage::response(tid1, json!(null))).unwrap();
873        client.poll();
874        fb.tick(&mut pdo.view(), &mut client);
875        assert_eq!(fb.configured_mv_v, Some(2.0));
876        assert_eq!(fb.state, State::WritingFullScale);
877        assert!(fb.busy);
878
879        // 2nd write: full-scale
880        let tid2 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_FULL_SCALE);
881        resp_tx.send(CommandMessage::response(tid2, json!(null))).unwrap();
882        client.poll();
883        fb.tick(&mut pdo.view(), &mut client);
884        assert_eq!(fb.configured_full_scale_load, Some(1000.0));
885        assert_eq!(fb.state, State::WritingScaleFactor);
886        assert!(fb.busy);
887
888        // 3rd write: scale factor
889        let tid3 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_SCALE_FACTOR);
890        resp_tx.send(CommandMessage::response(tid3, json!(null))).unwrap();
891        client.poll();
892        fb.tick(&mut pdo.view(), &mut client);
893        assert_eq!(fb.configured_scale_factor, Some(100000.0));
894        assert_eq!(fb.state, State::Idle);
895        assert!(!fb.busy);
896        assert!(!fb.error);
897    }
898
899    #[test]
900    fn configure_while_busy_is_noop() {
901        let (mut client, _resp_tx, mut write_rx) = test_client();
902        let mut fb = El3356::new("EL3356_0");
903
904        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
905        let _tid1 = last_sent_tid(&mut write_rx);
906        assert!(fb.busy);
907
908        // Second call while busy: nothing on the wire, state unchanged.
909        fb.configure(&mut client, 9999.0, 9.0, 99.0);
910        assert!(write_rx.try_recv().is_err(), "no new message should have been sent");
911        assert_eq!(fb.pending_mv_v, 2.0);
912    }
913
914    #[test]
915    fn sdo_error_sets_error_and_clears_busy() {
916        let (mut client, resp_tx, mut write_rx) = test_client();
917        let mut fb = El3356::new("EL3356_0");
918        let mut pdo = TestPdo::default();
919
920        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
921        let tid1 = last_sent_tid(&mut write_rx);
922
923        // Simulate error response
924        let mut err_msg = CommandMessage::response(tid1, json!(null));
925        err_msg.success = false;
926        err_msg.error_message = "device offline".to_string();
927        resp_tx.send(err_msg).unwrap();
928        client.poll();
929
930        fb.tick(&mut pdo.view(), &mut client);
931        assert!(fb.error);
932        assert!(fb.error_message.contains("device offline"));
933        assert!(!fb.busy);
934        assert_eq!(fb.state, State::Idle);
935    }
936
937    #[test]
938    fn clear_error_resets_flag() {
939        let mut fb = El3356::new("EL3356_0");
940        fb.error = true;
941        fb.error_message = "boom".to_string();
942        fb.clear_error();
943        assert!(!fb.error);
944        assert!(fb.error_message.is_empty());
945    }
946
947    // ── read_configuration ──
948
949    /// Helper: build the response payload that `ethercat.read_sdo` sends for
950    /// a REAL32 read — the `value` field is the u32 bit-pattern of the f32.
951    fn sdo_read_response_f32(v: f32) -> serde_json::Value {
952        json!({
953            "device": "EL3356_0",
954            "index": "0x8000",
955            "sub": 0,
956            "size": 4,
957            "value_hex": format!("0x{:08X}", v.to_bits()),
958            "value": v.to_bits() as u64,
959        })
960    }
961
962    fn assert_last_sent_read(
963        rx: &mut mpsc::UnboundedReceiver<String>,
964        expected_sub: u8,
965    ) -> u32 {
966        let msg_json = rx.try_recv().expect("expected a message on the wire");
967        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
968        assert_eq!(msg.topic, "ethercat.read_sdo");
969        assert_eq!(msg.data["index"], format!("0x{:04X}", SDO_IDX));
970        assert_eq!(msg.data["sub"], expected_sub);
971        assert!(msg.data.get("value").is_none(), "reads must not include a value field");
972        msg.transaction_id
973    }
974
975    #[test]
976    fn read_configuration_fetches_three_sdos() {
977        let (mut client, resp_tx, mut write_rx) = test_client();
978        let mut fb = El3356::new("EL3356_0");
979        let mut pdo = TestPdo::default();
980
981        fb.read_configuration(&mut client);
982        assert!(fb.busy);
983        assert_eq!(fb.state, State::ReadingMvV);
984        // Starts cleared so transient reads aren't mistaken for final values.
985        assert_eq!(fb.configured_mv_v, None);
986        assert_eq!(fb.configured_full_scale_load, None);
987        assert_eq!(fb.configured_scale_factor, None);
988
989        // 1st read: mV/V — device reports 2.5
990        let tid1 = assert_last_sent_read(&mut write_rx, SUB_MV_V);
991        resp_tx.send(CommandMessage::response(tid1, sdo_read_response_f32(2.5))).unwrap();
992        client.poll();
993        fb.tick(&mut pdo.view(), &mut client);
994        assert_eq!(fb.configured_mv_v, Some(2.5));
995        assert_eq!(fb.state, State::ReadingFullScale);
996        assert!(fb.busy);
997
998        // 2nd read: full-scale — device reports 500.0
999        let tid2 = assert_last_sent_read(&mut write_rx, SUB_FULL_SCALE);
1000        resp_tx.send(CommandMessage::response(tid2, sdo_read_response_f32(500.0))).unwrap();
1001        client.poll();
1002        fb.tick(&mut pdo.view(), &mut client);
1003        assert_eq!(fb.configured_full_scale_load, Some(500.0));
1004        assert_eq!(fb.state, State::ReadingScaleFactor);
1005
1006        // 3rd read: scale factor — device reports 100000.0
1007        let tid3 = assert_last_sent_read(&mut write_rx, SUB_SCALE_FACTOR);
1008        resp_tx.send(CommandMessage::response(tid3, sdo_read_response_f32(100_000.0))).unwrap();
1009        client.poll();
1010        fb.tick(&mut pdo.view(), &mut client);
1011        assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1012        assert_eq!(fb.state, State::Idle);
1013        assert!(!fb.busy);
1014        assert!(!fb.error);
1015    }
1016
1017    #[test]
1018    fn read_configuration_while_busy_is_noop() {
1019        let (mut client, _resp_tx, mut write_rx) = test_client();
1020        let mut fb = El3356::new("EL3356_0");
1021
1022        fb.configure(&mut client, 1000.0, 2.0, 100_000.0);
1023        let _tid = last_sent_tid(&mut write_rx);
1024        assert!(fb.busy);
1025
1026        fb.read_configuration(&mut client);
1027        assert!(write_rx.try_recv().is_err(), "no new message should have been sent");
1028        // State should still be the configure-write state, not a read state.
1029        assert_eq!(fb.state, State::WritingMvV);
1030    }
1031
1032    #[test]
1033    fn read_configuration_error_clears_busy_and_leaves_partial_none() {
1034        let (mut client, resp_tx, mut write_rx) = test_client();
1035        let mut fb = El3356::new("EL3356_0");
1036        let mut pdo = TestPdo::default();
1037
1038        // Pre-seed with stale values so we can confirm they're cleared on read start.
1039        fb.configured_mv_v = Some(9.9);
1040        fb.configured_full_scale_load = Some(9999.0);
1041        fb.configured_scale_factor = Some(99.0);
1042
1043        fb.read_configuration(&mut client);
1044        assert_eq!(fb.configured_mv_v, None);
1045        assert_eq!(fb.configured_full_scale_load, None);
1046        assert_eq!(fb.configured_scale_factor, None);
1047
1048        let tid1 = last_sent_tid(&mut write_rx);
1049        let mut err_msg = CommandMessage::response(tid1, json!(null));
1050        err_msg.success = false;
1051        err_msg.error_message = "SDO abort: 0x06020000".to_string();
1052        resp_tx.send(err_msg).unwrap();
1053        client.poll();
1054
1055        fb.tick(&mut pdo.view(), &mut client);
1056        assert!(fb.error);
1057        assert!(fb.error_message.contains("SDO abort"));
1058        assert!(!fb.busy);
1059        assert_eq!(fb.state, State::Idle);
1060        // All three stay None after a failed read — user should not trust partial data.
1061        assert_eq!(fb.configured_mv_v, None);
1062        assert_eq!(fb.configured_full_scale_load, None);
1063        assert_eq!(fb.configured_scale_factor, None);
1064    }
1065
1066    #[test]
1067    fn parse_sdo_real32_from_u32_bits() {
1068        // 2.5 as IEEE-754: 0x40200000
1069        let v = json!({"value": 0x40200000u64});
1070        assert_eq!(parse_sdo_real32(&v), Some(2.5));
1071    }
1072
1073    #[test]
1074    fn parse_sdo_real32_from_f64_fallback() {
1075        let v = json!({"value": 3.25});
1076        assert_eq!(parse_sdo_real32(&v), Some(3.25));
1077    }
1078
1079    #[test]
1080    fn parse_sdo_real32_missing_field() {
1081        let v = json!({"size": 4});
1082        assert_eq!(parse_sdo_real32(&v), None);
1083    }
1084
1085    // ── generic sdo_write / sdo_read ──
1086
1087    #[test]
1088    fn sdo_write_does_not_wipe_calibration_fields() {
1089        let (mut client, _resp_tx, mut write_rx) = test_client();
1090        let mut fb = El3356::new("EL3356_0");
1091
1092        // Seed calibration state as if configure() had already completed.
1093        fb.configured_mv_v = Some(2.0);
1094        fb.configured_full_scale_load = Some(1000.0);
1095        fb.configured_scale_factor = Some(100_000.0);
1096
1097        // An unrelated SDO write (e.g. a filter setter) must not touch them.
1098        fb.sdo_write(&mut client, 0x8000, 0x11, json!(0u16));
1099        let _tid = last_sent_tid(&mut write_rx);
1100
1101        assert_eq!(fb.configured_mv_v, Some(2.0));
1102        assert_eq!(fb.configured_full_scale_load, Some(1000.0));
1103        assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1104        assert!(fb.busy);
1105        assert_eq!(fb.state, State::WaitWriteGeneralSdo);
1106    }
1107
1108    #[test]
1109    fn sdo_read_does_not_wipe_calibration_fields() {
1110        let (mut client, _resp_tx, mut write_rx) = test_client();
1111        let mut fb = El3356::new("EL3356_0");
1112
1113        fb.configured_mv_v = Some(2.0);
1114        fb.configured_full_scale_load = Some(1000.0);
1115        fb.configured_scale_factor = Some(100_000.0);
1116
1117        fb.sdo_read(&mut client, 0x8000, 0x11);
1118        let _tid = last_sent_tid(&mut write_rx);
1119
1120        assert_eq!(fb.configured_mv_v, Some(2.0));
1121        assert_eq!(fb.configured_full_scale_load, Some(1000.0));
1122        assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1123    }
1124
1125    #[test]
1126    fn sdo_read_populates_result_accessors() {
1127        let (mut client, resp_tx, mut write_rx) = test_client();
1128        let mut fb = El3356::new("EL3356_0");
1129        let mut pdo = TestPdo::default();
1130
1131        fb.sdo_read(&mut client, 0x8000, 0x11);
1132        let tid = last_sent_tid(&mut write_rx);
1133
1134        // Respond with a u16 filter value (integer path) — represents a read
1135        // of the Mode 0 filter select register.
1136        let payload = json!({
1137            "device": "EL3356_0", "index": "0x8000", "sub": 0x11,
1138            "size": 2, "value_hex": "0x0001", "value": 1u64,
1139        });
1140        resp_tx.send(CommandMessage::response(tid, payload)).unwrap();
1141        client.poll();
1142        fb.tick(&mut pdo.view(), &mut client);
1143
1144        assert!(!fb.busy);
1145        assert_eq!(fb.result_as_i64(), Some(1));
1146        assert_eq!(fb.result_as_f64(), Some(1.0));
1147        // result() returns the full object
1148        assert_eq!(fb.result()["sub"], 0x11);
1149    }
1150
1151    #[test]
1152    fn result_as_f32_reinterprets_real32_bits() {
1153        let (mut client, resp_tx, mut write_rx) = test_client();
1154        let mut fb = El3356::new("EL3356_0");
1155        let mut pdo = TestPdo::default();
1156
1157        fb.sdo_read(&mut client, 0x8000, 0x23);
1158        let tid = last_sent_tid(&mut write_rx);
1159
1160        // Simulate a REAL32 read returning the bit pattern of 2.5
1161        let payload = json!({
1162            "device": "EL3356_0", "index": "0x8000", "sub": 0x23,
1163            "size": 4, "value_hex": format!("0x{:08X}", 2.5f32.to_bits()),
1164            "value": 2.5f32.to_bits() as u64,
1165        });
1166        resp_tx.send(CommandMessage::response(tid, payload)).unwrap();
1167        client.poll();
1168        fb.tick(&mut pdo.view(), &mut client);
1169
1170        assert_eq!(fb.result_as_f32(), Some(2.5));
1171        // result_as_i64 returns the raw u32 bit pattern — not useful for REAL32
1172        // but also not wrong; this asserts the accessor at least returns something.
1173        assert_eq!(fb.result_as_i64(), Some(2.5f32.to_bits() as i64));
1174    }
1175
1176    #[test]
1177    fn result_accessors_none_before_any_read() {
1178        let fb = El3356::new("EL3356_0");
1179        assert_eq!(fb.result_as_f64(), None);
1180        assert_eq!(fb.result_as_i64(), None);
1181        assert_eq!(fb.result_as_f32(), None);
1182        assert_eq!(fb.result(), serde_json::Value::Null);
1183    }
1184
1185    #[test]
1186    fn reset_clears_latent_state() {
1187        let (mut client, _resp_tx, mut write_rx) = test_client();
1188        let mut fb = El3356::new("EL3356_0");
1189        let mut pdo = TestPdo::default();
1190
1191        // Accumulate state: tare pulse, pending SDO, stale read buffer.
1192        fb.tare();
1193        assert!(fb.tare_release_at.is_some());
1194
1195        fb.sdo_read(&mut client, 0x8000, 0x11);
1196        let _ = last_sent_tid(&mut write_rx);
1197        fb.sdo_res_value = json!({"value": 99});
1198
1199        fb.reset();
1200
1201        // reset() drops op state, pulse timer, and prior read buffer...
1202        assert!(!fb.busy);
1203        assert!(!fb.error);
1204        assert_eq!(fb.state, State::Idle);
1205        assert!(fb.tare_release_at.is_none());
1206        assert_eq!(fb.sdo_res_value, serde_json::Value::Null);
1207
1208        // ...but does not touch peak_load or the configured_* fields.
1209        // (The peak was zeroed by tare() above; seed a new value post-reset.)
1210        pdo.load = 42.0;
1211        fb.tick(&mut pdo.view(), &mut client);
1212        assert_eq!(fb.peak_load, 42.0);
1213    }
1214
1215    #[test]
1216    fn is_error_and_is_busy_accessors() {
1217        let mut fb = El3356::new("EL3356_0");
1218        assert!(!fb.is_error());
1219        assert!(!fb.is_busy());
1220        fb.error = true;
1221        fb.busy = true;
1222        assert!(fb.is_error());
1223        assert!(fb.is_busy());
1224    }
1225}