Skip to main content

autocore_std/fb/beckhoff/
el3356.rs

1//! Function block for the Beckhoff EL3356 strain-gauge EtherCAT terminal.
2//!
3//! # Responsibilities
4//!
5//! - **Peak tracking**: maintains `peak_load` as the largest-magnitude `load`
6//!   value seen since construction or the last [`reset_peak`](El3356::reset_peak) /
7//!   [`tare`](El3356::tare) call.
8//! - **Tare pulse**: [`tare`](El3356::tare) sets the device's tare bit and
9//!   automatically clears it after 100 ms. Also resets the peak.
10//! - **Load cell configuration via SDO**: [`configure`](El3356::configure)
11//!   sequences three SDO writes to `0x8000`:
12//!     - sub `0x23` — sensitivity (mV/V)
13//!     - sub `0x24` — full-scale load
14//!     - sub `0x27` — scale factor
15//!
16//! # Wiring in project.json
17//!
18//! The FB is device-agnostic at construction; the EtherCAT device name is
19//! passed to [`new`](El3356::new) and used for SDO topic routing. The five
20//! PDO-linked GM variables (`{prefix}_load`, `_load_steady`, `_load_error`,
21//! `_load_overrange`, `_tare`) must exist and be linked to the terminal's
22//! corresponding FQDNs in `project.json`.
23//!
24//! # Example
25//!
26//! ```ignore
27//! use autocore_std::fb::beckhoff::El3356;
28//! use autocore_std::el3356_view;
29//!
30//! pub struct MyProgram {
31//!     load_cell: El3356,
32//!     manual_tare_edge: autocore_std::fb::RTrig,
33//! }
34//!
35//! impl MyProgram {
36//!     pub fn new() -> Self {
37//!         Self {
38//!             load_cell: El3356::new("EL3356_0"),
39//!             manual_tare_edge: Default::default(),
40//!         }
41//!     }
42//! }
43//!
44//! impl ControlProgram for MyProgram {
45//!     type Memory = GlobalMemory;
46//!
47//!     fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
48//!         // Edge-detect a manual tare button from an HMI
49//!         if self.manual_tare_edge.call(ctx.gm.manual_tare) {
50//!             self.load_cell.tare();
51//!         }
52//!
53//!         let mut view = el3356_view!(ctx.gm, impact);
54//!         self.load_cell.tick(&mut view, ctx.client);
55//!
56//!         // Peak load is exposed for display / logging
57//!         ctx.gm.impact_peak_load = self.load_cell.peak_load;
58//!     }
59//! }
60//! ```
61
62use crate::ethercat::{SdoClient, SdoResult};
63use crate::CommandClient;
64use serde_json::json;
65use std::time::{Duration, Instant};
66
67use super::El3356View;
68
69/// Duration of the tare pulse. Matches the `T#100MS` preset in the original
70/// TwinCAT implementation.
71const TARE_PULSE: Duration = Duration::from_millis(100);
72
73/// Per-SDO timeout. SDO transfers against a functioning EL3356 complete in
74/// tens of milliseconds; a multi-second ceiling is generous.
75const SDO_TIMEOUT: Duration = Duration::from_secs(3);
76
77// SDO sub-indices on object 0x8000.
78const SDO_IDX: u16 = 0x8000;
79const SUB_MV_V: u8 = 0x23;
80const SUB_FULL_SCALE: u8 = 0x24;
81const SUB_SCALE_FACTOR: u8 = 0x27;
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum State {
85    Idle,
86    WritingMvV,
87    WritingFullScale,
88    WritingScaleFactor,
89}
90
91/// Function block for the Beckhoff EL3356 strain-gauge terminal.
92pub struct El3356 {
93    // Configuration
94    sdo: SdoClient,
95
96    // Outputs — read by the control program each tick
97    /// Largest absolute load seen since construction, last reset, or last tare.
98    pub peak_load: f32,
99    /// True while a configuration sequence is in progress.
100    pub busy: bool,
101    /// True after an SDO operation failed. Clear with [`clear_error`](El3356::clear_error).
102    pub error: bool,
103    /// Last error message, if any.
104    pub error_message: String,
105    /// Last successfully written sensitivity (mV/V). `None` until a
106    /// configure() has completed.
107    pub configured_mv_v: Option<f32>,
108    /// Last successfully written full-scale load. `None` until a configure()
109    /// has completed.
110    pub configured_full_scale_load: Option<f32>,
111    /// Last successfully written scale factor. `None` until a configure()
112    /// has completed.
113    pub configured_scale_factor: Option<f32>,
114
115    // Internal state
116    state: State,
117    pending_tid: Option<u32>,
118    /// Values buffered for the current configure() sequence.
119    pending_full_scale: f32,
120    pending_mv_v: f32,
121    pending_scale_factor: f32,
122    /// When set, `tick()` will hold `view.tare = true` until this moment is
123    /// reached, at which point it clears the tare bit.
124    tare_release_at: Option<Instant>,
125}
126
127impl El3356 {
128    /// Create a new EL3356 function block for the given EtherCAT device name.
129    ///
130    /// `device` must match the name used in `project.json`'s ethercat device
131    /// list (it's the `device` field sent with every SDO request).
132    pub fn new(device: &str) -> Self {
133        Self {
134            sdo: SdoClient::new(device),
135            peak_load: 0.0,
136            busy: false,
137            error: false,
138            error_message: String::new(),
139            configured_mv_v: None,
140            configured_full_scale_load: None,
141            configured_scale_factor: None,
142            state: State::Idle,
143            pending_tid: None,
144            pending_full_scale: 0.0,
145            pending_mv_v: 0.0,
146            pending_scale_factor: 0.0,
147            tare_release_at: None,
148        }
149    }
150
151    /// Call every control cycle.
152    ///
153    /// Performs three things, in order:
154    /// 1. Updates `peak_load` from `*view.load`.
155    /// 2. Releases the tare pulse after 100 ms.
156    /// 3. Progresses any in-flight SDO operation from [`configure`](Self::configure).
157    pub fn tick(&mut self, view: &mut El3356View, client: &mut CommandClient) {
158        // 1. Peak tracking
159        let abs_load = view.load.abs();
160        if abs_load > self.peak_load.abs() {
161            self.peak_load = *view.load;
162        }
163
164        // 2. Tare pulse release
165        if let Some(release_at) = self.tare_release_at {
166            if Instant::now() >= release_at {
167                *view.tare = false;
168                self.tare_release_at = None;
169            } else {
170                *view.tare = true;
171            }
172        }
173
174        // 3. SDO sequence
175        self.progress_sdo(client);
176    }
177
178    /// Begin an SDO configuration sequence: sensitivity (mV/V), full-scale
179    /// load, and scale factor written to object `0x8000` subs `0x23`, `0x24`,
180    /// and `0x27` respectively. Non-blocking — sets `busy = true` and returns
181    /// immediately. Poll `busy` / `error` on subsequent ticks.
182    ///
183    /// No-op (logs a warning) if the FB is already `busy`. Any existing error
184    /// flag is cleared at the start of a new sequence.
185    pub fn configure(
186        &mut self,
187        client: &mut CommandClient,
188        full_scale_load: f32,
189        sensitivity_mv_v: f32,
190        scale_factor: f32,
191    ) {
192        if self.busy {
193            log::warn!("El3356::configure called while busy; request ignored");
194            return;
195        }
196        self.error = false;
197        self.error_message.clear();
198        self.pending_full_scale = full_scale_load;
199        self.pending_mv_v = sensitivity_mv_v;
200        self.pending_scale_factor = scale_factor;
201
202        // Start with mV/V (sub 0x23)
203        let tid = self.sdo.write(client, SDO_IDX, SUB_MV_V, json!(sensitivity_mv_v));
204        self.pending_tid = Some(tid);
205        self.state = State::WritingMvV;
206        self.busy = true;
207    }
208
209    /// Reset `peak_load` to `0.0`. Immediate; no IPC.
210    pub fn reset_peak(&mut self) {
211        self.peak_load = 0.0;
212    }
213
214    /// Pulse the tare bit high for 100 ms, and reset the peak.
215    ///
216    /// The device-side bit (`view.tare`) is actually written by [`tick`](Self::tick),
217    /// so `tick` must be called every cycle. If `tare` is called while a
218    /// previous pulse is still in progress, the 100 ms window restarts.
219    pub fn tare(&mut self) {
220        self.peak_load = 0.0;
221        self.tare_release_at = Some(Instant::now() + TARE_PULSE);
222    }
223
224    /// Clear the error flag and message.
225    pub fn clear_error(&mut self) {
226        self.error = false;
227        self.error_message.clear();
228    }
229
230    /// Low-level SDO write pass-through. Does **not** interact with the FB's
231    /// `busy`/`state` fields — use for advanced operations (e.g. changing the
232    /// filter mode at runtime) that don't fit the [`configure`](Self::configure)
233    /// pattern.
234    pub fn sdo_write(
235        &mut self,
236        client: &mut CommandClient,
237        index: u16,
238        sub_index: u8,
239        value: serde_json::Value,
240    ) -> u32 {
241        self.sdo.write(client, index, sub_index, value)
242    }
243
244    // ──────────────────────────────────────────────────────────────
245    // Internal helpers
246    // ──────────────────────────────────────────────────────────────
247
248    fn progress_sdo(&mut self, client: &mut CommandClient) {
249        let tid = match self.pending_tid {
250            Some(t) => t,
251            None => return,
252        };
253
254        let result = self.sdo.result(client, tid, SDO_TIMEOUT);
255        match result {
256            SdoResult::Pending => {} // keep waiting
257            SdoResult::Ok(_) => match self.state {
258                State::WritingMvV => {
259                    self.configured_mv_v = Some(self.pending_mv_v);
260                    let next_tid = self.sdo.write(
261                        client, SDO_IDX, SUB_FULL_SCALE, json!(self.pending_full_scale),
262                    );
263                    self.pending_tid = Some(next_tid);
264                    self.state = State::WritingFullScale;
265                }
266                State::WritingFullScale => {
267                    self.configured_full_scale_load = Some(self.pending_full_scale);
268                    let next_tid = self.sdo.write(
269                        client, SDO_IDX, SUB_SCALE_FACTOR, json!(self.pending_scale_factor),
270                    );
271                    self.pending_tid = Some(next_tid);
272                    self.state = State::WritingScaleFactor;
273                }
274                State::WritingScaleFactor => {
275                    self.configured_scale_factor = Some(self.pending_scale_factor);
276                    self.pending_tid = None;
277                    self.state = State::Idle;
278                    self.busy = false;
279                }
280                State::Idle => {
281                    // Response arrived but we're not in a sequence; discard.
282                    self.pending_tid = None;
283                }
284            },
285            SdoResult::Err(e) => {
286                self.set_error(&format!("SDO {:?} failed: {}", self.state, e));
287            }
288            SdoResult::Timeout => {
289                self.set_error(&format!("SDO {:?} timed out after {:?}", self.state, SDO_TIMEOUT));
290            }
291        }
292    }
293
294    fn set_error(&mut self, message: &str) {
295        log::error!("El3356: {}", message);
296        self.error = true;
297        self.error_message = message.to_string();
298        self.pending_tid = None;
299        self.state = State::Idle;
300        self.busy = false;
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use mechutil::ipc::CommandMessage;
308    use tokio::sync::mpsc;
309
310    /// Local storage for PDO fields used in tests. Avoids depending on
311    /// auto-generated GlobalMemory field names.
312    #[derive(Default)]
313    struct TestPdo {
314        tare: bool,
315        load: f32,
316        load_steady: bool,
317        load_error: bool,
318        load_overrange: bool,
319    }
320
321    impl TestPdo {
322        fn view(&mut self) -> El3356View<'_> {
323            El3356View {
324                tare:           &mut self.tare,
325                load:           &self.load,
326                load_steady:    &self.load_steady,
327                load_error:     &self.load_error,
328                load_overrange: &self.load_overrange,
329            }
330        }
331    }
332
333    fn test_client() -> (
334        CommandClient,
335        mpsc::UnboundedSender<CommandMessage>,
336        mpsc::UnboundedReceiver<String>,
337    ) {
338        let (write_tx, write_rx) = mpsc::unbounded_channel();
339        let (response_tx, response_rx) = mpsc::unbounded_channel();
340        let client = CommandClient::new(write_tx, response_rx);
341        (client, response_tx, write_rx)
342    }
343
344    /// Read the transaction_id from the most recently sent IPC message.
345    fn last_sent_tid(rx: &mut mpsc::UnboundedReceiver<String>) -> u32 {
346        let msg_json = rx.try_recv().expect("expected a message on the wire");
347        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
348        msg.transaction_id
349    }
350
351    fn assert_last_sent(
352        rx: &mut mpsc::UnboundedReceiver<String>,
353        expected_topic: &str,
354        expected_sub: u8,
355    ) -> u32 {
356        let msg_json = rx.try_recv().expect("expected a message on the wire");
357        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
358        assert_eq!(msg.topic, expected_topic);
359        assert_eq!(msg.data["index"], format!("0x{:04X}", SDO_IDX));
360        assert_eq!(msg.data["sub"], expected_sub);
361        msg.transaction_id
362    }
363
364    // ── peak tracking ──
365
366    #[test]
367    fn peak_follows_largest_magnitude() {
368        let (mut client, _resp_tx, _write_rx) = test_client();
369        let mut fb = El3356::new("EL3356_0");
370        let mut pdo = TestPdo::default();
371
372        // Positive then larger negative: peak should track absolute magnitude,
373        // but store the signed value at the peak (matches TwinCAT code exactly).
374        pdo.load = 10.0;
375        fb.tick(&mut pdo.view(), &mut client);
376        assert_eq!(fb.peak_load, 10.0);
377
378        pdo.load = -25.0;
379        fb.tick(&mut pdo.view(), &mut client);
380        assert_eq!(fb.peak_load, -25.0);
381
382        pdo.load = 20.0; // smaller than |-25|, shouldn't overwrite
383        fb.tick(&mut pdo.view(), &mut client);
384        assert_eq!(fb.peak_load, -25.0);
385    }
386
387    #[test]
388    fn reset_peak_zeroes_it() {
389        let (mut client, _resp_tx, _write_rx) = test_client();
390        let mut fb = El3356::new("EL3356_0");
391        let mut pdo = TestPdo { load: 42.0, ..Default::default() };
392        fb.tick(&mut pdo.view(), &mut client);
393        assert_eq!(fb.peak_load, 42.0);
394        fb.reset_peak();
395        assert_eq!(fb.peak_load, 0.0);
396    }
397
398    // ── tare pulse ──
399
400    #[test]
401    fn tare_resets_peak_and_pulses_bit() {
402        let (mut client, _resp_tx, _write_rx) = test_client();
403        let mut fb = El3356::new("EL3356_0");
404        let mut pdo = TestPdo { load: 50.0, ..Default::default() };
405
406        fb.tick(&mut pdo.view(), &mut client);
407        assert_eq!(fb.peak_load, 50.0);
408
409        fb.tare();
410        // Tare itself sets peak=0 immediately
411        assert_eq!(fb.peak_load, 0.0);
412
413        // tick() writes the tare bit high while within the pulse window
414        fb.tick(&mut pdo.view(), &mut client);
415        assert!(pdo.tare, "tare bit should be high within pulse window");
416
417        // Wait past the 100 ms window (small margin)
418        std::thread::sleep(TARE_PULSE + Duration::from_millis(20));
419        fb.tick(&mut pdo.view(), &mut client);
420        assert!(!pdo.tare, "tare bit should be cleared after pulse window");
421    }
422
423    // ── configure state machine ──
424
425    #[test]
426    fn configure_sequences_three_sdo_writes() {
427        let (mut client, resp_tx, mut write_rx) = test_client();
428        let mut fb = El3356::new("EL3356_0");
429        let mut pdo = TestPdo::default();
430
431        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
432        assert!(fb.busy);
433        assert_eq!(fb.state, State::WritingMvV);
434
435        // 1st write: mV/V
436        let tid1 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_MV_V);
437        resp_tx.send(CommandMessage::response(tid1, json!(null))).unwrap();
438        client.poll();
439        fb.tick(&mut pdo.view(), &mut client);
440        assert_eq!(fb.configured_mv_v, Some(2.0));
441        assert_eq!(fb.state, State::WritingFullScale);
442        assert!(fb.busy);
443
444        // 2nd write: full-scale
445        let tid2 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_FULL_SCALE);
446        resp_tx.send(CommandMessage::response(tid2, json!(null))).unwrap();
447        client.poll();
448        fb.tick(&mut pdo.view(), &mut client);
449        assert_eq!(fb.configured_full_scale_load, Some(1000.0));
450        assert_eq!(fb.state, State::WritingScaleFactor);
451        assert!(fb.busy);
452
453        // 3rd write: scale factor
454        let tid3 = assert_last_sent(&mut write_rx, "ethercat.write_sdo", SUB_SCALE_FACTOR);
455        resp_tx.send(CommandMessage::response(tid3, json!(null))).unwrap();
456        client.poll();
457        fb.tick(&mut pdo.view(), &mut client);
458        assert_eq!(fb.configured_scale_factor, Some(100000.0));
459        assert_eq!(fb.state, State::Idle);
460        assert!(!fb.busy);
461        assert!(!fb.error);
462    }
463
464    #[test]
465    fn configure_while_busy_is_noop() {
466        let (mut client, _resp_tx, mut write_rx) = test_client();
467        let mut fb = El3356::new("EL3356_0");
468
469        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
470        let _tid1 = last_sent_tid(&mut write_rx);
471        assert!(fb.busy);
472
473        // Second call while busy: nothing on the wire, state unchanged.
474        fb.configure(&mut client, 9999.0, 9.0, 99.0);
475        assert!(write_rx.try_recv().is_err(), "no new message should have been sent");
476        assert_eq!(fb.pending_mv_v, 2.0);
477    }
478
479    #[test]
480    fn sdo_error_sets_error_and_clears_busy() {
481        let (mut client, resp_tx, mut write_rx) = test_client();
482        let mut fb = El3356::new("EL3356_0");
483        let mut pdo = TestPdo::default();
484
485        fb.configure(&mut client, 1000.0, 2.0, 100000.0);
486        let tid1 = last_sent_tid(&mut write_rx);
487
488        // Simulate error response
489        let mut err_msg = CommandMessage::response(tid1, json!(null));
490        err_msg.success = false;
491        err_msg.error_message = "device offline".to_string();
492        resp_tx.send(err_msg).unwrap();
493        client.poll();
494
495        fb.tick(&mut pdo.view(), &mut client);
496        assert!(fb.error);
497        assert!(fb.error_message.contains("device offline"));
498        assert!(!fb.busy);
499        assert_eq!(fb.state, State::Idle);
500    }
501
502    #[test]
503    fn clear_error_resets_flag() {
504        let mut fb = El3356::new("EL3356_0");
505        fb.error = true;
506        fb.error_message = "boom".to_string();
507        fb.clear_error();
508        assert!(!fb.error);
509        assert!(fb.error_message.is_empty());
510    }
511}