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 write_calibration(
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_calibration(&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
419 /// Clear out whatever value is stored for tare in the EL3356's NVM. After this function completes,
420 /// the load reading will be the raw, scaled value from the load cell without
421 /// any offset.
422 pub fn clear_tare(&mut self, client: &mut CommandClient) {
423 if self.busy {
424 log::warn!("El3356::clear_tare called while busy; request ignored");
425 return;
426 }
427
428 self.sdo_write(client, 0x8000, 0x22, json!(0.0));
429 }
430
431 /// Clear the error flag and message.
432 pub fn clear_error(&mut self) {
433 self.error = false;
434 self.error_message.clear();
435 }
436
437 /// Write an arbitrary SDO. Non-blocking; sets `busy = true` and tracks
438 /// the response through the FB's state machine like the other operations.
439 ///
440 /// Does **not** touch the `configured_*` calibration fields — generic
441 /// writes are orthogonal to the calibration cycle managed by
442 /// [`configure`](Self::configure) and [`read_configuration`](Self::read_configuration).
443 pub fn sdo_write(
444 &mut self,
445 client: &mut CommandClient,
446 index: u16,
447 sub_index: u8,
448 value: serde_json::Value,
449 ) {
450 if self.busy {
451 log::warn!("El3356::sdo_write called while busy; request ignored");
452 return;
453 }
454
455 self.error = false;
456 self.error_message.clear();
457
458 let tid = self.sdo.write(client, index, sub_index, value);
459 self.pending_tid = Some(tid);
460 self.state = State::WaitWriteGeneralSdo;
461 self.busy = true;
462 }
463
464 /// Read an arbitrary SDO. Non-blocking; the response lands in the
465 /// internal result buffer, retrievable via [`result`](Self::result),
466 /// [`result_as_f64`](Self::result_as_f64),
467 /// [`result_as_i64`](Self::result_as_i64), or
468 /// [`result_as_f32`](Self::result_as_f32) once `busy` clears.
469 ///
470 /// Does **not** touch the `configured_*` calibration fields — generic
471 /// reads are orthogonal to the calibration cycle managed by
472 /// [`configure`](Self::configure) and [`read_configuration`](Self::read_configuration).
473 pub fn sdo_read(
474 &mut self,
475 client: &mut CommandClient,
476 index: u16,
477 sub_index: u8,
478 ) {
479 if self.busy {
480 log::warn!("El3356::sdo_read called while busy; request ignored");
481 return;
482 }
483
484 self.error = false;
485 self.error_message.clear();
486
487 let tid = self.sdo.read(client, index, sub_index);
488 self.pending_tid = Some(tid);
489 self.state = State::WaitReadGeneralSdo;
490 self.busy = true;
491 }
492
493 /// Enable or disable the filter on Mode 0, which is the default, slower mode of the bridge input ADC.
494 /// Factory default is TRUE.
495 /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
496 pub fn set_mode0_filter_enabled(&mut self, client: &mut CommandClient, enable : bool) {
497 if self.busy {
498 log::warn!("El3356::set_mode0_filter_enabled called while busy; request ignored");
499 return;
500 }
501
502 self.sdo_write(client, 0x8000, 0x01, json!(enable));
503 }
504
505 /// Enable or disable the averager on Mode 0, which is the default, slower mode of the bridge input ADC.
506 /// The averager is low-latency and should usually be left on.
507 /// Mode 0 (10.5 kSps internal rate): The 4-sample averager adds roughly 0.14 ms of latency.
508 /// Factory default is TRUE.
509 /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
510 pub fn set_mode0_averager_enabled(&mut self, client: &mut CommandClient, enable : bool) {
511 if self.busy {
512 log::warn!("El3356::set_mode0_averager_enabled called while busy; request ignored");
513 return;
514 }
515
516 self.sdo_write(client, 0x8000, 0x03, json!(enable));
517 }
518
519 /// Set the Mode 1 filter (CoE 0x8000:11).
520 /// Mode 0, the default mode, is High Precision.
521 /// Mode 0 is active when the Sample Mode bit of the Control Word is FALSE.
522 /// The ADC runs slower (yielding a hardware latency of around 7.2 ms) but delivers very high accuracy and low noise.
523 /// Mode 0 is typically used paired with a stronger IIR filter—for highly accurate, static weighing where a completely calm value is required.
524 pub fn set_mode0_filter(&mut self, client: &mut CommandClient, filter : El3356Filters) {
525 if self.busy {
526 log::warn!("El3356::set_mode0_filter called while busy; request ignored");
527 return;
528 }
529
530 self.sdo_write(client, 0x8000, 0x11, json!(filter as u16));
531 }
532
533
534 /// Enable or disable the filter on Mode 1, the faster mode of the bridge input ADC.
535 /// Factory default is TRUE.
536 /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
537 pub fn set_mode1_filter_enabled(&mut self, client: &mut CommandClient, enable : bool) {
538 if self.busy {
539 log::warn!("El3356::set_mode1_filter_enabled called while busy; request ignored");
540 return;
541 }
542
543 self.sdo_write(client, 0x8000, 0x02, json!(enable));
544 }
545
546 /// Enable or disable the averager on Mode 1, the faster mode of the bridge input ADC.
547 /// The averager is low-latency and should usually be left on.
548 /// Mode 1 (105.5 kSps internal rate): The 4-sample averager adds roughly 0.014 ms of latency.
549 /// Factory default is TRUE.
550 /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
551 pub fn set_mode1_averager_enabled(&mut self, client: &mut CommandClient, enable : bool) {
552 if self.busy {
553 log::warn!("El3356::set_mode1_averager_enabled called while busy; request ignored");
554 return;
555 }
556
557 self.sdo_write(client, 0x8000, 0x05, json!(enable));
558 }
559
560 /// Set the Mode 1 filter (CoE 0x8000:12).
561 /// Mode 1 sacrifices a bit of accuracy for speed: the ADC runs much faster
562 /// (yielding a hardware latency around 0.72 ms).
563 /// Mode 1 is typically paired with a very weak filter (like IIR1 or disabled entirely)
564 /// to track fast transients — for example, capturing a rapid impact or
565 /// tracking a high-speed dosing cycle.
566 /// Mode 1 is active when the Sample Mode bit of the Control Word is TRUE.
567 pub fn set_mode1_filter(&mut self, client: &mut CommandClient, filter : El3356Filters) {
568 if self.busy {
569 log::warn!("El3356::set_mode1_filter called while busy; request ignored");
570 return;
571 }
572
573 self.sdo_write(client, 0x8000, 0x12, json!(filter as u16));
574 }
575
576
577
578 /// The FB encountered an error processing the last command.
579 pub fn is_error(&self) -> bool {
580 return self.error;
581 }
582
583 /// The FB is busy processing a command.
584 pub fn is_busy(&self) -> bool {
585 return self.busy;
586 }
587
588 /// Full reset: drop all transient state so the FB behaves as if
589 /// freshly constructed (except for the cached SDO device name).
590 ///
591 /// Clears the error flag, cancels any in-flight SDO operation, releases
592 /// the tare bit on the next [`tick`](Self::tick), and discards the last
593 /// `sdo_read` result. Does **not** zero `peak_load` — call
594 /// [`reset_peak`](Self::reset_peak) or [`tare`](Self::tare) if you need
595 /// that — and does **not** clear `configured_*`; those stay populated
596 /// from the last [`configure`](Self::configure) or
597 /// [`read_configuration`](Self::read_configuration) so the program's
598 /// view of the device's calibration survives a mid-op abort.
599 pub fn reset(&mut self) {
600 self.error = false;
601 self.error_message.clear();
602 self.pending_tid = None;
603 self.state = State::Idle;
604 self.busy = false;
605 self.tare_release_at = None;
606 self.sdo_res_value = serde_json::Value::Null;
607 }
608
609
610 /// Get the full response from the most recent [`sdo_read`](Self::sdo_read).
611 ///
612 /// The returned value is the complete `ethercat.read_sdo` payload — an
613 /// object with `value`, `value_hex`, `size`, `raw_bytes`, etc. Prefer the
614 /// typed accessors ([`result_as_f64`](Self::result_as_f64),
615 /// [`result_as_i64`](Self::result_as_i64),
616 /// [`result_as_f32`](Self::result_as_f32)) for scalar register reads.
617 pub fn result(&self) -> serde_json::Value {
618 self.sdo_res_value.clone()
619 }
620
621 /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
622 /// as an `f64`, if the SDO returned a number. Returns `None` if no read
623 /// has completed, the response had no `value` field, or the field is not
624 /// coercible to `f64`.
625 pub fn result_as_f64(&self) -> Option<f64> {
626 self.sdo_res_value.get("value").and_then(|v| v.as_f64())
627 }
628
629 /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
630 /// as an `i64`. See [`result_as_f64`](Self::result_as_f64) for `None`
631 /// semantics.
632 pub fn result_as_i64(&self) -> Option<i64> {
633 self.sdo_res_value.get("value").and_then(|v| v.as_i64())
634 }
635
636 /// Get the `value` field of the most recent [`sdo_read`](Self::sdo_read)
637 /// as an `f32`, interpreting the returned `u32` bit pattern as an
638 /// IEEE-754 single-precision float. This is the correct accessor for
639 /// REAL32 SDOs (e.g. the EL3356's calibration parameters).
640 pub fn result_as_f32(&self) -> Option<f32> {
641 parse_sdo_real32(&self.sdo_res_value)
642 }
643
644
645 // ──────────────────────────────────────────────────────────────
646 // Internal helpers
647 // ──────────────────────────────────────────────────────────────
648
649 fn progress_sdo(&mut self, client: &mut CommandClient) {
650 let tid = match self.pending_tid {
651 Some(t) => t,
652 None => return,
653 };
654
655 let result = self.sdo.result(client, tid, SDO_TIMEOUT);
656 match result {
657 SdoResult::Pending => {} // keep waiting
658 SdoResult::Ok(data) => match self.state {
659 State::WritingMvV => {
660 self.configured_mv_v = Some(self.pending_mv_v);
661 let next_tid = self.sdo.write(
662 client, SDO_IDX, SUB_FULL_SCALE, json!(self.pending_full_scale),
663 );
664 self.pending_tid = Some(next_tid);
665 self.state = State::WritingFullScale;
666 }
667 State::WritingFullScale => {
668 self.configured_full_scale_load = Some(self.pending_full_scale);
669 let next_tid = self.sdo.write(
670 client, SDO_IDX, SUB_SCALE_FACTOR, json!(self.pending_scale_factor),
671 );
672 self.pending_tid = Some(next_tid);
673 self.state = State::WritingScaleFactor;
674 }
675 State::WritingScaleFactor => {
676 self.configured_scale_factor = Some(self.pending_scale_factor);
677 // reset any Tare value, which will be in the wrong units
678 let next_tid = self.sdo.write(
679 client, SDO_IDX, 0x22, json!(0.0),
680 );
681 self.pending_tid = Some(next_tid);
682 self.state = State::WaitWriteGeneralSdo;
683 },
684 State::WaitWriteGeneralSdo => {
685 self.pending_tid = None;
686 self.state = State::Idle;
687 self.busy = false;
688 },
689 State::ReadingMvV => match parse_sdo_real32(&data) {
690 Some(v) => {
691 self.configured_mv_v = Some(v);
692 let next_tid = self.sdo.read(client, SDO_IDX, SUB_FULL_SCALE);
693 self.pending_tid = Some(next_tid);
694 self.state = State::ReadingFullScale;
695 }
696 None => self.set_error(&format!(
697 "SDO read 0x8000:0x{:02X} (mV/V) returned unparseable value: {}",
698 SUB_MV_V, data,
699 )),
700 },
701 State::ReadingFullScale => match parse_sdo_real32(&data) {
702 Some(v) => {
703 self.configured_full_scale_load = Some(v);
704 let next_tid = self.sdo.read(client, SDO_IDX, SUB_SCALE_FACTOR);
705 self.pending_tid = Some(next_tid);
706 self.state = State::ReadingScaleFactor;
707 }
708 None => self.set_error(&format!(
709 "SDO read 0x8000:0x{:02X} (full-scale) returned unparseable value: {}",
710 SUB_FULL_SCALE, data,
711 )),
712 },
713 State::ReadingScaleFactor => match parse_sdo_real32(&data) {
714 Some(v) => {
715 self.configured_scale_factor = Some(v);
716 self.pending_tid = None;
717 self.state = State::Idle;
718 self.busy = false;
719 }
720 None => self.set_error(&format!(
721 "SDO read 0x8000:0x{:02X} (scale factor) returned unparseable value: {}",
722 SUB_SCALE_FACTOR, data,
723 )),
724 },
725 State::WaitReadGeneralSdo => {
726 self.sdo_res_value = data;
727 self.pending_tid = None;
728 self.state = State::Idle;
729 self.busy = false;
730 },
731 State::Idle => {
732 // Response arrived but we're not in a sequence; discard.
733 self.pending_tid = None;
734 }
735 },
736 SdoResult::Err(e) => {
737 self.set_error(&format!("SDO {:?} failed: {}", self.state, e));
738 }
739 SdoResult::Timeout => {
740 self.set_error(&format!("SDO {:?} timed out after {:?}", self.state, SDO_TIMEOUT));
741 }
742 }
743 }
744
745 fn set_error(&mut self, message: &str) {
746 log::error!("El3356: {}", message);
747 self.error = true;
748 self.error_message = message.to_string();
749 self.pending_tid = None;
750 self.state = State::Idle;
751 self.busy = false;
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use mechutil::ipc::CommandMessage;
759 use tokio::sync::mpsc;
760
761 /// Local storage for PDO fields used in tests. Avoids depending on
762 /// auto-generated GlobalMemory field names.
763 #[derive(Default)]
764 struct TestPdo {
765 tare: bool,
766 load: f32,
767 load_steady: bool,
768 load_error: bool,
769 load_overrange: bool,
770 }
771
772 impl TestPdo {
773 fn view(&mut self) -> El3356View<'_> {
774 El3356View {
775 tare: &mut self.tare,
776 load: &self.load,
777 load_steady: &self.load_steady,
778 load_error: &self.load_error,
779 load_overrange: &self.load_overrange,
780 }
781 }
782 }
783
784 fn test_client() -> (
785 CommandClient,
786 mpsc::UnboundedSender<CommandMessage>,
787 mpsc::UnboundedReceiver<String>,
788 ) {
789 let (write_tx, write_rx) = mpsc::unbounded_channel();
790 let (response_tx, response_rx) = mpsc::unbounded_channel();
791 let client = CommandClient::new(write_tx, response_rx);
792 (client, response_tx, write_rx)
793 }
794
795 /// Read the transaction_id from the most recently sent IPC message.
796 fn last_sent_tid(rx: &mut mpsc::UnboundedReceiver<String>) -> u32 {
797 let msg_json = rx.try_recv().expect("expected a message on the wire");
798 let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
799 msg.transaction_id
800 }
801
802 fn assert_last_sent(
803 rx: &mut mpsc::UnboundedReceiver<String>,
804 expected_topic: &str,
805 expected_sub: u8,
806 ) -> u32 {
807 let msg_json = rx.try_recv().expect("expected a message on the wire");
808 let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
809 assert_eq!(msg.topic, expected_topic);
810 assert_eq!(msg.data["index"], format!("0x{:04X}", SDO_IDX));
811 assert_eq!(msg.data["sub"], expected_sub);
812 msg.transaction_id
813 }
814
815 // ── peak tracking ──
816
817 #[test]
818 fn peak_follows_largest_magnitude() {
819 let (mut client, _resp_tx, _write_rx) = test_client();
820 let mut fb = El3356::new("EL3356_0");
821 let mut pdo = TestPdo::default();
822
823 // Positive then larger negative: peak should track absolute magnitude,
824 // but store the signed value at the peak (matches TwinCAT code exactly).
825 pdo.load = 10.0;
826 fb.tick(&mut pdo.view(), &mut client);
827 assert_eq!(fb.peak_load, 10.0);
828
829 pdo.load = -25.0;
830 fb.tick(&mut pdo.view(), &mut client);
831 assert_eq!(fb.peak_load, -25.0);
832
833 pdo.load = 20.0; // smaller than |-25|, shouldn't overwrite
834 fb.tick(&mut pdo.view(), &mut client);
835 assert_eq!(fb.peak_load, -25.0);
836 }
837
838 #[test]
839 fn reset_peak_zeroes_it() {
840 let (mut client, _resp_tx, _write_rx) = test_client();
841 let mut fb = El3356::new("EL3356_0");
842 let mut pdo = TestPdo { load: 42.0, ..Default::default() };
843 fb.tick(&mut pdo.view(), &mut client);
844 assert_eq!(fb.peak_load, 42.0);
845 fb.reset_peak();
846 assert_eq!(fb.peak_load, 0.0);
847 }
848
849 // ── tare pulse ──
850
851 #[test]
852 fn tare_resets_peak_and_pulses_bit() {
853 let (mut client, _resp_tx, _write_rx) = test_client();
854 let mut fb = El3356::new("EL3356_0");
855 let mut pdo = TestPdo { load: 50.0, ..Default::default() };
856
857 fb.tick(&mut pdo.view(), &mut client);
858 assert_eq!(fb.peak_load, 50.0);
859
860 fb.tare();
861 // Tare itself sets peak=0 immediately
862 assert_eq!(fb.peak_load, 0.0);
863
864 // tick() writes the tare bit high while within the pulse window
865 fb.tick(&mut pdo.view(), &mut client);
866 assert!(pdo.tare, "tare bit should be high within pulse window");
867
868 // Wait past the 100 ms window (small margin)
869 std::thread::sleep(TARE_PULSE + Duration::from_millis(20));
870 fb.tick(&mut pdo.view(), &mut client);
871 assert!(!pdo.tare, "tare bit should be cleared after pulse window");
872 }
873
874 // ── configure state machine ──
875
876 #[test]
877 fn configure_sequences_three_sdo_writes() {
878 let (mut client, resp_tx, mut write_rx) = test_client();
879 let mut fb = El3356::new("EL3356_0");
880 let mut pdo = TestPdo::default();
881
882 fb.write_calibration(&mut client, 1000.0, 2.0, 100000.0);
883 assert!(fb.busy);
884 assert_eq!(fb.state, State::WritingMvV);
885
886 // 1st write: mV/V
887 let tid1 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_MV_V);
888 resp_tx.send(CommandMessage::response(tid1, json!(null))).unwrap();
889 client.poll();
890 fb.tick(&mut pdo.view(), &mut client);
891 assert_eq!(fb.configured_mv_v, Some(2.0));
892 assert_eq!(fb.state, State::WritingFullScale);
893 assert!(fb.busy);
894
895 // 2nd write: full-scale
896 let tid2 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_FULL_SCALE);
897 resp_tx.send(CommandMessage::response(tid2, json!(null))).unwrap();
898 client.poll();
899 fb.tick(&mut pdo.view(), &mut client);
900 assert_eq!(fb.configured_full_scale_load, Some(1000.0));
901 assert_eq!(fb.state, State::WritingScaleFactor);
902 assert!(fb.busy);
903
904 // 3rd write: scale factor
905 let tid3 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_SCALE_FACTOR);
906 resp_tx.send(CommandMessage::response(tid3, json!(null))).unwrap();
907 client.poll();
908 fb.tick(&mut pdo.view(), &mut client);
909 assert_eq!(fb.configured_scale_factor, Some(100000.0));
910 assert_eq!(fb.state, State::Idle);
911 assert!(!fb.busy);
912 assert!(!fb.error);
913 }
914
915 #[test]
916 fn configure_while_busy_is_noop() {
917 let (mut client, _resp_tx, mut write_rx) = test_client();
918 let mut fb = El3356::new("EL3356_0");
919
920 fb.write_calibration(&mut client, 1000.0, 2.0, 100000.0);
921 let _tid1 = last_sent_tid(&mut write_rx);
922 assert!(fb.busy);
923
924 // Second call while busy: nothing on the wire, state unchanged.
925 fb.write_calibration(&mut client, 9999.0, 9.0, 99.0);
926 assert!(write_rx.try_recv().is_err(), "no new message should have been sent");
927 assert_eq!(fb.pending_mv_v, 2.0);
928 }
929
930 #[test]
931 fn sdo_error_sets_error_and_clears_busy() {
932 let (mut client, resp_tx, mut write_rx) = test_client();
933 let mut fb = El3356::new("EL3356_0");
934 let mut pdo = TestPdo::default();
935
936 fb.write_calibration(&mut client, 1000.0, 2.0, 100000.0);
937 let tid1 = last_sent_tid(&mut write_rx);
938
939 // Simulate error response
940 let mut err_msg = CommandMessage::response(tid1, json!(null));
941 err_msg.success = false;
942 err_msg.error_message = "device offline".to_string();
943 resp_tx.send(err_msg).unwrap();
944 client.poll();
945
946 fb.tick(&mut pdo.view(), &mut client);
947 assert!(fb.error);
948 assert!(fb.error_message.contains("device offline"));
949 assert!(!fb.busy);
950 assert_eq!(fb.state, State::Idle);
951 }
952
953 #[test]
954 fn clear_error_resets_flag() {
955 let mut fb = El3356::new("EL3356_0");
956 fb.error = true;
957 fb.error_message = "boom".to_string();
958 fb.clear_error();
959 assert!(!fb.error);
960 assert!(fb.error_message.is_empty());
961 }
962
963 // ── read_configuration ──
964
965 /// Helper: build the response payload that `ethercat.read_sdo` sends for
966 /// a REAL32 read — the `value` field is the u32 bit-pattern of the f32.
967 fn sdo_read_response_f32(v: f32) -> serde_json::Value {
968 json!({
969 "device": "EL3356_0",
970 "index": "0x8000",
971 "sub": 0,
972 "size": 4,
973 "value_hex": format!("0x{:08X}", v.to_bits()),
974 "value": v.to_bits() as u64,
975 })
976 }
977
978 fn assert_last_sent_read(
979 rx: &mut mpsc::UnboundedReceiver<String>,
980 expected_sub: u8,
981 ) -> u32 {
982 let msg_json = rx.try_recv().expect("expected a message on the wire");
983 let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
984 assert_eq!(msg.topic, "ethercat.read_sdo");
985 assert_eq!(msg.data["index"], format!("0x{:04X}", SDO_IDX));
986 assert_eq!(msg.data["sub"], expected_sub);
987 assert!(msg.data.get("value").is_none(), "reads must not include a value field");
988 msg.transaction_id
989 }
990
991 #[test]
992 fn read_configuration_fetches_three_sdos() {
993 let (mut client, resp_tx, mut write_rx) = test_client();
994 let mut fb = El3356::new("EL3356_0");
995 let mut pdo = TestPdo::default();
996
997 fb.read_calibration(&mut client);
998 assert!(fb.busy);
999 assert_eq!(fb.state, State::ReadingMvV);
1000 // Starts cleared so transient reads aren't mistaken for final values.
1001 assert_eq!(fb.configured_mv_v, None);
1002 assert_eq!(fb.configured_full_scale_load, None);
1003 assert_eq!(fb.configured_scale_factor, None);
1004
1005 // 1st read: mV/V — device reports 2.5
1006 let tid1 = assert_last_sent_read(&mut write_rx, SUB_MV_V);
1007 resp_tx.send(CommandMessage::response(tid1, sdo_read_response_f32(2.5))).unwrap();
1008 client.poll();
1009 fb.tick(&mut pdo.view(), &mut client);
1010 assert_eq!(fb.configured_mv_v, Some(2.5));
1011 assert_eq!(fb.state, State::ReadingFullScale);
1012 assert!(fb.busy);
1013
1014 // 2nd read: full-scale — device reports 500.0
1015 let tid2 = assert_last_sent_read(&mut write_rx, SUB_FULL_SCALE);
1016 resp_tx.send(CommandMessage::response(tid2, sdo_read_response_f32(500.0))).unwrap();
1017 client.poll();
1018 fb.tick(&mut pdo.view(), &mut client);
1019 assert_eq!(fb.configured_full_scale_load, Some(500.0));
1020 assert_eq!(fb.state, State::ReadingScaleFactor);
1021
1022 // 3rd read: scale factor — device reports 100000.0
1023 let tid3 = assert_last_sent_read(&mut write_rx, SUB_SCALE_FACTOR);
1024 resp_tx.send(CommandMessage::response(tid3, sdo_read_response_f32(100_000.0))).unwrap();
1025 client.poll();
1026 fb.tick(&mut pdo.view(), &mut client);
1027 assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1028 assert_eq!(fb.state, State::Idle);
1029 assert!(!fb.busy);
1030 assert!(!fb.error);
1031 }
1032
1033 #[test]
1034 fn read_configuration_while_busy_is_noop() {
1035 let (mut client, _resp_tx, mut write_rx) = test_client();
1036 let mut fb = El3356::new("EL3356_0");
1037
1038 fb.write_calibration(&mut client, 1000.0, 2.0, 100_000.0);
1039 let _tid = last_sent_tid(&mut write_rx);
1040 assert!(fb.busy);
1041
1042 fb.read_calibration(&mut client);
1043 assert!(write_rx.try_recv().is_err(), "no new message should have been sent");
1044 // State should still be the configure-write state, not a read state.
1045 assert_eq!(fb.state, State::WritingMvV);
1046 }
1047
1048 #[test]
1049 fn read_configuration_error_clears_busy_and_leaves_partial_none() {
1050 let (mut client, resp_tx, mut write_rx) = test_client();
1051 let mut fb = El3356::new("EL3356_0");
1052 let mut pdo = TestPdo::default();
1053
1054 // Pre-seed with stale values so we can confirm they're cleared on read start.
1055 fb.configured_mv_v = Some(9.9);
1056 fb.configured_full_scale_load = Some(9999.0);
1057 fb.configured_scale_factor = Some(99.0);
1058
1059 fb.read_calibration(&mut client);
1060 assert_eq!(fb.configured_mv_v, None);
1061 assert_eq!(fb.configured_full_scale_load, None);
1062 assert_eq!(fb.configured_scale_factor, None);
1063
1064 let tid1 = last_sent_tid(&mut write_rx);
1065 let mut err_msg = CommandMessage::response(tid1, json!(null));
1066 err_msg.success = false;
1067 err_msg.error_message = "SDO abort: 0x06020000".to_string();
1068 resp_tx.send(err_msg).unwrap();
1069 client.poll();
1070
1071 fb.tick(&mut pdo.view(), &mut client);
1072 assert!(fb.error);
1073 assert!(fb.error_message.contains("SDO abort"));
1074 assert!(!fb.busy);
1075 assert_eq!(fb.state, State::Idle);
1076 // All three stay None after a failed read — user should not trust partial data.
1077 assert_eq!(fb.configured_mv_v, None);
1078 assert_eq!(fb.configured_full_scale_load, None);
1079 assert_eq!(fb.configured_scale_factor, None);
1080 }
1081
1082 #[test]
1083 fn parse_sdo_real32_from_u32_bits() {
1084 // 2.5 as IEEE-754: 0x40200000
1085 let v = json!({"value": 0x40200000u64});
1086 assert_eq!(parse_sdo_real32(&v), Some(2.5));
1087 }
1088
1089 #[test]
1090 fn parse_sdo_real32_from_f64_fallback() {
1091 let v = json!({"value": 3.25});
1092 assert_eq!(parse_sdo_real32(&v), Some(3.25));
1093 }
1094
1095 #[test]
1096 fn parse_sdo_real32_missing_field() {
1097 let v = json!({"size": 4});
1098 assert_eq!(parse_sdo_real32(&v), None);
1099 }
1100
1101 // ── generic sdo_write / sdo_read ──
1102
1103 #[test]
1104 fn sdo_write_does_not_wipe_calibration_fields() {
1105 let (mut client, _resp_tx, mut write_rx) = test_client();
1106 let mut fb = El3356::new("EL3356_0");
1107
1108 // Seed calibration state as if configure() had already completed.
1109 fb.configured_mv_v = Some(2.0);
1110 fb.configured_full_scale_load = Some(1000.0);
1111 fb.configured_scale_factor = Some(100_000.0);
1112
1113 // An unrelated SDO write (e.g. a filter setter) must not touch them.
1114 fb.sdo_write(&mut client, 0x8000, 0x11, json!(0u16));
1115 let _tid = last_sent_tid(&mut write_rx);
1116
1117 assert_eq!(fb.configured_mv_v, Some(2.0));
1118 assert_eq!(fb.configured_full_scale_load, Some(1000.0));
1119 assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1120 assert!(fb.busy);
1121 assert_eq!(fb.state, State::WaitWriteGeneralSdo);
1122 }
1123
1124 #[test]
1125 fn sdo_read_does_not_wipe_calibration_fields() {
1126 let (mut client, _resp_tx, mut write_rx) = test_client();
1127 let mut fb = El3356::new("EL3356_0");
1128
1129 fb.configured_mv_v = Some(2.0);
1130 fb.configured_full_scale_load = Some(1000.0);
1131 fb.configured_scale_factor = Some(100_000.0);
1132
1133 fb.sdo_read(&mut client, 0x8000, 0x11);
1134 let _tid = last_sent_tid(&mut write_rx);
1135
1136 assert_eq!(fb.configured_mv_v, Some(2.0));
1137 assert_eq!(fb.configured_full_scale_load, Some(1000.0));
1138 assert_eq!(fb.configured_scale_factor, Some(100_000.0));
1139 }
1140
1141 #[test]
1142 fn sdo_read_populates_result_accessors() {
1143 let (mut client, resp_tx, mut write_rx) = test_client();
1144 let mut fb = El3356::new("EL3356_0");
1145 let mut pdo = TestPdo::default();
1146
1147 fb.sdo_read(&mut client, 0x8000, 0x11);
1148 let tid = last_sent_tid(&mut write_rx);
1149
1150 // Respond with a u16 filter value (integer path) — represents a read
1151 // of the Mode 0 filter select register.
1152 let payload = json!({
1153 "device": "EL3356_0", "index": "0x8000", "sub": 0x11,
1154 "size": 2, "value_hex": "0x0001", "value": 1u64,
1155 });
1156 resp_tx.send(CommandMessage::response(tid, payload)).unwrap();
1157 client.poll();
1158 fb.tick(&mut pdo.view(), &mut client);
1159
1160 assert!(!fb.busy);
1161 assert_eq!(fb.result_as_i64(), Some(1));
1162 assert_eq!(fb.result_as_f64(), Some(1.0));
1163 // result() returns the full object
1164 assert_eq!(fb.result()["sub"], 0x11);
1165 }
1166
1167 #[test]
1168 fn result_as_f32_reinterprets_real32_bits() {
1169 let (mut client, resp_tx, mut write_rx) = test_client();
1170 let mut fb = El3356::new("EL3356_0");
1171 let mut pdo = TestPdo::default();
1172
1173 fb.sdo_read(&mut client, 0x8000, 0x23);
1174 let tid = last_sent_tid(&mut write_rx);
1175
1176 // Simulate a REAL32 read returning the bit pattern of 2.5
1177 let payload = json!({
1178 "device": "EL3356_0", "index": "0x8000", "sub": 0x23,
1179 "size": 4, "value_hex": format!("0x{:08X}", 2.5f32.to_bits()),
1180 "value": 2.5f32.to_bits() as u64,
1181 });
1182 resp_tx.send(CommandMessage::response(tid, payload)).unwrap();
1183 client.poll();
1184 fb.tick(&mut pdo.view(), &mut client);
1185
1186 assert_eq!(fb.result_as_f32(), Some(2.5));
1187 // result_as_i64 returns the raw u32 bit pattern — not useful for REAL32
1188 // but also not wrong; this asserts the accessor at least returns something.
1189 assert_eq!(fb.result_as_i64(), Some(2.5f32.to_bits() as i64));
1190 }
1191
1192 #[test]
1193 fn result_accessors_none_before_any_read() {
1194 let fb = El3356::new("EL3356_0");
1195 assert_eq!(fb.result_as_f64(), None);
1196 assert_eq!(fb.result_as_i64(), None);
1197 assert_eq!(fb.result_as_f32(), None);
1198 assert_eq!(fb.result(), serde_json::Value::Null);
1199 }
1200
1201 #[test]
1202 fn reset_clears_latent_state() {
1203 let (mut client, _resp_tx, mut write_rx) = test_client();
1204 let mut fb = El3356::new("EL3356_0");
1205 let mut pdo = TestPdo::default();
1206
1207 // Accumulate state: tare pulse, pending SDO, stale read buffer.
1208 fb.tare();
1209 assert!(fb.tare_release_at.is_some());
1210
1211 fb.sdo_read(&mut client, 0x8000, 0x11);
1212 let _ = last_sent_tid(&mut write_rx);
1213 fb.sdo_res_value = json!({"value": 99});
1214
1215 fb.reset();
1216
1217 // reset() drops op state, pulse timer, and prior read buffer...
1218 assert!(!fb.busy);
1219 assert!(!fb.error);
1220 assert_eq!(fb.state, State::Idle);
1221 assert!(fb.tare_release_at.is_none());
1222 assert_eq!(fb.sdo_res_value, serde_json::Value::Null);
1223
1224 // ...but does not touch peak_load or the configured_* fields.
1225 // (The peak was zeroed by tare() above; seed a new value post-reset.)
1226 pdo.load = 42.0;
1227 fb.tick(&mut pdo.view(), &mut client);
1228 assert_eq!(fb.peak_load, 42.0);
1229 }
1230
1231 #[test]
1232 fn is_error_and_is_busy_accessors() {
1233 let mut fb = El3356::new("EL3356_0");
1234 assert!(!fb.is_error());
1235 assert!(!fb.is_busy());
1236 fb.error = true;
1237 fb.busy = true;
1238 assert!(fb.is_error());
1239 assert!(fb.is_busy());
1240 }
1241}