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}