Skip to main content

autocore_std/motion/
axis.rs

1//! Stateful motion controller for CiA 402 servo drives.
2//!
3//! [`Axis`] manages the CiA 402 protocol state machine internally,
4//! providing a clean high-level motion API. It owns an [`SdoClient`]
5//! for SDO operations during homing.
6//!
7//! # Usage
8//!
9//! ```ignore
10//! use autocore_std::motion::{Axis, AxisConfig};
11//!
12//! let config = AxisConfig::new(12_800).with_user_scale(360.0);
13//! let mut axis = Axis::new(config, "ClearPath_0");
14//!
15//! // In your control loop:
16//! axis.tick(&mut view, ctx.client);
17//!
18//! // Command the axis:
19//! axis.enable(&mut view);              // start enable sequence
20//! axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
21//! axis.home(&mut view, HomingMethod::CurrentPosition);
22//! ```
23
24use std::collections::VecDeque;
25use std::time::{Duration, Instant};
26
27use serde_json::json;
28use strum_macros::FromRepr;
29
30use super::axis_config::AxisConfig;
31use super::axis_view::AxisView;
32use super::cia402::{
33    Cia402Control, Cia402State, Cia402Status, ModesOfOperation, RawControlWord, RawStatusWord,
34};
35use super::homing::HomingMethod;
36use crate::command_client::CommandClient;
37use crate::ethercat::{SdoClient, SdoResult};
38use crate::fb::Ton;
39use crate::motion::FbSetModeOfOperation;
40
41// ──────────────────────────────────────────────
42// Internal state machine
43// ──────────────────────────────────────────────
44
45#[derive(Debug, Clone, PartialEq)]
46enum AxisOp {
47    Idle,
48    Enabling(u8),
49    Disabling(u8),
50    Moving(MoveKind, u8, bool, bool),
51    Homing(u8),
52    SoftHoming(u8),
53    Halting(u8),
54    FaultRecovery(u8),
55}
56
57/// Sub-steps of `AxisOp::Halting`.
58///
59/// The set-point queue is flushed by **toggling the mode of operation** (PP →
60/// Homing → PP) rather than by the CiA-402 set-point-acknowledge cancel
61/// handshake. A mode change resets the drive's positioning function
62/// deterministically, so there is no `SP_ACK` to coax out of the drive — which
63/// removed three separate drive-specific failure modes on the Kollmorgen AKD
64/// (won't-ack-while-halted, won't-ack-with-bit-5, won't-ack-zero-velocity).
65/// This is the same mechanism (and `FbSetModeOfOperation`, with SDO
66/// write + 0x6061 read-back) that homing already uses.
67#[repr(u8)]
68#[derive(Debug, Clone, PartialEq, FromRepr)]
69enum HaltState {
70    /// Halt bit set + new_setpoint cleared. Polling position for stability
71    /// before flushing the queue.
72    WaitStopped = 0,
73    /// Flushing the set-point queue via the shared mode toggle ([`FbFlushQueue`]).
74    /// Halt stays asserted until the flush completes; then it is released.
75    Flushing = 10,
76}
77
78// ──────────────────────────────────────────────
79// Shared set-point-queue flush
80// ──────────────────────────────────────────────
81
82/// Phase of a [`FbFlushQueue`] mode toggle.
83#[derive(Debug, Clone, Copy, PartialEq)]
84enum FlushPhase {
85    Idle,
86    /// Switching the mode of operation AWAY from PP (to Homing).
87    Away,
88    /// Switching the mode of operation BACK to PP.
89    Back,
90    Done,
91    Error,
92}
93
94/// The **one** way the FB flushes a CiA-402 drive's set-point queue: toggle the
95/// mode of operation PP → Homing → PP. A mode change resets the positioning
96/// function deterministically, so there is no set-point-acknowledge handshake to
97/// coax out of the drive (which removed three drive-specific AKD failure modes).
98/// Shared by the halt close-out and both soft-homing stops so there is a single
99/// queue-flush mechanism rather than several near-identical ones.
100///
101/// Drive with [`start`](Self::start) then [`tick`](Self::tick) until
102/// `!is_busy()`, then check [`is_error`](Self::is_error). The mode switch goes
103/// over SDO (write 0x6060 + read-back 0x6061 via [`FbSetModeOfOperation`]), the
104/// same path homing uses — required by drives that pin mode by SDO (the AKD).
105#[derive(Debug, Clone)]
106struct FbFlushQueue {
107    phase: FlushPhase,
108    mode_fb: FbSetModeOfOperation,
109}
110
111impl FbFlushQueue {
112    fn new() -> Self {
113        Self { phase: FlushPhase::Idle, mode_fb: FbSetModeOfOperation::new() }
114    }
115
116    /// Begin the flush: switch the mode AWAY from PP (to Homing).
117    fn start(&mut self, view: &mut impl AxisView, client: &mut CommandClient, sdo: &mut SdoClient) {
118        self.begin_switch(view, client, sdo, ModesOfOperation::Homing);
119        self.phase = FlushPhase::Away;
120    }
121
122    fn begin_switch(
123        &mut self,
124        view: &mut impl AxisView,
125        client: &mut CommandClient,
126        sdo: &mut SdoClient,
127        mode: ModesOfOperation,
128    ) {
129        view.set_modes_of_operation(mode.as_i8());
130        self.mode_fb.reset();
131        self.mode_fb.start(mode.as_i8());
132        self.mode_fb.tick(client, sdo);
133    }
134
135    fn tick(&mut self, view: &mut impl AxisView, client: &mut CommandClient, sdo: &mut SdoClient) {
136        match self.phase {
137            FlushPhase::Away => {
138                self.mode_fb.tick(client, sdo);
139                if !self.mode_fb.is_busy() {
140                    if self.mode_fb.is_error() {
141                        self.phase = FlushPhase::Error;
142                    } else {
143                        // Queue flushed; switch BACK to PP.
144                        self.begin_switch(view, client, sdo, ModesOfOperation::ProfilePosition);
145                        self.phase = FlushPhase::Back;
146                    }
147                }
148            }
149            FlushPhase::Back => {
150                self.mode_fb.tick(client, sdo);
151                if !self.mode_fb.is_busy() {
152                    self.phase = if self.mode_fb.is_error() {
153                        FlushPhase::Error
154                    } else {
155                        FlushPhase::Done
156                    };
157                }
158            }
159            _ => {}
160        }
161    }
162
163    fn is_busy(&self) -> bool {
164        matches!(self.phase, FlushPhase::Away | FlushPhase::Back)
165    }
166    fn is_error(&self) -> bool {
167        self.phase == FlushPhase::Error
168    }
169    fn error_code(&self) -> i32 {
170        self.mode_fb.error_code()
171    }
172    fn error_message(&self) -> String {
173        self.mode_fb.error_message()
174    }
175}
176
177/// How long each halt sub-stage may take before we error out.
178const HALT_STAGE_TIMEOUT: Duration = Duration::from_secs(3);
179
180/// Raw-encoder-count window within which the axis is considered "stopped."
181/// Sized to tolerate servo micro-oscillation during closed-loop hold.
182/// On a 10 000 cnt/mm drive this is 0.005 mm — below any meaningful
183/// motion but well above typical ±5 count hold jitter.
184const HALT_STABLE_WINDOW: i32 = 50;
185
186/// Velocity magnitude (in raw drive units, typically counts/s) at or
187/// below which the axis is considered "not moving" for the purposes of
188/// completing a halt. Used alongside position stability so we don't
189/// require *both* perfect position and zero velocity — either reliable
190/// indicator counts.
191const HALT_STOPPED_VELOCITY: i32 = 100;
192
193/// Consecutive ticks of stability required before issuing cancel_move.
194/// At a 10 ms scan period, 5 ticks = ~50 ms dwell — long enough for the
195/// drive to have actually settled, short enough not to stall the cycle.
196const HALT_STABLE_TICKS_REQUIRED: u8 = 5;
197
198#[repr(u8)]
199#[derive(Debug, Clone, PartialEq, FromRepr)]
200enum HomeState {
201    EnsurePpMode = 0,
202    WaitPpMode = 1,
203    Search = 5,
204    WaitSearching = 10,
205    WaitFoundSensor = 20,
206    WaitStoppedFoundSensor = 30,
207    /// Flushing the set-point queue at the found-sensor stop (shared mode toggle).
208    FlushingFound = 40,
209    DebounceFoundSensor = 50,
210    BackOff = 60,
211    WaitBackingOff = 70,
212    WaitLostSensor = 80,
213    WaitStoppedLostSensor = 90,
214    /// Flushing the set-point queue at the lost-sensor stop (shared mode toggle).
215    FlushingLost = 100,
216    WaitHomeOffsetDone = 125,
217
218    WriteHomingModeOp = 160,
219    WaitWriteHomingModeOp = 165,
220
221    WriteHomingMethod = 205,
222    WaitWriteHomingMethodDone = 210,
223    ClearHomingTrigger = 215,
224    TriggerHoming = 217,
225    WaitHomingStarted = 218,
226    WaitHomingDone = 220,
227    ResetHomingTrigger = 222,
228    WaitHomingTriggerCleared = 223,
229    WriteMotionModeOfOperation = 230,
230    WaitWriteMotionModeOfOperation = 235,
231    SendCurrentPositionTarget = 240,
232    WaitCurrentPositionTargetSent = 245,
233}
234
235#[derive(Debug, Clone, PartialEq)]
236enum MoveKind {
237    Absolute,
238    Relative,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq)]
242enum SoftHomeSensor {
243    PositiveLimit,
244    NegativeLimit,
245    HomeSensor,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq)]
249enum SoftHomeSensorType {
250    /// PNP: sensor reads true when object detected (normally open)
251    Pnp,
252    /// NPN: sensor reads false when object detected (normally closed)
253    Npn,
254}
255
256// ──────────────────────────────────────────────
257// Axis
258// ──────────────────────────────────────────────
259
260/// Stateful motion controller for a CiA 402 servo drive.
261///
262/// Manages the CiA 402 protocol (state machine, PP handshake, homing)
263/// internally. Call [`tick()`](Self::tick) every control cycle to progress
264/// operations and update output fields.
265/// How many control-word/status-word *transitions* the per-axis trace keeps.
266/// Recorded only on change, so this is an event history, not a per-tick log —
267/// 48 transitions comfortably covers an enable → move → halt → cancel sequence.
268const CW_SW_TRACE_CAP: usize = 48;
269
270/// One entry in the CiA-402 transition trace: the op-state and the
271/// control/status words at the moment one of them changed.
272#[derive(Clone)]
273struct TraceEntry {
274    seq: u32,
275    op: AxisOp,
276    cw: u16,
277    sw: u16,
278}
279
280/// Decode the PP-relevant control-word bits into a compact `A|B|C` string.
281fn decode_cw(cw: u16) -> String {
282    const BITS: [(u16, &str); 9] = [
283        (0, "SwOn"),
284        (1, "EnVolt"),
285        (2, "QStop"),
286        (3, "EnOp"),
287        (4, "NewSP"),
288        (5, "ChgSet"),
289        (6, "Rel"),
290        (7, "FltRst"),
291        (8, "HALT"),
292    ];
293    let f: Vec<&str> = BITS
294        .iter()
295        .filter(|(b, _)| cw & (1 << b) != 0)
296        .map(|(_, n)| *n)
297        .collect();
298    if f.is_empty() {
299        "-".to_string()
300    } else {
301        f.join("|")
302    }
303}
304
305/// Decode the status word: the CiA-402 state plus the PP handshake bits.
306fn decode_sw(sw: u16) -> String {
307    let mut f = vec![format!("{}", RawStatusWord(sw).state())];
308    for (bit, name) in [
309        (10, "TgtReached"),
310        (11, "IntLim"),
311        (12, "SP_ACK"),
312        (13, "FollowErr"),
313    ] {
314        if sw & (1 << bit) != 0 {
315            f.push(name.to_string());
316        }
317    }
318    f.join("|")
319}
320
321pub struct Axis {
322    config: AxisConfig,
323    sdo: SdoClient,
324
325    // ── Internal state ──
326    op: AxisOp,
327    home_offset: i32,
328    last_raw_position: i32,
329    op_started: Option<Instant>,
330    op_timeout: Duration,
331    homing_timeout: Duration,
332    move_start_timeout: Duration,
333    pending_move_target: i32,
334    pending_move_vel: u32,
335    pending_move_accel: u32,
336    pending_move_decel: u32,
337    homing_method: i8,
338    homing_sdo_tid: u32,
339    soft_home_sensor: SoftHomeSensor,
340    soft_home_sensor_type: SoftHomeSensorType,
341    soft_home_direction: f64,
342    halt_stable_count: u8,
343    prev_positive_limit: bool,
344    prev_negative_limit: bool,
345    prev_home_sensor: bool,
346
347    // ── CiA-402 transition trace (debugging aid) ──
348    cw_sw_trace: VecDeque<TraceEntry>,
349    trace_seq: u32,
350
351    fb_mode_of_operation: FbSetModeOfOperation,
352    /// Shared set-point-queue flush (mode toggle) used by the halt close-out
353    /// and both soft-homing stops.
354    flush: FbFlushQueue,
355
356    // ── Outputs (updated every tick) ──
357    /// True if a drive fault or operation timeout has occurred.
358    pub is_error: bool,
359    /// Drive error code (from status word or view error_code).
360    pub error_code: u32,
361    /// Human-readable error description.
362    pub error_message: String,
363    /// True when the drive is in Operation Enabled state.
364    pub motor_on: bool,
365    /// True when any operation is in progress (enable, move, home, fault recovery, etc.).
366    ///
367    /// Derived from the internal state machine — immediately true when a command
368    /// is issued, false when the operation completes or a fault cancels it.
369    /// Use this (not [`in_motion`](Self::in_motion)) to wait for operations to finish.
370    pub is_busy: bool,
371    /// True while a move operation specifically is active (subset of [`is_busy`](Self::is_busy)).
372    pub in_motion: bool,
373    /// True when velocity is positive.
374    pub moving_positive: bool,
375    /// True when velocity is negative.
376    pub moving_negative: bool,
377    /// Current position in user units (relative to home).
378    pub position: f64,
379    /// Current position in raw encoder counts (widened from i32).
380    pub raw_position: i64,
381    /// Current speed in user units/s (absolute value).
382    pub speed: f64,
383    /// True when position is at or beyond the maximum software limit.
384    pub at_max_limit: bool,
385    /// True when position is at or beyond the minimum software limit.
386    pub at_min_limit: bool,
387    /// True when the positive-direction hardware limit switch is active.
388    pub at_positive_limit_switch: bool,
389    /// True when the negative-direction hardware limit switch is active.
390    pub at_negative_limit_switch: bool,
391    /// True when the home reference sensor is active.
392    pub home_sensor: bool,
393
394    /// Timer used for delays between states.
395    ton: Ton,
396}
397
398impl Axis {
399    /// Create a new Axis with the given configuration.
400    ///
401    /// `device_name` must match the device name in `project.json`
402    /// (used for SDO operations during homing).
403    pub fn new(config: AxisConfig, device_name: &str) -> Self {
404        let op_timeout = Duration::from_secs_f64(config.operation_timeout_secs);
405        let homing_timeout = Duration::from_secs_f64(config.homing_timeout_secs);
406        let move_start_timeout = op_timeout; // reuse operation timeout for move handshake
407        Self {
408            config,
409            sdo: SdoClient::new(device_name),
410            op: AxisOp::Idle,
411            home_offset: 0,
412            last_raw_position: 0,
413            op_started: None,
414            op_timeout,
415            homing_timeout,
416            move_start_timeout,
417            pending_move_target: 0,
418            pending_move_vel: 0,
419            pending_move_accel: 0,
420            pending_move_decel: 0,
421            homing_method: 37,
422            homing_sdo_tid: 0,
423            soft_home_sensor: SoftHomeSensor::HomeSensor,
424            soft_home_sensor_type: SoftHomeSensorType::Pnp,
425            soft_home_direction: 1.0,
426            halt_stable_count: 0,
427            prev_positive_limit: false,
428            prev_negative_limit: false,
429            prev_home_sensor: false,
430            cw_sw_trace: VecDeque::new(),
431            trace_seq: 0,
432            is_error: false,
433            error_code: 0,
434            error_message: String::new(),
435            motor_on: false,
436            is_busy: false,
437            in_motion: false,
438            moving_positive: false,
439            moving_negative: false,
440            position: 0.0,
441            raw_position: 0,
442            speed: 0.0,
443            at_max_limit: false,
444            at_min_limit: false,
445            at_positive_limit_switch: false,
446            at_negative_limit_switch: false,
447            home_sensor: false,
448            ton: Ton::new(),
449            fb_mode_of_operation: FbSetModeOfOperation::new(),
450            flush: FbFlushQueue::new(),
451        }
452    }
453
454    /// Get a reference to the axis configuration.
455    pub fn config(&self) -> &AxisConfig {
456        &self.config
457    }
458
459    // ═══════════════════════════════════════════
460    // Motion commands
461    // ═══════════════════════════════════════════
462
463    /// Start an absolute move to `target` in user units.
464    ///
465    /// The axis must be enabled (Operation Enabled) before calling this.
466    /// If the target exceeds a software position limit, the move is rejected
467    /// and [`is_error`](Self::is_error) is set.
468    pub fn move_absolute(
469        &mut self,
470        view: &mut impl AxisView,
471        target: f64,
472        vel: f64,
473        accel: f64,
474        decel: f64,
475    ) {
476        if let Some(msg) = self.check_target_limit(target, view) {
477            self.set_op_error(&msg);
478            return;
479        }
480
481        let cpu = self.config.counts_per_user();
482        let raw_target = self.config.to_counts(target).round() as i32 + self.home_offset;
483        let raw_vel = (vel * cpu).round() as u32;
484        let raw_accel = (accel * cpu).round() as u32;
485        let raw_decel = (decel * cpu).round() as u32;
486
487        // User-frame direction (consistent with sw_max/sw_min and hw_pos/hw_neg).
488        let user_pos = target > self.position;
489        let user_neg = target < self.position;
490
491        self.start_move(
492            view,
493            raw_target,
494            raw_vel,
495            raw_accel,
496            raw_decel,
497            MoveKind::Absolute,
498            user_pos,
499            user_neg,
500        );
501    }
502
503    /// Start a relative move by `distance` user units from the current position.
504    ///
505    /// The axis must be enabled (Operation Enabled) before calling this.
506    /// If the resulting position would exceed a software position limit,
507    /// the move is rejected and [`is_error`](Self::is_error) is set.
508    pub fn move_relative(
509        &mut self,
510        view: &mut impl AxisView,
511        distance: f64,
512        vel: f64,
513        accel: f64,
514        decel: f64,
515    ) {
516        log::info!(
517            "Axis: request to move relative dist {} vel {} accel {} decel {}",
518            distance,
519            vel,
520            accel,
521            decel
522        );
523
524        if let Some(msg) = self.check_target_limit(self.position + distance, view) {
525            self.set_op_error(&msg);
526            return;
527        }
528
529        let cpu = self.config.counts_per_user();
530        let raw_distance = self.config.to_counts(distance).round() as i32;
531        let raw_vel = (vel * cpu).round() as u32;
532        let raw_accel = (accel * cpu).round() as u32;
533        let raw_decel = (decel * cpu).round() as u32;
534
535        log::info!("Axis starting relative move: request to move relative raw dist {} raw vel {} raw accel {} raw decel {}",
536            raw_distance, raw_vel, raw_accel, raw_decel
537        );
538
539        // Make sure bit 4 is off so that a new move can be commanded.
540        let mut cw = RawControlWord(view.control_word());
541        cw.set_bit(4, false); // new set-point
542        view.set_control_word(cw.raw());
543
544        // User-frame direction is the sign of `distance` (which is in user units).
545        let user_pos = distance > 0.0;
546        let user_neg = distance < 0.0;
547
548        self.start_move(
549            view,
550            raw_distance,
551            raw_vel,
552            raw_accel,
553            raw_decel,
554            MoveKind::Relative,
555            user_pos,
556            user_neg,
557        );
558    }
559
560    fn start_move(
561        &mut self,
562        view: &mut impl AxisView,
563        raw_target: i32,
564        raw_vel: u32,
565        raw_accel: u32,
566        raw_decel: u32,
567        kind: MoveKind,
568        user_pos: bool,
569        user_neg: bool,
570    ) {
571        self.pending_move_target = raw_target;
572        self.pending_move_vel = raw_vel;
573        self.pending_move_accel = raw_accel;
574        self.pending_move_decel = raw_decel;
575
576        // Set parameters on view
577        view.set_target_position(raw_target);
578        view.set_profile_velocity(raw_vel);
579        view.set_profile_acceleration(raw_accel);
580        view.set_profile_deceleration(raw_decel);
581
582        // Set control word: relative bit + trigger (new set-point).
583        // We also clear halt as belt-and-suspenders — limit-switch halts and
584        // explicit halt() both flow through the Halting state machine, which
585        // ends with halt already cleared, so this is normally a no-op.
586        let mut cw = RawControlWord(view.control_word());
587        cw.set_bit(6, kind == MoveKind::Relative);
588        cw.set_bit(8, false); // clear halt
589        cw.set_bit(4, true); // new set-point
590        view.set_control_word(cw.raw());
591
592        // pos/neg stored in AxisOp::Moving are USER-frame so that check_limits
593        // can compare them directly against user-frame software limits and
594        // the integrator's user-frame limit-switch wiring without an
595        // axis-inversion swap.
596        self.op = AxisOp::Moving(kind, 1, user_pos, user_neg);
597        self.op_started = Some(Instant::now());
598    }
599
600    /// Halt the current move (decelerate to stop).
601    ///
602    /// This is a **multi-tick** operation. `halt()` starts the sequence:
603    ///
604    /// 1. Halt bit (CW 8) set, new_setpoint (CW 4) cleared.
605    /// 2. Wait for motor position to stabilize for ~100 ms.
606    /// 3. Issue cancel_move with current_position as target.
607    /// 4. Wait for setpoint_ack (SW 12) + target_reached (SW 10).
608    /// 5. Clear new_setpoint, set single_setpoint (CW 5).
609    /// 6. Wait for setpoint_ack to drop.
610    /// 7. Return to Idle.
611    ///
612    /// [`is_busy`](Self::is_busy) stays `true` for the whole sequence.
613    /// Callers that wait on `!is_busy()` after `halt()` (e.g.
614    /// [`super::move_to_load::MoveToLoad`]) will correctly block until
615    /// the drive's PP handshake is fully cleaned up, preventing a
616    /// "set-point not acknowledged" timeout on the *next* move.
617    pub fn halt(&mut self, view: &mut impl AxisView) {
618        self.command_halt(view);
619        self.halt_stable_count = 0;
620        self.last_raw_position = view.position_actual();
621        self.op_started = Some(Instant::now());
622        self.op = AxisOp::Halting(HaltState::WaitStopped as u8);
623    }
624
625    // ═══════════════════════════════════════════
626    // Drive control
627    // ═══════════════════════════════════════════
628
629    /// Start the enable sequence (Shutdown → ReadyToSwitchOn → OperationEnabled).
630    ///
631    /// The sequence is multi-tick. Check [`motor_on`](Self::motor_on) for completion.
632    pub fn enable(&mut self, view: &mut impl AxisView) {
633        // Step 0: set PP mode + cmd_shutdown
634        view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
635        let mut cw = RawControlWord(view.control_word());
636        cw.cmd_shutdown();
637        view.set_control_word(cw.raw());
638
639        self.op = AxisOp::Enabling(1);
640        self.op_started = Some(Instant::now());
641    }
642
643    /// Start the disable sequence (OperationEnabled → ReadyToSwitchOn).
644    ///
645    /// Uses the CiA 402 Shutdown command (controlword 0x06) rather than
646    /// Disable Operation (0x07). Per the spec, "Switched On" leaves motor
647    /// energization vendor-defined — Teknic and Inovance happen to
648    /// de-energize, Kollmorgen AKD keeps holding current. "Ready To
649    /// Switch On" has the HV section *guaranteed* off, so torque drops
650    /// on every compliant drive.
651    pub fn disable(&mut self, view: &mut impl AxisView) {
652        let mut cw = RawControlWord(view.control_word());
653        cw.cmd_shutdown();
654        cw.set_bit(4, false);
655        cw.set_bit(8, false);
656        cw.set_bit(7, false);
657        cw.set_bit(2, true);
658        view.set_control_word(cw.raw());
659
660        self.op = AxisOp::Disabling(1);
661        self.op_started = Some(Instant::now());
662    }
663
664    /// Clear the axis error state.
665    ///
666    /// Two paths depending on whether the drive itself is faulted:
667    ///
668    /// - **Drive in CiA 402 Fault / FaultReactionActive**: runs the full
669    ///   reset sequence — clear bit 7, then assert it next tick (rising
670    ///   edge on the drive's fault-reset bit), then wait for the drive to
671    ///   leave the Fault state. `is_busy` stays true through this sequence.
672    ///
673    /// - **Drive healthy** (e.g. the `is_error` was set by a software
674    ///   software-limit overshoot, an operation timeout, or a limit-switch
675    ///   close-out timeout — the drive itself is still in OperationEnabled):
676    ///   no CiA 402 handshake is needed. Just clears the software error
677    ///   flags and returns the axis to Idle synchronously. `is_busy` goes
678    ///   to false on the same call.
679    pub fn reset_faults(&mut self, view: &mut impl AxisView) {
680        let sw = RawStatusWord(view.status_word());
681        let drive_in_fault = matches!(
682            sw.state(),
683            Cia402State::Fault | Cia402State::FaultReactionActive
684        );
685
686        self.is_error = false;
687        self.error_code = 0;
688        self.error_message.clear();
689
690        if drive_in_fault {
691            // Step 0: clear bit 7 first (so next step produces a rising edge).
692            let mut cw = RawControlWord(view.control_word());
693            cw.cmd_clear_fault_reset();
694            view.set_control_word(cw.raw());
695            self.op = AxisOp::FaultRecovery(1);
696            self.op_started = Some(Instant::now());
697        } else {
698            // Software-only error — no drive handshake needed.
699            self.op = AxisOp::Idle;
700            self.op_started = None;
701            self.is_busy = false;
702            self.in_motion = false;
703        }
704    }
705
706    /// Start a homing sequence with the given homing method.
707    ///
708    /// **Integrated** methods delegate to the drive's built-in CiA 402
709    /// homing mode (SDO writes + homing trigger).
710    ///
711    /// **Software** methods are implemented by the Axis, which monitors
712    /// [`AxisView`] sensor signals for edge triggers and captures home.
713    pub fn home(&mut self, view: &mut impl AxisView, method: HomingMethod) {
714        if method.is_integrated() {
715            self.homing_method = match method {
716                HomingMethod::CurrentPosition => self.config.soft_home_method,
717                _ => method.cia402_code(),
718            };
719            self.op = AxisOp::Homing(0);
720            self.op_started = Some(Instant::now());
721            let _ = view;
722        } else {
723            self.configure_soft_homing(method);
724            self.start_soft_homing(view);
725        }
726    }
727
728    // ═══════════════════════════════════════════
729    // Position management
730    // ═══════════════════════════════════════════
731
732    /// Set the current position to the given user-unit value.
733    ///
734    /// Adjusts the internal home offset so that the current raw position
735    /// maps to `user_units`. Does not move the motor.
736    pub fn set_position(&mut self, view: &impl AxisView, user_units: f64) {
737        self.home_offset =
738            view.position_actual() - self.config.to_counts(user_units).round() as i32;
739    }
740
741    /// Set the current position to `user_units` and persist it to the drive.
742    ///
743    /// Writes `0x607C` (home offset) to the drive in encoder counts, then runs
744    /// a CiA 402 homing cycle with method [`AxisConfig::soft_home_method`] so
745    /// the drive's reported position (`0x6064`) reads `user_units` afterward.
746    /// No motor motion occurs.
747    ///
748    /// For drives with absolute encoders configured in linear mode (e.g.
749    /// Inovance SV660N with `0x2002:02 = 1`), the resulting encoder offset
750    /// (`0x2005:2F`/`0x2005:31`) is auto-saved to EEPROM, so the new reference
751    /// survives a power cycle.
752    ///
753    /// **Preconditions:** drive must be enabled (OperationEnabled) and at rest
754    /// (no move in progress). The operation switches to HM mode internally and
755    /// back to PP on completion. Poll [`is_busy`](Self::is_busy) to know when
756    /// it finishes.
757    pub fn set_position_persistent(&mut self, client: &mut CommandClient, user_units: f64) {
758        // Reuse the SoftHoming state machine starting from the post-607C-write
759        // step (WaitHomeOffsetDone). We issue the 607C SDO write here; the
760        // state machine handles the rest: wait for ack, switch to HM, write
761        // 6098 = soft_home_method, trigger homing, wait for completion, switch
762        // back to PP, and zero the software home_offset since the drive owns
763        // it now.
764        self.config.home_position = user_units;
765        let desired_counts = self.config.to_counts(user_units).round() as i32;
766        self.homing_sdo_tid = self.sdo.write(client, 0x607C, 0, json!(desired_counts));
767        self.op = AxisOp::SoftHoming(HomeState::WaitHomeOffsetDone as u8);
768        self.op_started = Some(Instant::now());
769        log::info!(
770            "set_position_persistent: writing 0x607C = {} counts ({} user units)",
771            desired_counts,
772            user_units
773        );
774    }
775
776    /// Set the home position in user units. This value is used by the next
777    /// `home()` call to set the axis position at the reference point.
778    /// Can be called at any time before homing.
779    pub fn set_home_position(&mut self, user_units: f64) {
780        self.config.home_position = user_units;
781    }
782
783    /// Set the maximum (positive) software position limit.
784    pub fn set_software_max_limit(&mut self, user_units: f64) {
785        self.config.max_position_limit = user_units;
786        self.config.enable_max_position_limit = true;
787    }
788
789    /// Set the minimum (negative) software position limit.
790    pub fn set_software_min_limit(&mut self, user_units: f64) {
791        self.config.min_position_limit = user_units;
792        self.config.enable_min_position_limit = true;
793    }
794
795    // ═══════════════════════════════════════════
796    // SDO pass-through
797    // ═══════════════════════════════════════════
798
799    /// Write an SDO value to the drive.
800    pub fn sdo_write(
801        &mut self,
802        client: &mut CommandClient,
803        index: u16,
804        sub_index: u8,
805        value: serde_json::Value,
806    ) {
807        self.sdo.write(client, index, sub_index, value);
808    }
809
810    /// Start an SDO read from the drive. Returns a transaction ID.
811    pub fn sdo_read(&mut self, client: &mut CommandClient, index: u16, sub_index: u8) -> u32 {
812        self.sdo.read(client, index, sub_index)
813    }
814
815    /// Check the result of a previous SDO read.
816    pub fn sdo_result(&mut self, client: &mut CommandClient, tid: u32) -> SdoResult {
817        self.sdo.result(client, tid, Duration::from_secs(5))
818    }
819
820    // ═══════════════════════════════════════════
821    // Tick — call every control cycle
822    // ═══════════════════════════════════════════
823
824    /// Update outputs and progress the current operation.
825    ///
826    /// Must be called every control cycle. Does three things:
827    /// 1. Checks for drive faults
828    /// 2. Progresses the current multi-tick operation
829    /// 3. Updates output fields (position, velocity, status)
830    ///
831    /// Outputs are updated last so they reflect the final state after
832    /// all processing for this tick.
833    pub fn tick(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
834        self.check_faults(view);
835        self.progress_op(view, client);
836        self.update_outputs(view);
837        self.check_limits(view);
838        self.record_trace(view);
839    }
840
841    // ═══════════════════════════════════════════
842    // Internal: CiA-402 transition trace (debug aid)
843    // ═══════════════════════════════════════════
844
845    /// Append to the trace if the op-state or the control/status words changed
846    /// since the last entry. Change-driven, so a multi-second stall (e.g. a halt
847    /// waiting on set-point-acknowledge) costs a single line, not one per tick.
848    fn record_trace(&mut self, view: &impl AxisView) {
849        let cw = view.control_word();
850        let sw = view.status_word();
851        let changed = self
852            .cw_sw_trace
853            .back()
854            .map_or(true, |e| e.cw != cw || e.sw != sw || e.op != self.op);
855        if !changed {
856            return;
857        }
858        if self.cw_sw_trace.len() >= CW_SW_TRACE_CAP {
859            self.cw_sw_trace.pop_front();
860        }
861        self.cw_sw_trace.push_back(TraceEntry {
862            seq: self.trace_seq,
863            op: self.op.clone(),
864            cw,
865            sw,
866        });
867        self.trace_seq = self.trace_seq.wrapping_add(1);
868    }
869
870    /// The recent CiA-402 control/status-word transitions, decoded into a
871    /// human-readable block. A debugging aid for handshake problems such as a
872    /// halt-cancel timeout: each line shows the op-state, the control word (with
873    /// `HALT`, `NewSP`, `ChgSet`, … flags) and the status word (CiA-402 state
874    /// plus `SP_ACK`, `TgtReached`, …). Because entries are recorded only on
875    /// change, this is a compact event log rather than a per-tick PDO dump.
876    ///
877    /// It is also dumped automatically to the log whenever an operation errors
878    /// (see the `Axis error:` log line), so failures arrive with the sequence
879    /// that produced them.
880    pub fn trace(&self) -> String {
881        let mut out = String::new();
882        for e in &self.cw_sw_trace {
883            out.push_str(&format!(
884                "  [{:>4}] {:<20} CW={:#06x} [{}]  SW={:#06x} [{}]\n",
885                e.seq,
886                format!("{:?}", e.op),
887                e.cw,
888                decode_cw(e.cw),
889                e.sw,
890                decode_sw(e.sw),
891            ));
892        }
893        out
894    }
895
896    // ═══════════════════════════════════════════
897    // Internal: output update
898    // ═══════════════════════════════════════════
899
900    fn update_outputs(&mut self, view: &impl AxisView) {
901        let raw = view.position_actual();
902        self.raw_position = raw as i64;
903        self.position = self.config.to_user((raw - self.home_offset) as f64);
904
905        let vel = view.velocity_actual();
906        let user_vel = self.config.to_user(vel as f64);
907        self.speed = user_vel.abs();
908        self.moving_positive = user_vel > 0.0;
909        self.moving_negative = user_vel < 0.0;
910        self.is_busy = self.op != AxisOp::Idle;
911        self.in_motion = matches!(self.op, AxisOp::Moving(_, _, _, _) | AxisOp::SoftHoming(_));
912
913        let sw = RawStatusWord(view.status_word());
914        self.motor_on = sw.state() == Cia402State::OperationEnabled;
915
916        self.last_raw_position = raw;
917    }
918
919    // ═══════════════════════════════════════════
920    // Internal: fault check
921    // ═══════════════════════════════════════════
922
923    fn check_faults(&mut self, view: &impl AxisView) {
924        let sw = RawStatusWord(view.status_word());
925        let state = sw.state();
926
927        if matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
928            if !matches!(self.op, AxisOp::FaultRecovery(_)) {
929                self.is_error = true;
930                let ec = view.error_code();
931                if ec != 0 {
932                    self.error_code = ec as u32;
933                }
934                self.error_message = format!("Drive fault (state: {})", state);
935                // Cancel the current operation so is_busy goes false
936                self.op = AxisOp::Idle;
937                self.op_started = None;
938            }
939        }
940    }
941
942    // ═══════════════════════════════════════════
943    // Internal: operation timeout helper
944    // ═══════════════════════════════════════════
945
946    fn op_timed_out(&self) -> bool {
947        self.op_started
948            .map_or(false, |t| t.elapsed() > self.op_timeout)
949    }
950
951    fn homing_timed_out(&self) -> bool {
952        self.op_started
953            .map_or(false, |t| t.elapsed() > self.homing_timeout)
954    }
955
956    fn move_start_timed_out(&self) -> bool {
957        self.op_started
958            .map_or(false, |t| t.elapsed() > self.move_start_timeout)
959    }
960
961    /// Has the current operation exceeded the supplied stage timeout?
962    /// Used by the halt state machine so each sub-stage gets its own
963    /// budget rather than sharing the general `op_timeout`.
964    fn op_stage_timed_out(&self, limit: Duration) -> bool {
965        self.op_started.map_or(false, |t| t.elapsed() > limit)
966    }
967
968    fn set_op_error(&mut self, msg: &str) {
969        self.is_error = true;
970        self.error_message = msg.to_string();
971        log::error!("Axis error: {}", msg);
972        // Dump the CiA-402 handshake trace so failures (e.g. a halt-cancel
973        // timeout) arrive with the control/status-word sequence that led to
974        // them — far less data than logging every PDO every tick.
975        if !self.cw_sw_trace.is_empty() {
976            log::warn!(
977                "Axis CiA-402 trace ({} recent transitions, oldest first):\n{}",
978                self.cw_sw_trace.len(),
979                self.trace(),
980            );
981        }
982        self.op = AxisOp::Idle;
983        self.op_started = None;
984        self.is_busy = false;
985        self.in_motion = false;
986    }
987
988    fn restore_pp_after_error(&mut self, msg: &str) {
989        self.is_error = true;
990        self.error_message = msg.to_string();
991        self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);
992        log::error!("Axis error: {}", msg);
993    }
994
995    fn finish_op_error(&mut self) {
996        self.op = AxisOp::Idle;
997        self.op_started = None;
998        self.is_busy = false;
999        self.in_motion = false;
1000    }
1001
1002    fn complete_op(&mut self) {
1003        self.op = AxisOp::Idle;
1004        self.op_started = None;
1005        // Clear busy/motion flags directly so callers that observe state on
1006        // the same tick (e.g. check_limits, which runs after update_outputs)
1007        // see Idle immediately. For complete_op calls inside progress_op,
1008        // update_outputs would recompute these to the same values anyway.
1009        self.is_busy = false;
1010        self.in_motion = false;
1011    }
1012
1013    // ═══════════════════════════════════════════
1014    // Internal: position limits and limit switches
1015    // ═══════════════════════════════════════════
1016
1017    /// Resolve the effective maximum software limit for this tick, combining
1018    /// the static [`AxisConfig`] value (if enabled) with any dynamic limit
1019    /// supplied by the [`AxisView`] (e.g. a GM-linked variable). The most
1020    /// restrictive (smallest) value wins.
1021    fn effective_max_limit(&self, view: &impl AxisView) -> Option<f64> {
1022        let static_limit = if self.config.enable_max_position_limit {
1023            Some(self.config.max_position_limit)
1024        } else {
1025            None
1026        };
1027        match (static_limit, view.dynamic_max_position_limit()) {
1028            (Some(s), Some(d)) => Some(s.min(d)),
1029            (Some(v), None) | (None, Some(v)) => Some(v),
1030            (None, None) => None,
1031        }
1032    }
1033
1034    /// Resolve the effective minimum software limit for this tick. See
1035    /// [`effective_max_limit`](Self::effective_max_limit) — the most
1036    /// restrictive (largest) value wins.
1037    fn effective_min_limit(&self, view: &impl AxisView) -> Option<f64> {
1038        let static_limit = if self.config.enable_min_position_limit {
1039            Some(self.config.min_position_limit)
1040        } else {
1041            None
1042        };
1043        match (static_limit, view.dynamic_min_position_limit()) {
1044            (Some(s), Some(d)) => Some(s.max(d)),
1045            (Some(v), None) | (None, Some(v)) => Some(v),
1046            (None, None) => None,
1047        }
1048    }
1049
1050    /// Check whether a target position (in user units) exceeds a software limit.
1051    /// Returns `Some(error_message)` if the target is out of range, `None` if OK.
1052    /// Consults both the static [`AxisConfig`] limits and any dynamic limits
1053    /// exposed by the view, taking whichever is most restrictive.
1054    fn check_target_limit(&self, target: f64, view: &impl AxisView) -> Option<String> {
1055        if let Some(max) = self.effective_max_limit(view) {
1056            if target > max {
1057                return Some(format!(
1058                    "Target {:.3} exceeds max software limit {:.3}",
1059                    target, max
1060                ));
1061            }
1062        }
1063        if let Some(min) = self.effective_min_limit(view) {
1064            if target < min {
1065                return Some(format!(
1066                    "Target {:.3} exceeds min software limit {:.3}",
1067                    target, min
1068                ));
1069            }
1070        }
1071        None
1072    }
1073
1074    /// Check software position limits, hardware limit switches, and home sensor.
1075    /// If a limit is violated and a move is in progress in that direction,
1076    /// halt the drive and set an error. Moving in the opposite direction is
1077    /// always allowed so the axis can be recovered.
1078    ///
1079    /// During software homing on a limit switch (`SoftHoming` + `SoftHomeSensor::PositiveLimit`
1080    /// or `NegativeLimit`), the homed-on switch is suppressed so it triggers a home
1081    /// event rather than an error halt. The opposite switch still protects.
1082    fn check_limits(&mut self, view: &mut impl AxisView) {
1083        // ── Software position limits (static config + dynamic GM-linked) ──
1084        let eff_max = self.effective_max_limit(view);
1085        let eff_min = self.effective_min_limit(view);
1086        let sw_max = eff_max.map_or(false, |m| self.position >= m);
1087        let sw_min = eff_min.map_or(false, |m| self.position <= m);
1088
1089        self.at_max_limit = sw_max;
1090        self.at_min_limit = sw_min;
1091
1092        // ── Hardware limit switches ──
1093        let hw_pos = view.positive_limit_active();
1094        let hw_neg = view.negative_limit_active();
1095
1096        self.at_positive_limit_switch = hw_pos;
1097        self.at_negative_limit_switch = hw_neg;
1098
1099        // ── Home sensor ──
1100        self.home_sensor = view.home_sensor_active();
1101
1102        // ── Save previous sensor state for next tick's edge detection ──
1103        self.prev_positive_limit = hw_pos;
1104        self.prev_negative_limit = hw_neg;
1105        self.prev_home_sensor = view.home_sensor_active();
1106
1107        // ── Halt logic (only while moving or soft-homing) ──
1108        let mut commanded_positive = false;
1109        let mut commanded_negative = false;
1110
1111        let is_moving = matches!(self.op, AxisOp::Moving(_, _, _, _));
1112        let is_soft_homing = matches!(self.op, AxisOp::SoftHoming(_));
1113
1114        if !is_moving && !is_soft_homing {
1115            return; // Only halt actively if we are currently driving into the limit
1116        }
1117
1118        match &self.op {
1119            AxisOp::Moving(_, _, pos, neg) => {
1120                // Already user-frame (set by move_absolute / move_relative).
1121                commanded_positive = *pos;
1122                commanded_negative = *neg;
1123            }
1124            AxisOp::SoftHoming(_) => match self.soft_home_sensor {
1125                SoftHomeSensor::PositiveLimit => commanded_positive = true,
1126                SoftHomeSensor::NegativeLimit => commanded_negative = true,
1127                SoftHomeSensor::HomeSensor => {
1128                    commanded_positive = self.moving_positive;
1129                    commanded_negative = self.moving_negative;
1130                }
1131            },
1132            _ => {}
1133        }
1134
1135        // During software homing, suppress the limit switch being homed on
1136        let suppress_pos = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::PositiveLimit;
1137        let suppress_neg = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::NegativeLimit;
1138
1139        let effective_hw_pos = hw_pos && !suppress_pos;
1140        let effective_hw_neg = hw_neg && !suppress_neg;
1141
1142        // During soft homing, suppress software limits too (we need to move freely)
1143        let effective_sw_max = sw_max && !is_soft_homing;
1144        let effective_sw_min = sw_min && !is_soft_homing;
1145
1146        let positive_blocked = (effective_sw_max || effective_hw_pos) && commanded_positive;
1147        let negative_blocked = (effective_sw_min || effective_hw_neg) && commanded_negative;
1148
1149        if positive_blocked || negative_blocked {
1150            let mut cw = RawControlWord(view.control_word());
1151            cw.set_bit(8, true); // halt
1152            view.set_control_word(cw.raw());
1153
1154            let msg = if effective_hw_pos && commanded_positive {
1155                "Positive limit switch active".to_string()
1156            } else if effective_hw_neg && commanded_negative {
1157                "Negative limit switch active".to_string()
1158            } else if effective_sw_max && commanded_positive {
1159                format!(
1160                    "Software position limit: position {:.3} >= max {:.3}",
1161                    self.position,
1162                    eff_max.unwrap_or(self.position)
1163                )
1164            } else {
1165                format!(
1166                    "Software position limit: position {:.3} <= min {:.3}",
1167                    self.position,
1168                    eff_min.unwrap_or(self.position)
1169                )
1170            };
1171
1172            if is_soft_homing {
1173                // Hitting the *opposite* limit during soft homing is a real
1174                // fault — the axis went the wrong way and must be reset.
1175                self.set_op_error(&msg);
1176            } else {
1177                // Regular move into a limit: enter the multi-stage Halting
1178                // close-out (wait for motor stop → cancel queued setpoint →
1179                // clear halt) so the drive ends in a clean state. Just
1180                // clearing halt isn't enough — the previous move's target is
1181                // still queued in the drive's PP buffer, and on the next
1182                // start_move some drives ignore the new setpoint until that
1183                // queued one is canceled.
1184                //
1185                // is_busy stays true through the close-out. Callers detect
1186                // why the move ended by inspecting
1187                // `at_positive_limit_switch` / `at_negative_limit_switch` /
1188                // `at_max_limit` / `at_min_limit` once `!is_busy()`.
1189                log::info!("Axis move halted by limit: {}", msg);
1190                self.command_halt(view);
1191                self.halt_stable_count = 0;
1192                self.last_raw_position = view.position_actual();
1193                self.op_started = Some(Instant::now());
1194                self.op = AxisOp::Halting(HaltState::WaitStopped as u8);
1195            }
1196        }
1197    }
1198
1199    // ═══════════════════════════════════════════
1200    // Internal: operation progress
1201    // ═══════════════════════════════════════════
1202
1203    fn progress_op(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
1204        match self.op.clone() {
1205            AxisOp::Idle => {}
1206            AxisOp::Enabling(step) => self.tick_enabling(view, step),
1207            AxisOp::Disabling(step) => self.tick_disabling(view, step),
1208            AxisOp::Moving(kind, step, pos, neg) => self.tick_moving(view, kind, step, pos, neg),
1209            AxisOp::Homing(step) => self.tick_homing(view, client, step),
1210            AxisOp::SoftHoming(step) => self.tick_soft_homing(view, client, step),
1211            AxisOp::Halting(step) => self.tick_halting(view, client, step),
1212            AxisOp::FaultRecovery(step) => self.tick_fault_recovery(view, step),
1213        }
1214    }
1215
1216    // ── Enabling ──
1217    // Step 0: (done in enable()) ensure PP + cmd_shutdown
1218    // Step 1: wait ReadyToSwitchOn → cmd_enable_operation
1219    // Step 2: wait OperationEnabled → capture home → Idle
1220    fn tick_enabling(&mut self, view: &mut impl AxisView, step: u8) {
1221        match step {
1222            1 => {
1223                let sw = RawStatusWord(view.status_word());
1224                if sw.state() == Cia402State::ReadyToSwitchOn {
1225                    let mut cw = RawControlWord(view.control_word());
1226                    cw.cmd_enable_operation();
1227                    view.set_control_word(cw.raw());
1228                    self.op = AxisOp::Enabling(2);
1229                } else if self.op_timed_out() {
1230                    self.set_op_error("Enable timeout: waiting for ReadyToSwitchOn");
1231                }
1232            }
1233            2 => {
1234                let sw = RawStatusWord(view.status_word());
1235                if sw.state() == Cia402State::OperationEnabled {
1236                    // NO - We do not do software-based encoder. That would break absolute encoders.
1237                    // self.home_offset = view.position_actual();
1238                    // log::info!("Axis enabled — home captured at {}", self.home_offset);
1239
1240                    // Possible TODO: Read the home_offset in the drive?
1241
1242                    self.complete_op();
1243                } else if self.op_timed_out() {
1244                    self.set_op_error("Enable timeout: waiting for OperationEnabled");
1245                }
1246            }
1247            _ => self.complete_op(),
1248        }
1249    }
1250
1251    // ── Disabling ──
1252    // Step 0: (done in disable()) cmd_shutdown
1253    // Step 1: wait not OperationEnabled → Idle
1254    fn tick_disabling(&mut self, view: &mut impl AxisView, step: u8) {
1255        match step {
1256            1 => {
1257                let sw = RawStatusWord(view.status_word());
1258                if sw.state() != Cia402State::OperationEnabled {
1259                    self.complete_op();
1260                } else if self.op_timed_out() {
1261                    self.set_op_error("Disable timeout: drive still in OperationEnabled");
1262                }
1263            }
1264            _ => self.complete_op(),
1265        }
1266    }
1267
1268    // ── Moving ──
1269    // Step 0: (done in move_absolute/relative()) set params + trigger
1270    // Step 1: wait set_point_acknowledge → ack
1271    // Step 2: wait ack cleared (one tick)
1272    // Step 3: wait target_reached → Idle
1273    fn tick_moving(
1274        &mut self,
1275        view: &mut impl AxisView,
1276        kind: MoveKind,
1277        step: u8,
1278        pos: bool,
1279        neg: bool,
1280    ) {
1281        match step {
1282            1 => {
1283                // Wait for set-point acknowledge (bit 12)
1284                let sw = RawStatusWord(view.status_word());
1285                if sw.raw() & (1 << 12) != 0 {
1286                    // Ack: clear new set-point (bit 4)
1287                    let mut cw = RawControlWord(view.control_word());
1288                    cw.set_bit(4, false);
1289                    view.set_control_word(cw.raw());
1290                    self.op = AxisOp::Moving(kind, 2, pos, neg);
1291                } else if self.move_start_timed_out() {
1292                    self.set_op_error("Move timeout: set-point not acknowledged");
1293                }
1294            }
1295            2 => {
1296                // Wait for the drive to confirm it saw Bit 4 go low
1297                let sw = RawStatusWord(view.status_word());
1298                if sw.raw() & (1 << 12) == 0 {
1299                    // Handshake is officially reset. Now wait for physics.
1300                    self.op = AxisOp::Moving(kind, 3, pos, neg);
1301                }
1302            }
1303            3 => {
1304                // Wait for target reached (bit 10) — no timeout, moves can take arbitrarily long
1305                let sw = RawStatusWord(view.status_word());
1306                if sw.target_reached() {
1307                    self.complete_op();
1308                }
1309            }
1310            _ => self.complete_op(),
1311        }
1312    }
1313
1314    // ── Homing (hardware-delegated) ──
1315    // Step 0:  write homing method SDO (0x6098:0)
1316    // Step 1:  wait SDO ack
1317    // Step 2:  write homing speed SDO (0x6099:1 — search for switch)
1318    // Step 3:  wait SDO ack
1319    // Step 4:  write homing speed SDO (0x6099:2 — search for zero)
1320    // Step 5:  wait SDO ack
1321    // Step 6:  write homing accel SDO (0x609A:0)
1322    // Step 7:  wait SDO ack
1323    // Step 8:  switch to Homing mode (PDO field + SDO write 0x6060)
1324    // Step 9:  wait for Homing mode written + verified via SDO read-back (0x6061)
1325    // Step 10: trigger homing (rising edge on CW bit 4)
1326    // Step 11: wait for drive to acknowledge start (bit 12 clears)
1327    // Step 12: wait homing complete (bits 10+12 set, 13 clear)
1328    // Step 13: capture home offset, clear CW bit 4
1329    // Step 14: switch back to PP mode (PDO field + SDO write 0x6060)
1330    // Step 15: wait for PP mode written + verified, then → Idle
1331    //
1332    // If homing_speed and homing_accel are both 0, steps 2-7 are skipped
1333    // (preserves backward compatibility for users who pre-configure via SDO).
1334    //
1335    // Mode is switched AND verified over SDO (via FbSetModeOfOperation) rather
1336    // than trusting modes_of_operation_display(): on drives that don't PDO-map
1337    // 0x6060/0x6061 (e.g. Kollmorgen AKD) the cyclic display field is a codegen
1338    // stub mirrored from the commanded value, so it would falsely confirm.
1339    fn tick_homing(&mut self, view: &mut impl AxisView, client: &mut CommandClient, step: u8) {
1340        match step {
1341            0 => {
1342                // Write homing method via SDO (0x6098:0)
1343                self.homing_sdo_tid = self.sdo.write(client, 0x6098, 0, json!(self.homing_method));
1344                self.op = AxisOp::Homing(1);
1345            }
1346            1 => {
1347                // Wait for SDO write ack
1348                match self
1349                    .sdo
1350                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
1351                {
1352                    SdoResult::Ok(_) => {
1353                        // Skip speed/accel SDOs if both are zero
1354                        if self.config.homing_speed == 0.0 && self.config.homing_accel == 0.0 {
1355                            self.op = AxisOp::Homing(8);
1356                        } else {
1357                            self.op = AxisOp::Homing(2);
1358                        }
1359                    }
1360                    SdoResult::Pending => {
1361                        if self.homing_timed_out() {
1362                            self.set_op_error("Homing timeout: SDO write for homing method");
1363                        }
1364                    }
1365                    SdoResult::Err(e) => {
1366                        self.set_op_error(&format!("Homing SDO error: {}", e));
1367                    }
1368                    SdoResult::Timeout => {
1369                        self.set_op_error("Homing timeout: SDO write timed out");
1370                    }
1371                }
1372            }
1373            2 => {
1374                // Write homing speed (0x6099:1 — search for switch)
1375                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
1376                self.homing_sdo_tid = self.sdo.write(client, 0x6099, 1, json!(speed_counts));
1377                self.op = AxisOp::Homing(3);
1378            }
1379            3 => {
1380                match self
1381                    .sdo
1382                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
1383                {
1384                    SdoResult::Ok(_) => {
1385                        self.op = AxisOp::Homing(4);
1386                    }
1387                    SdoResult::Pending => {
1388                        if self.homing_timed_out() {
1389                            self.set_op_error(
1390                                "Homing timeout: SDO write for homing speed (switch)",
1391                            );
1392                        }
1393                    }
1394                    SdoResult::Err(e) => {
1395                        self.set_op_error(&format!("Homing SDO error: {}", e));
1396                    }
1397                    SdoResult::Timeout => {
1398                        self.set_op_error("Homing timeout: SDO write timed out");
1399                    }
1400                }
1401            }
1402            4 => {
1403                // Write homing speed (0x6099:2 — search for zero, same value)
1404                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
1405                self.homing_sdo_tid = self.sdo.write(client, 0x6099, 2, json!(speed_counts));
1406                self.op = AxisOp::Homing(5);
1407            }
1408            5 => {
1409                match self
1410                    .sdo
1411                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
1412                {
1413                    SdoResult::Ok(_) => {
1414                        self.op = AxisOp::Homing(6);
1415                    }
1416                    SdoResult::Pending => {
1417                        if self.homing_timed_out() {
1418                            self.set_op_error("Homing timeout: SDO write for homing speed (zero)");
1419                        }
1420                    }
1421                    SdoResult::Err(e) => {
1422                        self.set_op_error(&format!("Homing SDO error: {}", e));
1423                    }
1424                    SdoResult::Timeout => {
1425                        self.set_op_error("Homing timeout: SDO write timed out");
1426                    }
1427                }
1428            }
1429            6 => {
1430                // Write homing acceleration (0x609A:0)
1431                let accel_counts = self.config.to_counts(self.config.homing_accel).round() as u32;
1432                self.homing_sdo_tid = self.sdo.write(client, 0x609A, 0, json!(accel_counts));
1433                self.op = AxisOp::Homing(7);
1434            }
1435            7 => {
1436                match self
1437                    .sdo
1438                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
1439                {
1440                    SdoResult::Ok(_) => {
1441                        self.op = AxisOp::Homing(8);
1442                    }
1443                    SdoResult::Pending => {
1444                        if self.homing_timed_out() {
1445                            self.set_op_error("Homing timeout: SDO write for homing acceleration");
1446                        }
1447                    }
1448                    SdoResult::Err(e) => {
1449                        self.set_op_error(&format!("Homing SDO error: {}", e));
1450                    }
1451                    SdoResult::Timeout => {
1452                        self.set_op_error("Homing timeout: SDO write timed out");
1453                    }
1454                }
1455            }
1456            8 => {
1457                // Switch into Homing mode on BOTH channels:
1458                //  - the cyclic PDO field, so drives that PDO-map 0x6060 carry the
1459                //    correct mode on the wire, and
1460                //  - an SDO write + read-back of 0x6060/0x6061 (FbSetModeOfOperation),
1461                //    so drives that pin mode by SDO (e.g. Kollmorgen AKD, which does
1462                //    not PDO-map mode) actually transition AND are verified.
1463                // The SDO read-back replaces the old modes_of_operation_display()
1464                // check, which silently passed on PDO-unmapped drives because codegen
1465                // mirrors the commanded value into the display stub.
1466                view.set_modes_of_operation(ModesOfOperation::Homing.as_i8());
1467                // Ensure CW bit 4 starts LOW so the next step issues a clean rising edge.
1468                let mut cw = RawControlWord(view.control_word());
1469                cw.set_bit(4, false);
1470                view.set_control_word(cw.raw());
1471                self.fb_mode_of_operation.reset();
1472                self.fb_mode_of_operation
1473                    .start(ModesOfOperation::Homing as i8);
1474                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1475                self.op = AxisOp::Homing(9);
1476            }
1477            9 => {
1478                // Wait for the mode switch to be written and verified over SDO.
1479                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1480                if !self.fb_mode_of_operation.is_busy() {
1481                    if self.fb_mode_of_operation.is_error() {
1482                        self.set_op_error(&format!(
1483                            "Homing: failed to enter Homing mode: {} {}",
1484                            self.fb_mode_of_operation.error_code(),
1485                            self.fb_mode_of_operation.error_message()
1486                        ));
1487                    } else {
1488                        self.op = AxisOp::Homing(10);
1489                    }
1490                }
1491            }
1492            10 => {
1493                // Trigger homing: rising edge on bit 4
1494                // DIAGNOSTIC: position_actual just before triggering, to compare
1495                // against the post-home value (step 13) — tells us whether the
1496                // drive's method actually changes 0x6064 or only sets the bit.
1497                log::info!(
1498                    "Homing[trigger]: method={} position_actual(0x6064) BEFORE = {}",
1499                    self.homing_method,
1500                    view.position_actual(),
1501                );
1502                let mut cw = RawControlWord(view.control_word());
1503                cw.set_bit(4, true);
1504                view.set_control_word(cw.raw());
1505                self.op = AxisOp::Homing(11);
1506            }
1507            11 => {
1508                // One drive cycle after the trigger edge, then evaluate completion.
1509                //
1510                // We deliberately do NOT wait for bit 12 (homing attained) to clear
1511                // first. That old start-gate assumed the drive de-asserts attained at
1512                // the start of a new run and re-asserts on completion — true for
1513                // methods that take time to run, but an instantaneous method (35/37,
1514                // set-current-position) completes within one cycle, so bit 12 goes
1515                // high immediately and is never observed low. The gate's original
1516                // purpose (rejecting a stale PP set-point acknowledge in bit 12) is
1517                // now covered by verifying mode 6 over SDO in steps 8–9 before we
1518                // ever trigger: once in Homing mode, bit 12 carries homing semantics.
1519                let sw = view.status_word();
1520                if sw & (1 << 13) != 0 {
1521                    self.set_op_error("Homing error: drive reported homing failure");
1522                } else {
1523                    self.op = AxisOp::Homing(12);
1524                }
1525            }
1526            12 => {
1527                // Wait for homing complete.
1528                //
1529                // Per CiA-402, bit 12 (homing attained) with bit 13 (error) clear is
1530                // the authoritative "homed" indicator. We do NOT require bit 10
1531                // (target reached): a set-current-position home reports "attained,
1532                // target not reached" (bit12=1, bit10=0) and never sets bit 10.
1533                // Methods that move to a target set both, so bit 12 alone still holds.
1534                let sw = view.status_word();
1535                let error = sw & (1 << 13) != 0;
1536                let attained = sw & (1 << 12) != 0;
1537
1538                if error {
1539                    self.set_op_error("Homing error: drive reported homing failure");
1540                } else if attained {
1541                    self.op = AxisOp::Homing(13);
1542                } else if self.homing_timed_out() {
1543                    self.set_op_error(&format!(
1544                        "Homing timeout: procedure did not complete (sw=0x{:04X})",
1545                        sw
1546                    ));
1547                }
1548            }
1549            13 => {
1550                // Capture home offset, applying home_position so the reference
1551                // point reads as config.home_position in user units.
1552                self.home_offset = view.position_actual()
1553                    - self.config.to_counts(self.config.home_position).round() as i32;
1554                // DIAGNOSTIC: compare position_actual AFTER homing to the BEFORE
1555                // value logged at step 10. If it didn't change, the drive set the
1556                // HOMED bit but not its reported position — so the reference is
1557                // held by the *software* home_offset below (lost on restart),
1558                // not by the drive.
1559                log::info!(
1560                    "Homing[done]: position_actual(0x6064) AFTER = {}, home_position = {} counts, software home_offset = {}",
1561                    view.position_actual(),
1562                    self.config.to_counts(self.config.home_position).round() as i32,
1563                    self.home_offset,
1564                );
1565                // Clear homing start bit in its own cycle before switching modes
1566                let mut cw = RawControlWord(view.control_word());
1567                cw.set_bit(4, false);
1568                view.set_control_word(cw.raw());
1569                self.op = AxisOp::Homing(14);
1570            }
1571            14 => {
1572                // One tick later (bit 4 already cleared in step 13, so the drive
1573                // sees the falling edge before the mode change), switch back to PP
1574                // mode. Same dual-channel approach as the switch into Homing: cyclic
1575                // PDO field plus an SDO write + read-back so it lands and is verified
1576                // on drives that don't PDO-map mode.
1577                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1578                self.fb_mode_of_operation.reset();
1579                self.fb_mode_of_operation
1580                    .start(ModesOfOperation::ProfilePosition as i8);
1581                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1582                self.op = AxisOp::Homing(15);
1583            }
1584            15 => {
1585                // Wait for the switch back to PP mode to be written and verified.
1586                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1587                if !self.fb_mode_of_operation.is_busy() {
1588                    if self.fb_mode_of_operation.is_error() {
1589                        self.set_op_error(&format!(
1590                            "Homing: failed to restore PP mode: {} {}",
1591                            self.fb_mode_of_operation.error_code(),
1592                            self.fb_mode_of_operation.error_message()
1593                        ));
1594                    } else {
1595                        log::info!("Homing complete — home offset: {}", self.home_offset);
1596                        self.complete_op();
1597                    }
1598                }
1599            }
1600            _ => self.complete_op(),
1601        }
1602    }
1603
1604    // ── Software homing helpers ──
1605
1606    fn configure_soft_homing(&mut self, method: HomingMethod) {
1607        match method {
1608            HomingMethod::LimitSwitchPosPnp => {
1609                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1610                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1611                self.soft_home_direction = 1.0;
1612            }
1613            HomingMethod::LimitSwitchNegPnp => {
1614                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1615                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1616                self.soft_home_direction = -1.0;
1617            }
1618            HomingMethod::LimitSwitchPosNpn => {
1619                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1620                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1621                self.soft_home_direction = 1.0;
1622            }
1623            HomingMethod::LimitSwitchNegNpn => {
1624                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1625                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1626                self.soft_home_direction = -1.0;
1627            }
1628            HomingMethod::HomeSensorPosPnp => {
1629                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1630                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1631                self.soft_home_direction = 1.0;
1632            }
1633            HomingMethod::HomeSensorNegPnp => {
1634                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1635                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1636                self.soft_home_direction = -1.0;
1637            }
1638            HomingMethod::HomeSensorPosNpn => {
1639                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1640                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1641                self.soft_home_direction = 1.0;
1642            }
1643            HomingMethod::HomeSensorNegNpn => {
1644                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1645                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1646                self.soft_home_direction = -1.0;
1647            }
1648            _ => {} // integrated methods handled elsewhere
1649        }
1650    }
1651
1652    fn start_soft_homing(&mut self, view: &mut impl AxisView) {
1653        self.op = AxisOp::SoftHoming(HomeState::EnsurePpMode as u8);
1654        self.op_started = Some(Instant::now());
1655    }
1656
1657    fn check_soft_home_trigger(&self, view: &impl AxisView) -> bool {
1658        let raw = match self.soft_home_sensor {
1659            SoftHomeSensor::PositiveLimit => view.positive_limit_active(),
1660            SoftHomeSensor::NegativeLimit => view.negative_limit_active(),
1661            SoftHomeSensor::HomeSensor => view.home_sensor_active(),
1662        };
1663        match self.soft_home_sensor_type {
1664            SoftHomeSensorType::Pnp => raw,  // PNP: true = detected
1665            SoftHomeSensorType::Npn => !raw, // NPN: false = detected
1666        }
1667    }
1668
1669    /// Calculate the maximum relative target for the specified direction.
1670    /// The result is adjusted for whether the motor direction has been inverted.
1671    fn calculate_max_relative_target(&self, direction: f64) -> i32 {
1672        let dir = if !self.config.invert_direction {
1673            direction
1674        } else {
1675            -direction
1676        };
1677
1678        let target = if dir > 0.0 { i32::MAX } else { i32::MIN };
1679
1680        return target;
1681    }
1682
1683    /// Configure the command word for an immediate halt: set the halt bit and
1684    /// clear the new-setpoint bit. The set-point queue is flushed afterward by
1685    /// the shared mode toggle (`FbFlushQueue`), so no cancel set-point is needed.
1686    pub fn command_halt(&self, view: &mut impl AxisView) {
1687        let mut cw = RawControlWord(view.control_word());
1688        cw.set_bit(8, true); // halt
1689        cw.set_bit(4, false); // reset new setpoint bit
1690        cw.set_bit(5, false);
1691        cw.set_bit(6, false); // absolute move
1692        view.set_control_word(cw.raw());
1693    }
1694
1695    /// Writes out the scaled homing speed into the bus.
1696    fn command_homing_speed(&self, view: &mut impl AxisView) {
1697        let cpu = self.config.counts_per_user();
1698        let vel = (self.config.homing_speed * cpu).round() as u32;
1699        let accel = (self.config.homing_accel * cpu).round() as u32;
1700        let decel = (self.config.homing_decel * cpu).round() as u32;
1701        view.set_profile_velocity(vel);
1702        view.set_profile_acceleration(accel);
1703        view.set_profile_deceleration(decel);
1704    }
1705
1706    // ── Software homing state machine ──
1707    //
1708    // Phase 1: SEARCH (steps 0-3)
1709    //   Relative move in search direction until sensor triggers.
1710    //
1711    // Phase 2: HALT (steps 4-6)
1712    //   Stop the motor, cancel the old target.
1713    //
1714    // Phase 3: BACK-OFF (steps 7-11)
1715    //   Move opposite direction until sensor clears, then stop.
1716    //
1717    // Phase 4: SET HOME (steps 12-18)
1718    //   Write home offset to drive via SDO, trigger CurrentPosition homing,
1719    //   send hold set-point, complete.
1720    //
1721    fn tick_soft_homing(&mut self, view: &mut impl AxisView, client: &mut CommandClient, step: u8) {
1722        match HomeState::from_repr(step) {
1723            Some(HomeState::EnsurePpMode) => {
1724                //
1725                // If the drive crapped out in a previous mode, it might still be in homing mode.
1726                // Make sure we're in Profile Position mode.
1727                //
1728                log::info!("SoftHome: Ensuring PP mode..");
1729                self.fb_mode_of_operation
1730                    .start(ModesOfOperation::ProfilePosition as i8);
1731                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1732                self.op = AxisOp::SoftHoming(HomeState::WaitPpMode as u8);
1733            }
1734            Some(HomeState::WaitPpMode) => {
1735                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1736                if !self.fb_mode_of_operation.is_busy() {
1737                    if self.fb_mode_of_operation.is_error() {
1738                        self.set_op_error(&format!(
1739                            "Software homing SDO error writing homing mode of operation: {} {}",
1740                            self.fb_mode_of_operation.error_code(),
1741                            self.fb_mode_of_operation.error_message()
1742                        ));
1743                    } else {
1744                        log::info!("SoftHome: Drive is in PP mode!");
1745
1746                        // If sensor is NOT triggered, search for it (issue a move).
1747                        // If sensor IS already triggered, skip search and go straight
1748                        // to the found-sensor halt/back-off sequence.
1749                        if !self.check_soft_home_trigger(view) {
1750                            log::info!("SoftHome: Not on home switch; seek out.");
1751                            self.op = AxisOp::SoftHoming(HomeState::Search as u8);
1752                        } else {
1753                            log::info!("SoftHome: Already on home switch, skipping ahead to back-off stage.");
1754                            self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1755                        }
1756                    }
1757                }
1758            }
1759
1760            // ── Phase 1: SEARCH ──
1761            Some(HomeState::Search) => {
1762                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1763
1764                // // Absolute move to a far-away position in the search direction.
1765                // // Use raw counts directly to avoid overflow with invert_direction.
1766                // let far_counts = (self.soft_home_direction * 999_999.0 * cpu).round() as i32;
1767                // let target = if self.config.invert_direction { -far_counts } else { far_counts };
1768                // let target = target + view.position_actual(); // offset from current
1769
1770                // move in a relative direction as far as possible
1771                // we will stop when we reach the switch
1772                let target = self.calculate_max_relative_target(self.soft_home_direction);
1773                view.set_target_position(target);
1774
1775                // let cpu = self.config.counts_per_user();
1776                // let vel = (self.config.homing_speed * cpu).round() as u32;
1777                // let accel = (self.config.homing_accel * cpu).round() as u32;
1778                // let decel = (self.config.homing_decel * cpu).round() as u32;
1779                // view.set_profile_velocity(vel);
1780                // view.set_profile_acceleration(accel);
1781                // view.set_profile_deceleration(decel);
1782
1783                self.command_homing_speed(view);
1784
1785                let mut cw = RawControlWord(view.control_word());
1786                cw.set_bit(4, true); // new set-point
1787                cw.set_bit(6, true); // sets true for relative move
1788                cw.set_bit(8, false); // clear halt
1789                cw.set_bit(13, true); // move relative to the actual current motor position
1790                view.set_control_word(cw.raw());
1791
1792                log::info!(
1793                    "SoftHome[0]: SEARCH relative target={} vel={} dir={} pos={}",
1794                    target,
1795                    self.config.homing_speed,
1796                    self.soft_home_direction,
1797                    view.position_actual()
1798                );
1799                self.op = AxisOp::SoftHoming(HomeState::WaitSearching as u8);
1800            }
1801            Some(HomeState::WaitSearching) => {
1802                if self.check_soft_home_trigger(view) {
1803                    log::debug!("SoftHome[1]: sensor triggered during ack wait");
1804                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1805                    return;
1806                }
1807                let sw = RawStatusWord(view.status_word());
1808                if sw.raw() & (1 << 12) != 0 {
1809                    let mut cw = RawControlWord(view.control_word());
1810                    cw.set_bit(4, false);
1811                    view.set_control_word(cw.raw());
1812                    log::debug!("SoftHome[1]: set-point ack received, clearing bit 4");
1813                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1814                } else if self.homing_timed_out() {
1815                    self.set_op_error("Software homing timeout: set-point not acknowledged");
1816                }
1817            }
1818            // Some(HomeState::WaitSensor) => {
1819            //     if self.check_soft_home_trigger(view) {
1820            //         log::debug!("SoftHome[2]: sensor triggered during transition");
1821            //         self.op = AxisOp::SoftHoming(4);
1822            //         return;
1823            //     }
1824            //     log::debug!("SoftHome[2]: transition → monitoring");
1825            //     self.op = AxisOp::SoftHoming(3);
1826            // }
1827            Some(HomeState::WaitFoundSensor) => {
1828                if self.check_soft_home_trigger(view) {
1829                    log::info!(
1830                        "SoftHome[3]: sensor triggered at pos={}. HALTING",
1831                        view.position_actual()
1832                    );
1833                    log::info!("ControlWord is : {} ", view.control_word());
1834
1835                    let mut cw = RawControlWord(view.control_word());
1836                    cw.set_bit(8, true); // halt
1837                    cw.set_bit(4, false); // reset new setpoint bit
1838                    view.set_control_word(cw.raw());
1839
1840                    self.halt_stable_count = 0;
1841                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedFoundSensor as u8);
1842                } else if self.homing_timed_out() {
1843                    self.set_op_error("Software homing timeout: sensor not detected");
1844                }
1845            }
1846
1847            Some(HomeState::WaitStoppedFoundSensor) => {
1848                const STABLE_WINDOW: i32 = 1;
1849                const STABLE_TICKS_REQUIRED: u8 = 10;
1850
1851                // let mut cw = RawControlWord(view.control_word());
1852                // cw.set_bit(8, true);
1853                // view.set_control_word(cw.raw());
1854
1855                let pos = view.position_actual();
1856                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1857                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1858                } else {
1859                    self.halt_stable_count = 0;
1860                }
1861
1862                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1863                    log::info!(
1864                        "SoftHome[5] motor is stopped. Cancel move and wait for bit 12 go true."
1865                    );
1866                    self.flush.start(view, client, &mut self.sdo);
1867                    self.op = AxisOp::SoftHoming(HomeState::FlushingFound as u8);
1868                } else if self.homing_timed_out() {
1869                    self.set_op_error(
1870                        "Software homing timeout: motor did not stop after sensor trigger",
1871                    );
1872                }
1873            }
1874            Some(HomeState::FlushingFound) => {
1875                self.flush.tick(view, client, &mut self.sdo);
1876                if self.flush.is_busy() {
1877                    if self.homing_timed_out() {
1878                        self.set_op_error("Software homing timeout: queue flush did not complete");
1879                    }
1880                    return;
1881                }
1882                if self.flush.is_error() {
1883                    self.set_op_error(&format!(
1884                        "Software homing: queue flush failed: {} {}",
1885                        self.flush.error_code(),
1886                        self.flush.error_message()
1887                    ));
1888                    return;
1889                }
1890                // Queue flushed and back in PP. Release halt + set-point bits, drop
1891                // stale target, then debounce before backing off.
1892                let mut cw = RawControlWord(view.control_word());
1893                cw.set_bit(8, false);
1894                cw.set_bit(4, false);
1895                cw.set_bit(5, false);
1896                view.set_control_word(cw.raw());
1897                view.set_target_position(view.position_actual());
1898                log::info!("SoftHome[6]: queue flushed; proceeding to debounce.");
1899                self.op = AxisOp::SoftHoming(HomeState::DebounceFoundSensor as u8);
1900                self.ton.call(false, Duration::from_secs(3));
1901            }
1902            // Delay before back-off (60 = wait ~1 second for drive to settle)
1903            Some(HomeState::DebounceFoundSensor) => {
1904                self.ton.call(true, Duration::from_secs(3));
1905
1906                let sw = RawStatusWord(view.status_word());
1907                if self.ton.q && sw.raw() & (1 << 12) == 0 {
1908                    self.ton.call(false, Duration::from_secs(3));
1909                    log::info!("SoftHome[6.a.]: delay complete, starting back-off from pos={} cw=0x{:04X} sw={:04x}",
1910                    view.position_actual(), view.control_word(), view.status_word());
1911                    self.op = AxisOp::SoftHoming(HomeState::BackOff as u8);
1912                }
1913            }
1914
1915            // ── Phase 3: BACK-OFF until sensor clears ──
1916            Some(HomeState::BackOff) => {
1917                let target = (self.calculate_max_relative_target(-self.soft_home_direction)) / 2;
1918                view.set_target_position(target);
1919
1920                self.command_homing_speed(view);
1921
1922                let mut cw = RawControlWord(view.control_word());
1923                cw.set_bit(4, true); // new set-point
1924                cw.set_bit(6, true); // relative move
1925                cw.set_bit(13, true); // relative from current, actualy position
1926                view.set_control_word(cw.raw());
1927                log::info!(
1928                    "SoftHome[7]: BACK-OFF absolute target={} vel={} pos={} cw=0x{:04X}",
1929                    target,
1930                    self.config.homing_speed,
1931                    view.position_actual(),
1932                    cw.raw()
1933                );
1934                self.op = AxisOp::SoftHoming(HomeState::WaitBackingOff as u8);
1935            }
1936            Some(HomeState::WaitBackingOff) => {
1937                let sw = RawStatusWord(view.status_word());
1938                if sw.raw() & (1 << 12) != 0 {
1939                    let mut cw = RawControlWord(view.control_word());
1940                    cw.set_bit(4, false);
1941                    view.set_control_word(cw.raw());
1942                    log::info!(
1943                        "SoftHome[WaitBackingOff]: back-off ack received, pos={}",
1944                        view.position_actual()
1945                    );
1946                    self.op = AxisOp::SoftHoming(HomeState::WaitLostSensor as u8);
1947                } else if self.homing_timed_out() {
1948                    self.set_op_error("Software homing timeout: back-off not acknowledged");
1949                }
1950            }
1951            Some(HomeState::WaitLostSensor) => {
1952                if !self.check_soft_home_trigger(view) {
1953                    log::info!(
1954                        "SoftHome[WaitLostSensor]: sensor lost at pos={}. Halting...",
1955                        view.position_actual()
1956                    );
1957
1958                    self.command_halt(view);
1959                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedLostSensor as u8);
1960                } else if self.homing_timed_out() {
1961                    self.set_op_error(
1962                        "Software homing timeout: sensor did not clear during back-off",
1963                    );
1964                }
1965            }
1966            Some(HomeState::WaitStoppedLostSensor) => {
1967                const STABLE_WINDOW: i32 = 1;
1968                const STABLE_TICKS_REQUIRED: u8 = 10;
1969
1970                // let mut cw = RawControlWord(view.control_word());
1971                // cw.set_bit(8, true);
1972                // view.set_control_word(cw.raw());
1973
1974                let pos = view.position_actual();
1975                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1976                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1977                } else {
1978                    self.halt_stable_count = 0;
1979                }
1980
1981                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1982                    log::info!("SoftHome[WaitStoppedLostSensor] motor is stopped. Cancel move and wait for bit 12 go true.");
1983                    self.flush.start(view, client, &mut self.sdo);
1984                    self.op = AxisOp::SoftHoming(HomeState::FlushingLost as u8);
1985                } else if self.homing_timed_out() {
1986                    self.set_op_error("Software homing timeout: motor did not stop after back-off");
1987                }
1988            }
1989            Some(HomeState::FlushingLost) => {
1990                self.flush.tick(view, client, &mut self.sdo);
1991                if self.flush.is_busy() {
1992                    if self.homing_timed_out() {
1993                        self.set_op_error("Software homing timeout: queue flush did not complete");
1994                    }
1995                    return;
1996                }
1997                if self.flush.is_error() {
1998                    self.set_op_error(&format!(
1999                        "Software homing: queue flush failed: {} {}",
2000                        self.flush.error_code(),
2001                        self.flush.error_message()
2002                    ));
2003                    return;
2004                }
2005                // Queue flushed and back in PP. Release halt + set-point bits, then
2006                // write the home offset (0x607C) and verify.
2007                let mut cw = RawControlWord(view.control_word());
2008                cw.set_bit(8, false);
2009                cw.set_bit(4, false);
2010                cw.set_bit(5, false);
2011                view.set_control_word(cw.raw());
2012
2013                let desired_counts =
2014                    self.config.to_counts(self.config.home_position).round() as i32;
2015                self.homing_sdo_tid = self.sdo.write(client, 0x607C, 0, json!(desired_counts));
2016                log::info!(
2017                    "SoftHome[FlushingLost]: queue flushed; writing home offset {} [{} counts].",
2018                    self.config.home_position, desired_counts
2019                );
2020                self.op = AxisOp::SoftHoming(HomeState::WaitHomeOffsetDone as u8);
2021            }
2022
2023            Some(HomeState::WaitHomeOffsetDone) => {
2024                // Wait for home offset SDO ack
2025                match self
2026                    .sdo
2027                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
2028                {
2029                    SdoResult::Ok(_) => {
2030                        self.op = AxisOp::SoftHoming(HomeState::WriteHomingModeOp as u8);
2031                    }
2032                    SdoResult::Pending => {
2033                        if self.homing_timed_out() {
2034                            self.set_op_error("Software homing timeout: home offset SDO write");
2035                        }
2036                    }
2037                    SdoResult::Err(e) => {
2038                        self.set_op_error(&format!("Software homing SDO error: {}", e));
2039                    }
2040                    SdoResult::Timeout => {
2041                        self.set_op_error("Software homing: home offset SDO timed out");
2042                    }
2043                }
2044            }
2045            Some(HomeState::WriteHomingModeOp) => {
2046                // Switch the mode of operation into Homing Mode so that we can execute
2047                // the homing command.
2048
2049                self.fb_mode_of_operation.reset();
2050                self.fb_mode_of_operation
2051                    .start(ModesOfOperation::Homing as i8);
2052                self.fb_mode_of_operation.tick(client, &mut self.sdo);
2053                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingModeOp as u8);
2054            }
2055            Some(HomeState::WaitWriteHomingModeOp) => {
2056                // Wait for method SDO ack
2057                self.fb_mode_of_operation.tick(client, &mut self.sdo);
2058
2059                if !self.fb_mode_of_operation.is_busy() {
2060                    if self.fb_mode_of_operation.is_error() {
2061                        self.set_op_error(&format!(
2062                            "Software homing SDO error writing homing mode of operation: {} {}",
2063                            self.fb_mode_of_operation.error_code(),
2064                            self.fb_mode_of_operation.error_message()
2065                        ));
2066                    } else {
2067                        log::info!("SoftHome: Drive is now in Homing Mode.");
2068                        self.op = AxisOp::SoftHoming(HomeState::WriteHomingMethod as u8);
2069                    }
2070                }
2071            }
2072            Some(HomeState::WriteHomingMethod) => {
2073                // Write homing method = CurrentPosition (default 37, configurable
2074                // via AxisConfig::soft_home_method — Inovance SV660N needs 35).
2075                self.homing_sdo_tid =
2076                    self.sdo
2077                        .write(client, 0x6098, 0, json!(self.config.soft_home_method));
2078                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingMethodDone as u8);
2079            }
2080            Some(HomeState::WaitWriteHomingMethodDone) => {
2081                // Wait for method SDO ack
2082                match self
2083                    .sdo
2084                    .result(client, self.homing_sdo_tid, Duration::from_secs(5))
2085                {
2086                    SdoResult::Ok(_) => {
2087                        log::info!("SoftHome: Successfully wrote homing method.");
2088                        self.op = AxisOp::SoftHoming(HomeState::ClearHomingTrigger as u8);
2089                    }
2090                    SdoResult::Pending => {
2091                        if self.homing_timed_out() {
2092                            self.restore_pp_after_error(
2093                                "Software homing timeout: homing method SDO write",
2094                            );
2095                        }
2096                    }
2097                    SdoResult::Err(e) => {
2098                        self.restore_pp_after_error(&format!("Software homing SDO error: {}", e));
2099                    }
2100                    SdoResult::Timeout => {
2101                        self.restore_pp_after_error("Software homing: homing method SDO timed out");
2102                    }
2103                }
2104            }
2105            Some(HomeState::ClearHomingTrigger) => {
2106                // Switch to homing mode and ensure CW bit 4 starts LOW, so the
2107                // next state can issue a clean rising edge the drive will see.
2108                let mut cw = RawControlWord(view.control_word());
2109                cw.set_bit(4, false);
2110                view.set_control_word(cw.raw());
2111                self.op = AxisOp::SoftHoming(HomeState::TriggerHoming as u8);
2112            }
2113            Some(HomeState::TriggerHoming) => {
2114                // Rising edge on CW bit 4 to start homing.
2115                let mut cw = RawControlWord(view.control_word());
2116                cw.set_bit(4, true);
2117                view.set_control_word(cw.raw());
2118                log::info!("SoftHome[TriggerHoming]: start homing");
2119                self.op = AxisOp::SoftHoming(HomeState::WaitHomingStarted as u8);
2120            }
2121            Some(HomeState::WaitHomingStarted) => {
2122                // Wait for the drive to clear bit 12 (Homing attained) to acknowledge
2123                // the start of homing. Without this handshake, stale bit 12 carried
2124                // over from the previous mode (e.g. PP set-point acknowledge) would
2125                // cause WaitHomingDone to pass instantly, and the drive would never
2126                // actually perform the homing method.
2127                let sw = view.status_word();
2128                let error = sw & (1 << 13) != 0;
2129                if error {
2130                    self.restore_pp_after_error("Software homing: drive reported homing error");
2131                } else if sw & (1 << 12) == 0 {
2132                    self.op = AxisOp::SoftHoming(HomeState::WaitHomingDone as u8);
2133                } else if self.homing_timed_out() {
2134                    self.restore_pp_after_error(&format!("Software homing timeout: drive did not acknowledge homing start (sw=0x{:04X})", sw));
2135                }
2136            }
2137            Some(HomeState::WaitHomingDone) => {
2138                // Wait for homing complete (bit 12 attained + bit 10 reached).
2139                let sw = view.status_word();
2140                let error = sw & (1 << 13) != 0;
2141                let attained = sw & (1 << 12) != 0;
2142                let reached = sw & (1 << 10) != 0;
2143
2144                if error {
2145                    self.restore_pp_after_error("Software homing: drive reported homing error");
2146                } else if attained && reached {
2147                    // DIAGNOSTIC: did the drive actually apply the set-position?
2148                    // position_actual (0x6064) should now read the value we wrote
2149                    // to 0x607C. If it instead reads a raw/unchanged number, the
2150                    // drive set the HOMED bit but did NOT move its reported
2151                    // position — meaning the persistence relies on software, not
2152                    // the drive.
2153                    log::info!(
2154                        "SoftHome[WaitHomingDone]: homing complete sw=0x{:04X} position_actual(0x6064)={} target(0x607C)={} counts",
2155                        sw,
2156                        view.position_actual(),
2157                        self.config.to_counts(self.config.home_position).round() as i32,
2158                    );
2159                    self.op = AxisOp::SoftHoming(HomeState::ResetHomingTrigger as u8);
2160                } else if self.homing_timed_out() {
2161                    self.restore_pp_after_error(&format!("Software homing timeout: drive homing did not complete (sw=0x{:04X} attained={} reached={})", sw, attained, reached));
2162                }
2163            }
2164            Some(HomeState::ResetHomingTrigger) => {
2165                // Clear CW bit 4 first, in its own RxPDO cycle, so the drive sees
2166                // the falling edge *before* we change modes_of_operation away from
2167                // Homing. Changing both at once can leave the drive committing
2168                // ambiguous state.
2169                let mut cw = RawControlWord(view.control_word());
2170                cw.set_bit(4, false);
2171                view.set_control_word(cw.raw());
2172                self.op = AxisOp::SoftHoming(HomeState::WaitHomingTriggerCleared as u8);
2173            }
2174            Some(HomeState::WaitHomingTriggerCleared) => {
2175                // One tick later, switch back to PP mode and record that the drive
2176                // now owns the offset so our software-side offset is zero.
2177                self.home_offset = 0; // drive handles it now
2178                self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);
2179            }
2180
2181            Some(HomeState::WriteMotionModeOfOperation) => {
2182                // Switch back to PP motion mode
2183
2184                self.fb_mode_of_operation.reset();
2185                self.fb_mode_of_operation
2186                    .start(ModesOfOperation::ProfilePosition as i8);
2187                self.fb_mode_of_operation.tick(client, &mut self.sdo);
2188                self.op = AxisOp::SoftHoming(HomeState::WaitWriteMotionModeOfOperation as u8);
2189            }
2190            Some(HomeState::WaitWriteMotionModeOfOperation) => {
2191                // Wait for method SDO ack
2192                self.fb_mode_of_operation.tick(client, &mut self.sdo);
2193
2194                if !self.fb_mode_of_operation.is_busy() {
2195                    if self.fb_mode_of_operation.is_error() {
2196                        self.set_op_error(&format!(
2197                            "Software homing SDO error writing homing mode of operation: {} {}",
2198                            self.fb_mode_of_operation.error_code(),
2199                            self.fb_mode_of_operation.error_message()
2200                        ));
2201                    } else {
2202                        if self.is_error {
2203                            log::error!("Drive back in PP mode after error. Homing sequence did not complete!");
2204                            self.finish_op_error();
2205                        } else {
2206                            // Set the target position so this drive doesn't go wandering off after homing
2207                            // changed the position
2208                            self.op =
2209                                AxisOp::SoftHoming(HomeState::SendCurrentPositionTarget as u8);
2210                        }
2211                    }
2212                }
2213            }
2214
2215            Some(HomeState::SendCurrentPositionTarget) => {
2216                // Hold position: send set-point to current position
2217                let current_pos = view.position_actual();
2218                view.set_target_position(current_pos);
2219                view.set_profile_velocity(0);
2220                let mut cw = RawControlWord(view.control_word());
2221                cw.set_bit(4, true);
2222                cw.set_bit(5, true);
2223                cw.set_bit(6, false); // absolute
2224                view.set_control_word(cw.raw());
2225                self.op = AxisOp::SoftHoming(HomeState::WaitCurrentPositionTargetSent as u8);
2226            }
2227            Some(HomeState::WaitCurrentPositionTargetSent) => {
2228                // Wait for hold ack
2229                let sw = RawStatusWord(view.status_word());
2230                if sw.raw() & (1 << 12) != 0 {
2231                    let mut cw = RawControlWord(view.control_word());
2232                    cw.set_bit(4, false);
2233                    view.set_control_word(cw.raw());
2234                    log::info!(
2235                        "Software homing complete — position set to {} user units",
2236                        self.config.home_position
2237                    );
2238                    self.complete_op();
2239                } else if self.homing_timed_out() {
2240                    self.set_op_error("Software homing timeout: hold position not acknowledged");
2241                }
2242            }
2243            _ => self.complete_op(),
2244        }
2245    }
2246
2247    // ── Halting ──
2248    //
2249    // Three-stage close-out of the PP handshake, mirroring the soft-home
2250    // stop sequence (WaitStoppedFoundSensor → WaitFoundSensorAck →
2251    // WaitFoundSensorAckClear). Leaving any stage out results in a dirty
2252    // handshake that makes the next `move_absolute` time out at
2253    // "set-point not acknowledged."
2254    //
2255    // Step 0: (done in halt()) command_halt — bit 8 set, bit 4 cleared.
2256    // Step 1: wait for position to be stable → command_cancel_move.
2257    // Step 2: wait for SW bit 12 AND bit 10 → clear bit 4, set bit 5.
2258    // Step 3: wait for SW bit 12 to drop → Idle.
2259    fn tick_halting(
2260        &mut self,
2261        view: &mut impl AxisView,
2262        client: &mut CommandClient,
2263        step: u8,
2264    ) {
2265        match HaltState::from_repr(step) {
2266            Some(HaltState::WaitStopped) => {
2267                let pos = view.position_actual();
2268                let pos_stable = (pos - self.last_raw_position).abs() <= HALT_STABLE_WINDOW;
2269
2270                let vel = view.velocity_actual().abs();
2271                let vel_stopped = vel <= HALT_STOPPED_VELOCITY;
2272
2273                if pos_stable || vel_stopped {
2274                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
2275                } else {
2276                    self.halt_stable_count = 0;
2277                }
2278
2279                if self.halt_stable_count >= HALT_STABLE_TICKS_REQUIRED {
2280                    // Motor stopped (halt still asserted). Flush the set-point
2281                    // queue via the shared mode toggle, then release halt.
2282                    self.flush.start(view, client, &mut self.sdo);
2283                    self.op_started = Some(Instant::now());
2284                    self.op = AxisOp::Halting(HaltState::Flushing as u8);
2285                } else if self.op_stage_timed_out(HALT_STAGE_TIMEOUT) {
2286                    self.set_op_error("Halt timeout: motor did not stop");
2287                }
2288            }
2289            Some(HaltState::Flushing) => {
2290                self.flush.tick(view, client, &mut self.sdo);
2291                if self.flush.is_busy() {
2292                    if self.op_stage_timed_out(HALT_STAGE_TIMEOUT) {
2293                        self.set_op_error("Halt timeout: set-point queue flush did not complete");
2294                    }
2295                    return;
2296                }
2297                if self.flush.is_error() {
2298                    self.set_op_error(&format!(
2299                        "Halt: set-point queue flush failed: {} {}",
2300                        self.flush.error_code(),
2301                        self.flush.error_message()
2302                    ));
2303                    return;
2304                }
2305                // Queue flushed and back in PP. Release halt + the set-point bits
2306                // and drop any stale target so the next move starts clean.
2307                let mut cw = RawControlWord(view.control_word());
2308                cw.set_bit(8, false); // clear halt
2309                cw.set_bit(4, false);
2310                cw.set_bit(5, false);
2311                view.set_control_word(cw.raw());
2312                view.set_target_position(view.position_actual());
2313                self.complete_op();
2314            }
2315            None => {
2316                log::warn!("Axis halt: unknown sub-step {}, forcing idle", step);
2317                self.complete_op();
2318            }
2319        }
2320    }
2321
2322    // ── Fault Recovery ──
2323    // Step 0: (done in reset_faults()) clear bit 7
2324    // Step 1: assert bit 7 (fault reset rising edge)
2325    // Step 2: wait fault cleared → Idle
2326    fn tick_fault_recovery(&mut self, view: &mut impl AxisView, step: u8) {
2327        match step {
2328            1 => {
2329                // Assert fault reset (rising edge on bit 7)
2330                let mut cw = RawControlWord(view.control_word());
2331                cw.cmd_fault_reset();
2332                view.set_control_word(cw.raw());
2333                self.op = AxisOp::FaultRecovery(2);
2334            }
2335            2 => {
2336                // Wait for fault to clear
2337                let sw = RawStatusWord(view.status_word());
2338                let state = sw.state();
2339                if !matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
2340                    log::info!("Fault cleared (drive state: {})", state);
2341                    self.complete_op();
2342                } else if self.op_timed_out() {
2343                    self.set_op_error("Fault reset timeout: drive still faulted");
2344                }
2345            }
2346            _ => self.complete_op(),
2347        }
2348    }
2349}
2350
2351// ──────────────────────────────────────────────
2352// Tests
2353// ──────────────────────────────────────────────
2354
2355#[cfg(test)]
2356mod tests {
2357    use super::*;
2358
2359    /// Mock AxisView for testing.
2360    struct MockView {
2361        control_word: u16,
2362        status_word: u16,
2363        target_position: i32,
2364        profile_velocity: u32,
2365        profile_acceleration: u32,
2366        profile_deceleration: u32,
2367        modes_of_operation: i8,
2368        modes_of_operation_display: i8,
2369        position_actual: i32,
2370        velocity_actual: i32,
2371        error_code: u16,
2372        positive_limit: bool,
2373        negative_limit: bool,
2374        home_sensor: bool,
2375    }
2376
2377    impl MockView {
2378        fn new() -> Self {
2379            Self {
2380                control_word: 0,
2381                status_word: 0x0040, // SwitchOnDisabled
2382                target_position: 0,
2383                profile_velocity: 0,
2384                profile_acceleration: 0,
2385                profile_deceleration: 0,
2386                modes_of_operation: 0,
2387                modes_of_operation_display: 1, // PP
2388                position_actual: 0,
2389                velocity_actual: 0,
2390                error_code: 0,
2391                positive_limit: false,
2392                negative_limit: false,
2393                home_sensor: false,
2394            }
2395        }
2396
2397        fn set_state(&mut self, state: u16) {
2398            self.status_word = state;
2399        }
2400    }
2401
2402    impl AxisView for MockView {
2403        fn control_word(&self) -> u16 {
2404            self.control_word
2405        }
2406        fn set_control_word(&mut self, word: u16) {
2407            self.control_word = word;
2408        }
2409        fn set_target_position(&mut self, pos: i32) {
2410            self.target_position = pos;
2411        }
2412        fn set_profile_velocity(&mut self, vel: u32) {
2413            self.profile_velocity = vel;
2414        }
2415        fn set_profile_acceleration(&mut self, accel: u32) {
2416            self.profile_acceleration = accel;
2417        }
2418        fn set_profile_deceleration(&mut self, decel: u32) {
2419            self.profile_deceleration = decel;
2420        }
2421        fn set_modes_of_operation(&mut self, mode: i8) {
2422            self.modes_of_operation = mode;
2423        }
2424        fn modes_of_operation_display(&self) -> i8 {
2425            self.modes_of_operation_display
2426        }
2427        fn status_word(&self) -> u16 {
2428            self.status_word
2429        }
2430        fn position_actual(&self) -> i32 {
2431            self.position_actual
2432        }
2433        fn velocity_actual(&self) -> i32 {
2434            self.velocity_actual
2435        }
2436        fn error_code(&self) -> u16 {
2437            self.error_code
2438        }
2439        fn positive_limit_active(&self) -> bool {
2440            self.positive_limit
2441        }
2442        fn negative_limit_active(&self) -> bool {
2443            self.negative_limit
2444        }
2445        fn home_sensor_active(&self) -> bool {
2446            self.home_sensor
2447        }
2448    }
2449
2450    fn test_config() -> AxisConfig {
2451        AxisConfig::new(12_800).with_user_scale(360.0)
2452    }
2453
2454    /// Helper: create axis + mock client channels.
2455    fn test_axis() -> (
2456        Axis,
2457        CommandClient,
2458        tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>,
2459        tokio::sync::mpsc::UnboundedReceiver<String>,
2460    ) {
2461        use tokio::sync::mpsc;
2462        let (write_tx, write_rx) = mpsc::unbounded_channel();
2463        let (response_tx, response_rx) = mpsc::unbounded_channel();
2464        let client = CommandClient::new(write_tx, response_rx);
2465        let axis = Axis::new(test_config(), "TestDrive");
2466        (axis, client, response_tx, write_rx)
2467    }
2468
2469    #[test]
2470    fn axis_config_conversion() {
2471        let cfg = test_config();
2472        // 45 degrees = 1600 counts
2473        assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
2474    }
2475
2476    #[test]
2477    fn trace_records_on_change_not_every_tick() {
2478        let (mut axis, mut client, _r, _w) = test_axis();
2479        let mut view = MockView::new();
2480        view.set_state(0x0040); // Switch On Disabled
2481
2482        // Many idle ticks with a constant control/status word → one entry, not
2483        // one per tick. This is what keeps a multi-second stall cheap.
2484        for _ in 0..10 {
2485            axis.tick(&mut view, &mut client);
2486        }
2487        let after_idle = axis.trace().lines().count();
2488        assert!(
2489            after_idle <= 1,
2490            "constant state must record ≤1 line, got {}",
2491            after_idle
2492        );
2493
2494        // A status-word change records exactly one new transition.
2495        view.set_state(0x0027); // Operation Enabled
2496        axis.tick(&mut view, &mut client);
2497        assert!(
2498            axis.trace().lines().count() > after_idle,
2499            "a SW change adds a line"
2500        );
2501        assert!(
2502            axis.trace().contains("Operation Enabled"),
2503            "trace decodes the SW state"
2504        );
2505    }
2506
2507    #[test]
2508    fn trace_decoders_render_handshake_bits() {
2509        // Cancel control word with HALT cleared (the intended fix) vs still set.
2510        assert_eq!(decode_cw(0x003F), "SwOn|EnVolt|QStop|EnOp|NewSP|ChgSet");
2511        assert!(
2512            decode_cw(0x013F).contains("HALT"),
2513            "0x13F still asserts HALT"
2514        );
2515        // Status word: Operation Enabled with set-point acknowledge (bit 12).
2516        let s = decode_sw(0x1027);
2517        assert!(
2518            s.contains("Operation Enabled") && s.contains("SP_ACK"),
2519            "got {s}"
2520        );
2521    }
2522
2523    #[test]
2524    fn enable_sequence_sets_pp_mode_and_shutdown() {
2525        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2526        let mut view = MockView::new();
2527
2528        axis.enable(&mut view);
2529
2530        // Should have set PP mode
2531        assert_eq!(
2532            view.modes_of_operation,
2533            ModesOfOperation::ProfilePosition.as_i8()
2534        );
2535        // Should have issued shutdown command (bits 1,2 set; 0,3,7 clear)
2536        assert_eq!(view.control_word & 0x008F, 0x0006);
2537        // Should be in Enabling state
2538        assert_eq!(axis.op, AxisOp::Enabling(1));
2539
2540        // Simulate drive reaching ReadyToSwitchOn
2541        view.set_state(0x0021); // ReadyToSwitchOn
2542        axis.tick(&mut view, &mut client);
2543
2544        // Should have issued enable_operation (bits 0-3 set; 7 clear)
2545        assert_eq!(view.control_word & 0x008F, 0x000F);
2546        assert_eq!(axis.op, AxisOp::Enabling(2));
2547
2548        // Simulate drive reaching OperationEnabled
2549        view.set_state(0x0027); // OperationEnabled
2550        axis.tick(&mut view, &mut client);
2551
2552        // Should be idle now, motor_on = true
2553        assert_eq!(axis.op, AxisOp::Idle);
2554        assert!(axis.motor_on);
2555    }
2556
2557    #[test]
2558    fn move_absolute_sets_target() {
2559        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2560        let mut view = MockView::new();
2561        view.set_state(0x0027); // OperationEnabled
2562        axis.tick(&mut view, &mut client); // update outputs
2563
2564        // Move to 45 degrees at 90 deg/s, 180 deg/s² accel/decel
2565        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2566
2567        // Target should be ~1600 counts (45° at 12800 cpr / 360°)
2568        assert_eq!(view.target_position, 1600);
2569        // Velocity: 90 deg/s * (12800/360) ≈ 3200 counts/s
2570        assert_eq!(view.profile_velocity, 3200);
2571        // Accel: 180 deg/s² * (12800/360) ≈ 6400 counts/s²
2572        assert_eq!(view.profile_acceleration, 6400);
2573        assert_eq!(view.profile_deceleration, 6400);
2574        // Bit 4 (new set-point) should be set
2575        assert!(view.control_word & (1 << 4) != 0);
2576        // Bit 6 (relative) should be clear for absolute move
2577        assert!(view.control_word & (1 << 6) == 0);
2578        // Should be in Moving state
2579        assert!(matches!(
2580            axis.op,
2581            AxisOp::Moving(MoveKind::Absolute, 1, _, _)
2582        ));
2583    }
2584
2585    #[test]
2586    fn move_relative_sets_relative_bit() {
2587        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2588        let mut view = MockView::new();
2589        view.set_state(0x0027);
2590        axis.tick(&mut view, &mut client);
2591
2592        axis.move_relative(&mut view, 10.0, 90.0, 180.0, 180.0);
2593
2594        // Bit 6 (relative) should be set
2595        assert!(view.control_word & (1 << 6) != 0);
2596        assert!(matches!(
2597            axis.op,
2598            AxisOp::Moving(MoveKind::Relative, 1, _, _)
2599        ));
2600    }
2601
2602    #[test]
2603    fn move_completes_on_target_reached() {
2604        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2605        let mut view = MockView::new();
2606        view.set_state(0x0027); // OperationEnabled
2607        axis.tick(&mut view, &mut client);
2608
2609        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2610
2611        // Step 1: simulate set-point acknowledge (bit 12)
2612        view.status_word = 0x1027; // OperationEnabled + bit 12
2613        axis.tick(&mut view, &mut client);
2614        // Should have cleared bit 4
2615        assert!(view.control_word & (1 << 4) == 0);
2616
2617        // Step 2: simulate target reached (bit 10)
2618        view.status_word = 0x0427; // OperationEnabled + bit 10
2619        axis.tick(&mut view, &mut client);
2620        // Should be idle now
2621        assert_eq!(axis.op, AxisOp::Idle);
2622        assert!(!axis.in_motion);
2623    }
2624
2625    #[test]
2626    fn fault_detected_sets_error() {
2627        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2628        let mut view = MockView::new();
2629        view.set_state(0x0008); // Fault
2630        view.error_code = 0x1234;
2631
2632        axis.tick(&mut view, &mut client);
2633
2634        assert!(axis.is_error);
2635        assert_eq!(axis.error_code, 0x1234);
2636        assert!(axis.error_message.contains("fault"));
2637    }
2638
2639    #[test]
2640    fn fault_recovery_sequence() {
2641        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2642        let mut view = MockView::new();
2643        view.set_state(0x0008); // Fault
2644
2645        axis.reset_faults(&mut view);
2646        // Step 0: bit 7 should be cleared
2647        assert!(view.control_word & 0x0080 == 0);
2648
2649        // Step 1: tick should assert bit 7
2650        axis.tick(&mut view, &mut client);
2651        assert!(view.control_word & 0x0080 != 0);
2652
2653        // Step 2: simulate fault cleared → SwitchOnDisabled
2654        view.set_state(0x0040);
2655        axis.tick(&mut view, &mut client);
2656        assert_eq!(axis.op, AxisOp::Idle);
2657        assert!(!axis.is_error);
2658    }
2659
2660    #[test]
2661    fn reset_faults_software_only_is_synchronous() {
2662        // When the axis hits a software error (e.g. target outside the
2663        // software limits) the drive stays in OperationEnabled — there is
2664        // no CiA 402 fault to clear. reset_faults() must return the axis
2665        // to Idle on the same call without engaging the drive's bit-7
2666        // fault-reset handshake.
2667        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2668        let mut view = MockView::new();
2669        view.set_state(0x0027); // OperationEnabled
2670        axis.tick(&mut view, &mut client);
2671
2672        // Trigger a software-only error via target outside software limit.
2673        axis.set_software_min_limit(207.0);
2674        axis.move_absolute(&mut view, 200.0, 90.0, 180.0, 180.0);
2675        assert!(axis.is_error, "move past software limit must set is_error");
2676        assert!(!axis.is_busy);
2677
2678        // Capture cw before reset. reset_faults must not modify it for
2679        // a software-only error (drive is healthy, no handshake needed).
2680        let cw_before = view.control_word;
2681
2682        axis.reset_faults(&mut view);
2683
2684        // Synchronous: error cleared, axis already Idle, not busy.
2685        assert!(!axis.is_error);
2686        assert_eq!(axis.error_code, 0);
2687        assert!(axis.error_message.is_empty());
2688        assert!(!axis.is_busy);
2689        assert_eq!(axis.op, AxisOp::Idle);
2690        // No drive handshake — control word untouched.
2691        assert_eq!(view.control_word, cw_before);
2692    }
2693
2694    #[test]
2695    fn disable_sequence() {
2696        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2697        let mut view = MockView::new();
2698        view.set_state(0x0027); // OperationEnabled
2699
2700        axis.disable(&mut view);
2701        // Should have sent shutdown command
2702        assert_eq!(view.control_word & 0x008F, 0x0006);
2703
2704        // Simulate drive leaving OperationEnabled
2705        view.set_state(0x0021); // ReadyToSwitchOn
2706        axis.tick(&mut view, &mut client);
2707        assert_eq!(axis.op, AxisOp::Idle);
2708    }
2709
2710    #[test]
2711    fn position_tracks_with_home_offset() {
2712        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2713        let mut view = MockView::new();
2714        view.set_state(0x0027);
2715        view.position_actual = 5000;
2716
2717        // Enable to capture home offset
2718        axis.enable(&mut view);
2719        view.set_state(0x0021);
2720        axis.tick(&mut view, &mut client);
2721        view.set_state(0x0027);
2722        axis.tick(&mut view, &mut client);
2723
2724        // Home offset should be 5000
2725        assert_eq!(axis.home_offset, 5000);
2726
2727        // Position should be 0 (at home)
2728        assert!((axis.position - 0.0).abs() < 0.01);
2729
2730        // Move actual position to 5000 + 1600 = 6600
2731        view.position_actual = 6600;
2732        axis.tick(&mut view, &mut client);
2733
2734        // Should read as 45 degrees
2735        assert!((axis.position - 45.0).abs() < 0.1);
2736    }
2737
2738    #[test]
2739    fn set_position_adjusts_home_offset() {
2740        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2741        let mut view = MockView::new();
2742        view.position_actual = 3200;
2743
2744        axis.set_position(&view, 90.0);
2745        axis.tick(&mut view, &mut client);
2746
2747        // home_offset = 3200 - to_counts(90.0) = 3200 - 3200 = 0
2748        assert_eq!(axis.home_offset, 0);
2749        assert!((axis.position - 90.0).abs() < 0.01);
2750    }
2751
2752    #[test]
2753    fn halt_runs_multi_stage_close_out() {
2754        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2755        let mut view = MockView::new();
2756        view.set_state(0x0027);
2757
2758        axis.halt(&mut view);
2759
2760        // halt() sets CW bit 8 (halt) and clears CW bit 4 (new setpoint).
2761        assert!(view.control_word & (1 << 8) != 0, "halt bit must be set");
2762        assert!(
2763            view.control_word & (1 << 4) == 0,
2764            "new_setpoint must be cleared"
2765        );
2766
2767        // Halt is a multi-stage process. The first stage is WaitStopped.
2768        assert!(
2769            matches!(axis.op, AxisOp::Halting(_)),
2770            "halt should enter Halting state, not Idle"
2771        );
2772        let AxisOp::Halting(step) = axis.op.clone() else {
2773            unreachable!()
2774        };
2775        assert_eq!(step, HaltState::WaitStopped as u8);
2776
2777        // Tick alone does NOT immediately return to Idle anymore —
2778        // WaitStopped polls position stability (5 consecutive ticks
2779        // with position stable or velocity near zero). With a static
2780        // MockView, position stays at 0 and velocity stays at 0, so
2781        // vel_stopped is true every tick → the counter accumulates
2782        // and the state progresses after HALT_STABLE_TICKS_REQUIRED.
2783        for _ in 0..HALT_STABLE_TICKS_REQUIRED {
2784            axis.tick(&mut view, &mut client);
2785        }
2786        // After enough stable ticks we advance past WaitStopped into the mode
2787        // toggle (Flushing), with the SDO mode switch under way.
2788        assert!(matches!(axis.op, AxisOp::Halting(_)));
2789        let AxisOp::Halting(step) = axis.op.clone() else {
2790            unreachable!()
2791        };
2792        assert_eq!(
2793            step,
2794            HaltState::Flushing as u8,
2795            "should advance past WaitStopped once position/velocity is stable"
2796        );
2797        // Halt stays asserted through the away-switch (the old target is still
2798        // live until the queue is flushed).
2799        assert!(view.control_word & (1 << 8) != 0, "halt held through the away mode switch");
2800
2801        // axis.is_busy must remain true through the whole halt sequence so
2802        // callers like MoveToLoad don't see "stopped" until the PP state
2803        // is fully cleaned up.
2804        assert!(axis.is_busy, "is_busy must stay true across Halting stages");
2805    }
2806
2807    #[test]
2808    fn is_busy_tracks_operations() {
2809        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2810        let mut view = MockView::new();
2811
2812        // Idle — not busy
2813        axis.tick(&mut view, &mut client);
2814        assert!(!axis.is_busy);
2815
2816        // Enable — busy
2817        axis.enable(&mut view);
2818        axis.tick(&mut view, &mut client);
2819        assert!(axis.is_busy);
2820
2821        // Complete enable
2822        view.set_state(0x0021);
2823        axis.tick(&mut view, &mut client);
2824        view.set_state(0x0027);
2825        axis.tick(&mut view, &mut client);
2826        assert!(!axis.is_busy);
2827
2828        // Move — busy
2829        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2830        axis.tick(&mut view, &mut client);
2831        assert!(axis.is_busy);
2832        assert!(axis.in_motion);
2833    }
2834
2835    #[test]
2836    fn fault_during_move_cancels_op() {
2837        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2838        let mut view = MockView::new();
2839        view.set_state(0x0027); // OperationEnabled
2840        axis.tick(&mut view, &mut client);
2841
2842        // Start a move
2843        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2844        axis.tick(&mut view, &mut client);
2845        assert!(axis.is_busy);
2846        assert!(!axis.is_error);
2847
2848        // Fault occurs mid-move
2849        view.set_state(0x0008); // Fault
2850        axis.tick(&mut view, &mut client);
2851
2852        // is_busy should be false, is_error should be true
2853        assert!(!axis.is_busy);
2854        assert!(axis.is_error);
2855        assert_eq!(axis.op, AxisOp::Idle);
2856    }
2857
2858    #[test]
2859    fn move_absolute_rejected_by_max_limit() {
2860        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2861        let mut view = MockView::new();
2862        view.set_state(0x0027);
2863        axis.tick(&mut view, &mut client);
2864
2865        axis.set_software_max_limit(90.0);
2866        axis.move_absolute(&mut view, 100.0, 90.0, 180.0, 180.0);
2867
2868        // Should not have started a move — error instead
2869        assert!(axis.is_error);
2870        assert_eq!(axis.op, AxisOp::Idle);
2871        assert!(axis.error_message.contains("max software limit"));
2872    }
2873
2874    #[test]
2875    fn move_absolute_rejected_by_min_limit() {
2876        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2877        let mut view = MockView::new();
2878        view.set_state(0x0027);
2879        axis.tick(&mut view, &mut client);
2880
2881        axis.set_software_min_limit(-10.0);
2882        axis.move_absolute(&mut view, -20.0, 90.0, 180.0, 180.0);
2883
2884        assert!(axis.is_error);
2885        assert_eq!(axis.op, AxisOp::Idle);
2886        assert!(axis.error_message.contains("min software limit"));
2887    }
2888
2889    #[test]
2890    fn move_relative_rejected_by_max_limit() {
2891        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2892        let mut view = MockView::new();
2893        view.set_state(0x0027);
2894        axis.tick(&mut view, &mut client);
2895
2896        // Position is 0, max limit 50 — relative move of +60 should be rejected
2897        axis.set_software_max_limit(50.0);
2898        axis.move_relative(&mut view, 60.0, 90.0, 180.0, 180.0);
2899
2900        assert!(axis.is_error);
2901        assert_eq!(axis.op, AxisOp::Idle);
2902        assert!(axis.error_message.contains("max software limit"));
2903    }
2904
2905    #[test]
2906    fn move_within_limits_allowed() {
2907        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2908        let mut view = MockView::new();
2909        view.set_state(0x0027);
2910        axis.tick(&mut view, &mut client);
2911
2912        axis.set_software_max_limit(90.0);
2913        axis.set_software_min_limit(-90.0);
2914        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2915
2916        // Should have started normally
2917        assert!(!axis.is_error);
2918        assert!(matches!(
2919            axis.op,
2920            AxisOp::Moving(MoveKind::Absolute, 1, _, _)
2921        ));
2922    }
2923
2924    #[test]
2925    fn runtime_limit_halts_move_in_violated_direction() {
2926        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2927        let mut view = MockView::new();
2928        view.set_state(0x0027);
2929        axis.tick(&mut view, &mut client);
2930
2931        axis.set_software_max_limit(45.0);
2932        // Start a move to exactly the limit (allowed)
2933        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2934
2935        // Simulate the drive overshooting past 45° (position_actual in counts)
2936        // home_offset is 0, so 1650 counts = 46.4°
2937        view.position_actual = 1650;
2938        view.velocity_actual = 100; // moving positive
2939
2940        // Simulate set-point ack so we're in Moving step 2
2941        view.status_word = 0x1027;
2942        axis.tick(&mut view, &mut client);
2943        view.status_word = 0x0027;
2944        axis.tick(&mut view, &mut client);
2945
2946        // Should have halted cleanly without error. The axis enters the
2947        // Halting close-out (caller waits for !is_busy then checks
2948        // at_max_limit).
2949        assert!(!axis.is_error);
2950        assert!(axis.at_max_limit);
2951        assert!(axis.is_busy);
2952        assert!(matches!(axis.op, AxisOp::Halting(_)));
2953        // Halt bit (bit 8) should be set
2954        assert!(view.control_word & (1 << 8) != 0);
2955    }
2956
2957    #[test]
2958    fn runtime_limit_allows_move_in_opposite_direction() {
2959        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2960        let mut view = MockView::new();
2961        view.set_state(0x0027);
2962        // Start at 50° (past max limit)
2963        view.position_actual = 1778; // ~50°
2964        axis.set_software_max_limit(45.0);
2965        axis.tick(&mut view, &mut client);
2966        assert!(axis.at_max_limit);
2967
2968        // Move back toward 0 — should be allowed even though at max limit
2969        axis.move_absolute(&mut view, 0.0, 90.0, 180.0, 180.0);
2970        assert!(!axis.is_error);
2971        assert!(matches!(
2972            axis.op,
2973            AxisOp::Moving(MoveKind::Absolute, 1, _, _)
2974        ));
2975
2976        // Simulate moving negative — limit check should not halt
2977        view.velocity_actual = -100;
2978        view.status_word = 0x1027; // ack
2979        axis.tick(&mut view, &mut client);
2980        // Still moving, no error from limit
2981        assert!(!axis.is_error);
2982    }
2983
2984    #[test]
2985    fn positive_limit_switch_halts_positive_move() {
2986        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2987        let mut view = MockView::new();
2988        view.set_state(0x0027);
2989        axis.tick(&mut view, &mut client);
2990
2991        // Start a move in the positive direction
2992        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2993        view.velocity_actual = 100; // moving positive
2994                                    // Simulate set-point ack so we're in Moving step 2
2995        view.status_word = 0x1027;
2996        axis.tick(&mut view, &mut client);
2997        view.status_word = 0x0027;
2998
2999        // Now the positive limit switch trips
3000        view.positive_limit = true;
3001        axis.tick(&mut view, &mut client);
3002
3003        // Limit hit during a regular move enters the Halting close-out:
3004        // no error, halt bit asserted, drive's queued setpoint will be
3005        // canceled and halt cleared before the axis returns to Idle.
3006        // Caller waits for !is_busy then inspects at_positive_limit_switch.
3007        assert!(!axis.is_error);
3008        assert!(axis.at_positive_limit_switch);
3009        assert!(axis.is_busy);
3010        assert!(matches!(axis.op, AxisOp::Halting(_)));
3011        // Halt bit should be set
3012        assert!(view.control_word & (1 << 8) != 0);
3013    }
3014
3015    #[test]
3016    fn negative_limit_switch_halts_negative_move() {
3017        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3018        let mut view = MockView::new();
3019        view.set_state(0x0027);
3020        axis.tick(&mut view, &mut client);
3021
3022        // Start a move in the negative direction
3023        axis.move_absolute(&mut view, -45.0, 90.0, 180.0, 180.0);
3024        view.velocity_actual = -100; // moving negative
3025        view.status_word = 0x1027;
3026        axis.tick(&mut view, &mut client);
3027        view.status_word = 0x0027;
3028
3029        // Negative limit switch trips
3030        view.negative_limit = true;
3031        axis.tick(&mut view, &mut client);
3032
3033        // Limit hit during a regular move enters the Halting close-out:
3034        // no error, halt bit asserted, drive's queued setpoint will be
3035        // canceled and halt cleared before the axis returns to Idle.
3036        // Caller waits for !is_busy then inspects at_negative_limit_switch.
3037        assert!(!axis.is_error);
3038        assert!(axis.at_negative_limit_switch);
3039        assert!(axis.is_busy);
3040        assert!(matches!(axis.op, AxisOp::Halting(_)));
3041        assert!(view.control_word & (1 << 8) != 0);
3042    }
3043
3044    #[test]
3045    fn limit_halt_flushes_queue_via_mode_toggle() {
3046        // After a limit-switch halt, the axis runs the Halting close-out:
3047        // motor stop → toggle the mode of operation (PP → Homing → PP) to flush
3048        // the set-point queue → clear halt. This verifies it enters that toggle
3049        // with halt held; the SDO mode-switch completion is hardware-verified.
3050        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3051        let mut view = MockView::new();
3052        view.set_state(0x0027); // OperationEnabled
3053        axis.tick(&mut view, &mut client);
3054
3055        // Drive an initial move into a positive limit.
3056        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
3057        view.velocity_actual = 100;
3058        view.status_word = 0x1027;
3059        axis.tick(&mut view, &mut client);
3060        view.status_word = 0x0027;
3061        view.positive_limit = true;
3062        axis.tick(&mut view, &mut client);
3063
3064        // Limit halt: in Halting close-out, halt asserted, busy, no error.
3065        assert!(!axis.is_error);
3066        assert!(axis.is_busy);
3067        assert!(matches!(axis.op, AxisOp::Halting(_)));
3068        assert!(view.control_word & (1 << 8) != 0);
3069
3070        // Advance past WaitStopped into the mode toggle. With a static MockView
3071        // and no SDO responder, the mode switch stays in flight — so we verify
3072        // the structural transition here (the full SDO mode-switch completion is
3073        // exercised on hardware via the CiA-402 trace): halt is held while the
3074        // set-point queue is flushed by switching the mode of operation away
3075        // from Profile Position.
3076        view.velocity_actual = 0;
3077        view.positive_limit = false;
3078        for _ in 0..HALT_STABLE_TICKS_REQUIRED {
3079            axis.tick(&mut view, &mut client);
3080        }
3081        let AxisOp::Halting(step) = axis.op.clone() else {
3082            unreachable!()
3083        };
3084        assert_eq!(step, HaltState::Flushing as u8, "halt flushes the queue via a mode toggle");
3085        assert!(view.control_word & (1 << 8) != 0, "halt held through the queue flush");
3086        assert!(axis.is_busy, "axis stays busy through the close-out");
3087        assert_ne!(
3088            view.modes_of_operation,
3089            ModesOfOperation::ProfilePosition.as_i8(),
3090            "mode switched away from PP to flush the queue",
3091        );
3092    }
3093
3094    #[test]
3095    fn limit_switch_allows_move_in_opposite_direction() {
3096        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3097        let mut view = MockView::new();
3098        view.set_state(0x0027);
3099        // Positive limit is active, but we're moving negative (retreating)
3100        view.positive_limit = true;
3101        view.velocity_actual = -100;
3102        axis.tick(&mut view, &mut client);
3103        assert!(axis.at_positive_limit_switch);
3104
3105        // Move in the negative direction should be allowed
3106        axis.move_absolute(&mut view, -10.0, 90.0, 180.0, 180.0);
3107        view.status_word = 0x1027;
3108        axis.tick(&mut view, &mut client);
3109
3110        // Should still be moving, no error
3111        assert!(!axis.is_error);
3112        assert!(matches!(axis.op, AxisOp::Moving(_, _, _, _)));
3113    }
3114
3115    #[test]
3116    fn limit_switch_ignored_when_not_moving() {
3117        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3118        let mut view = MockView::new();
3119        view.set_state(0x0027);
3120        view.positive_limit = true;
3121
3122        axis.tick(&mut view, &mut client);
3123
3124        // Output flag is set, but no error since we're not moving
3125        assert!(axis.at_positive_limit_switch);
3126        assert!(!axis.is_error);
3127    }
3128
3129    #[test]
3130    fn home_sensor_output_tracks_view() {
3131        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3132        let mut view = MockView::new();
3133        view.set_state(0x0027);
3134
3135        axis.tick(&mut view, &mut client);
3136        assert!(!axis.home_sensor);
3137
3138        view.home_sensor = true;
3139        axis.tick(&mut view, &mut client);
3140        assert!(axis.home_sensor);
3141
3142        view.home_sensor = false;
3143        axis.tick(&mut view, &mut client);
3144        assert!(!axis.home_sensor);
3145    }
3146
3147    #[test]
3148    fn velocity_output_converted() {
3149        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3150        let mut view = MockView::new();
3151        view.set_state(0x0027);
3152        // 3200 counts/s = 90 deg/s
3153        view.velocity_actual = 3200;
3154
3155        axis.tick(&mut view, &mut client);
3156
3157        assert!((axis.speed - 90.0).abs() < 0.1);
3158        assert!(axis.moving_positive);
3159        assert!(!axis.moving_negative);
3160    }
3161
3162    // ── Software homing tests ──
3163
3164    fn soft_homing_config() -> AxisConfig {
3165        let mut cfg = AxisConfig::new(12_800).with_user_scale(360.0);
3166        cfg.homing_speed = 10.0;
3167        cfg.homing_accel = 20.0;
3168        cfg.homing_decel = 20.0;
3169        cfg
3170    }
3171
3172    fn soft_homing_axis() -> (
3173        Axis,
3174        CommandClient,
3175        tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>,
3176        tokio::sync::mpsc::UnboundedReceiver<String>,
3177    ) {
3178        use tokio::sync::mpsc;
3179        let (write_tx, write_rx) = mpsc::unbounded_channel();
3180        let (response_tx, response_rx) = mpsc::unbounded_channel();
3181        let client = CommandClient::new(write_tx, response_rx);
3182        let axis = Axis::new(soft_homing_config(), "TestDrive");
3183        (axis, client, response_tx, write_rx)
3184    }
3185
3186    /// Helper: enable the axis and put it in OperationEnabled state.
3187    fn enable_axis(axis: &mut Axis, view: &mut MockView, client: &mut CommandClient) {
3188        view.set_state(0x0027); // OperationEnabled
3189        axis.tick(view, client);
3190    }
3191
3192    /// Helper: drive the soft homing state machine through phases 2-4
3193    /// (halt, back-off, set home). Call after sensor triggers (step 4).
3194    /// `trigger_pos`: position where sensor triggered
3195    /// `clear_sensor`: closure to deactivate the sensor on the view
3196    fn complete_soft_homing(
3197        axis: &mut Axis,
3198        view: &mut MockView,
3199        client: &mut CommandClient,
3200        resp_tx: &tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>,
3201        trigger_pos: i32,
3202        clear_sensor: impl FnOnce(&mut MockView),
3203    ) {
3204        use mechutil::ipc::CommandMessage as IpcMsg;
3205
3206        // Phase 2: HALT (steps 4-6)
3207        // Step 4: halt
3208        axis.tick(view, client);
3209        assert!(matches!(axis.op, AxisOp::SoftHoming(5)));
3210
3211        // Step 5: motor decelerating then stopped
3212        view.position_actual = trigger_pos + 100;
3213        axis.tick(view, client);
3214        view.position_actual = trigger_pos + 120;
3215        axis.tick(view, client);
3216        // 10 stable ticks
3217        for _ in 0..10 {
3218            axis.tick(view, client);
3219        }
3220        assert!(matches!(axis.op, AxisOp::SoftHoming(6)));
3221
3222        // Step 6: cancel ack → delay step 60
3223        view.status_word = 0x1027;
3224        axis.tick(view, client);
3225        assert!(matches!(axis.op, AxisOp::SoftHoming(60)));
3226        view.status_word = 0x0027;
3227
3228        // Step 60: delay (~1 second = 100 ticks)
3229        for _ in 0..100 {
3230            axis.tick(view, client);
3231        }
3232        assert!(matches!(axis.op, AxisOp::SoftHoming(7)));
3233
3234        // Phase 3: BACK-OFF (steps 7-11)
3235        // Step 7: start back-off move
3236        axis.tick(view, client);
3237        assert!(matches!(axis.op, AxisOp::SoftHoming(8)));
3238
3239        // Step 8: ack
3240        view.status_word = 0x1027;
3241        axis.tick(view, client);
3242        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
3243        view.status_word = 0x0027;
3244
3245        // Step 9: sensor still active, then clears
3246        axis.tick(view, client);
3247        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
3248        clear_sensor(view);
3249        view.position_actual = trigger_pos - 200;
3250        axis.tick(view, client);
3251        assert!(matches!(axis.op, AxisOp::SoftHoming(10)));
3252
3253        // Step 10-11: halt after back-off, wait stable
3254        axis.tick(view, client);
3255        assert!(matches!(axis.op, AxisOp::SoftHoming(11)));
3256        for _ in 0..10 {
3257            axis.tick(view, client);
3258        }
3259        assert!(matches!(axis.op, AxisOp::SoftHoming(12)));
3260
3261        // Phase 4: SET HOME (steps 12-19)
3262        // Step 12: cancel ack + SDO write home offset
3263        view.status_word = 0x1027;
3264        axis.tick(view, client);
3265        view.status_word = 0x0027;
3266        assert!(matches!(axis.op, AxisOp::SoftHoming(13)));
3267
3268        // Step 13: SDO ack for home offset
3269        let tid = axis.homing_sdo_tid;
3270        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
3271        client.poll();
3272        axis.tick(view, client);
3273        assert!(matches!(axis.op, AxisOp::SoftHoming(14)));
3274
3275        // Step 14→15: SDO write homing method, ack
3276        axis.tick(view, client);
3277        let tid = axis.homing_sdo_tid;
3278        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
3279        client.poll();
3280        axis.tick(view, client);
3281        assert!(matches!(axis.op, AxisOp::SoftHoming(16)));
3282
3283        // Step 16: switch to homing mode + trigger
3284        view.modes_of_operation_display = ModesOfOperation::Homing.as_i8();
3285        axis.tick(view, client);
3286        assert!(matches!(axis.op, AxisOp::SoftHoming(17)));
3287
3288        // Step 17: homing complete (attained + reached)
3289        view.status_word = 0x1427; // bit 12 + bit 10
3290        axis.tick(view, client);
3291        assert!(matches!(axis.op, AxisOp::SoftHoming(18)));
3292        view.modes_of_operation_display = ModesOfOperation::ProfilePosition.as_i8();
3293        view.status_word = 0x0027;
3294
3295        // Step 18: hold position
3296        axis.tick(view, client);
3297        assert!(matches!(axis.op, AxisOp::SoftHoming(19)));
3298
3299        // Step 19: hold ack
3300        view.status_word = 0x1027;
3301        axis.tick(view, client);
3302        view.status_word = 0x0027;
3303
3304        assert_eq!(axis.op, AxisOp::Idle);
3305        assert!(!axis.is_busy);
3306        assert!(!axis.is_error);
3307        assert_eq!(axis.home_offset, 0); // drive handles it now
3308    }
3309
3310    #[test]
3311    fn soft_homing_pnp_home_sensor_full_sequence() {
3312        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
3313        let mut view = MockView::new();
3314        enable_axis(&mut axis, &mut view, &mut client);
3315
3316        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3317
3318        // Phase 1: search
3319        axis.tick(&mut view, &mut client); // step 0→1
3320        view.status_word = 0x1027;
3321        axis.tick(&mut view, &mut client); // step 1→2 (ack)
3322        view.status_word = 0x0027;
3323        axis.tick(&mut view, &mut client); // step 2→3
3324
3325        // Sensor triggers
3326        view.home_sensor = true;
3327        view.position_actual = 5000;
3328        axis.tick(&mut view, &mut client);
3329        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3330
3331        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000, |v| {
3332            v.home_sensor = false;
3333        });
3334    }
3335
3336    #[test]
3337    fn soft_homing_npn_home_sensor_full_sequence() {
3338        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
3339        let mut view = MockView::new();
3340        // NPN: sensor reads true normally, false when detected
3341        view.home_sensor = true;
3342        enable_axis(&mut axis, &mut view, &mut client);
3343
3344        axis.home(&mut view, HomingMethod::HomeSensorPosNpn);
3345
3346        // Phase 1: search
3347        axis.tick(&mut view, &mut client);
3348        view.status_word = 0x1027;
3349        axis.tick(&mut view, &mut client);
3350        view.status_word = 0x0027;
3351        axis.tick(&mut view, &mut client);
3352
3353        // NPN: sensor goes false = detected
3354        view.home_sensor = false;
3355        view.position_actual = 3000;
3356        axis.tick(&mut view, &mut client);
3357        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3358
3359        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 3000, |v| {
3360            v.home_sensor = true;
3361        }); // NPN: back to true = cleared
3362    }
3363
3364    #[test]
3365    fn soft_homing_limit_switch_suppresses_halt() {
3366        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3367        let mut view = MockView::new();
3368        enable_axis(&mut axis, &mut view, &mut client);
3369
3370        // Software homing on positive limit switch (rising edge)
3371        axis.home(&mut view, HomingMethod::LimitSwitchPosPnp);
3372
3373        // Progress through initial steps
3374        axis.tick(&mut view, &mut client); // step 0 → 1
3375        view.status_word = 0x1027; // ack
3376        axis.tick(&mut view, &mut client); // step 1 → 2
3377        view.status_word = 0x0027;
3378        axis.tick(&mut view, &mut client); // step 2 → 3
3379
3380        // Positive limit switch trips — should NOT halt (suppressed)
3381        view.positive_limit = true;
3382        view.velocity_actual = 100; // moving positive
3383        view.position_actual = 8000;
3384        axis.tick(&mut view, &mut client);
3385
3386        // Should have detected rising edge → step 4, NOT an error halt
3387        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3388        assert!(!axis.is_error);
3389    }
3390
3391    #[test]
3392    fn soft_homing_opposite_limit_still_protects() {
3393        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3394        let mut view = MockView::new();
3395        enable_axis(&mut axis, &mut view, &mut client);
3396
3397        // Software homing on home sensor (positive direction)
3398        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3399
3400        // Progress through initial steps
3401        axis.tick(&mut view, &mut client); // step 0 → 1
3402        view.status_word = 0x1027; // ack
3403        axis.tick(&mut view, &mut client); // step 1 → 2
3404        view.status_word = 0x0027;
3405        axis.tick(&mut view, &mut client); // step 2 → 3
3406
3407        // Negative limit switch trips while searching positive (shouldn't happen
3408        // in practice, but tests protection)
3409        view.negative_limit = true;
3410        view.velocity_actual = -100; // moving negative
3411        axis.tick(&mut view, &mut client);
3412
3413        // Should have halted with error (negative limit protects)
3414        assert!(axis.is_error);
3415        assert!(axis.error_message.contains("Negative limit switch"));
3416    }
3417
3418    #[test]
3419    // fn soft_homing_sensor_already_active_rejects() {
3420    //     let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3421    //     let mut view = MockView::new();
3422    //     enable_axis(&mut axis, &mut view, &mut client);
3423
3424    //     // Home sensor is already active (rising edge would never happen)
3425    //     view.home_sensor = true;
3426    //     axis.tick(&mut view, &mut client); // update prev state
3427
3428    //     axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3429
3430    //     // Should have been rejected immediately
3431    //     assert!(axis.is_error);
3432    //     assert!(axis.error_message.contains("already in trigger state"));
3433    //     assert_eq!(axis.op, AxisOp::Idle);
3434    // }
3435    #[test]
3436    fn soft_homing_negative_direction_sets_negative_target() {
3437        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3438        let mut view = MockView::new();
3439        enable_axis(&mut axis, &mut view, &mut client);
3440
3441        axis.home(&mut view, HomingMethod::HomeSensorNegPnp);
3442        axis.tick(&mut view, &mut client); // step 0
3443
3444        // Target should be negative (large negative value in counts)
3445        assert!(view.target_position < 0);
3446    }
3447
3448    #[test]
3449    fn home_integrated_method_starts_hardware_homing() {
3450        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3451        let mut view = MockView::new();
3452        enable_axis(&mut axis, &mut view, &mut client);
3453
3454        axis.home(&mut view, HomingMethod::CurrentPosition);
3455        assert!(matches!(axis.op, AxisOp::Homing(0)));
3456        assert_eq!(axis.homing_method, 37);
3457    }
3458
3459    #[test]
3460    fn home_integrated_arbitrary_code() {
3461        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3462        let mut view = MockView::new();
3463        enable_axis(&mut axis, &mut view, &mut client);
3464
3465        axis.home(&mut view, HomingMethod::Integrated(35));
3466        assert!(matches!(axis.op, AxisOp::Homing(0)));
3467        assert_eq!(axis.homing_method, 35);
3468    }
3469
3470    #[test]
3471    fn hardware_homing_skips_speed_sdos_when_zero() {
3472        use mechutil::ipc::CommandMessage;
3473
3474        let (mut axis, mut client, resp_tx, mut write_rx) = test_axis();
3475        let mut view = MockView::new();
3476        enable_axis(&mut axis, &mut view, &mut client);
3477
3478        // Config has homing_speed = 0 and homing_accel = 0 (defaults)
3479        axis.home(&mut view, HomingMethod::Integrated(37));
3480
3481        // Step 0: writes homing method SDO
3482        axis.tick(&mut view, &mut client);
3483        assert!(matches!(axis.op, AxisOp::Homing(1)));
3484
3485        // Drain the SDO write message
3486        let _ = write_rx.try_recv();
3487
3488        // Simulate SDO ack — need to use the correct tid from the sdo write
3489        let tid = axis.homing_sdo_tid;
3490        resp_tx
3491            .send(CommandMessage::response(tid, serde_json::json!(null)))
3492            .unwrap();
3493        client.poll();
3494        axis.tick(&mut view, &mut client);
3495
3496        // Should have skipped to step 8 (set homing mode)
3497        assert!(matches!(axis.op, AxisOp::Homing(8)));
3498    }
3499
3500    #[test]
3501    fn hardware_homing_writes_speed_sdos_when_nonzero() {
3502        use mechutil::ipc::CommandMessage;
3503
3504        let (mut axis, mut client, resp_tx, mut write_rx) = soft_homing_axis();
3505        let mut view = MockView::new();
3506        enable_axis(&mut axis, &mut view, &mut client);
3507
3508        // Config has homing_speed = 10.0, homing_accel = 20.0
3509        axis.home(&mut view, HomingMethod::Integrated(37));
3510
3511        // Step 0: writes homing method SDO
3512        axis.tick(&mut view, &mut client);
3513        assert!(matches!(axis.op, AxisOp::Homing(1)));
3514        let _ = write_rx.try_recv();
3515
3516        // SDO ack for homing method
3517        let tid = axis.homing_sdo_tid;
3518        resp_tx
3519            .send(CommandMessage::response(tid, serde_json::json!(null)))
3520            .unwrap();
3521        client.poll();
3522        axis.tick(&mut view, &mut client);
3523        // Should go to step 2 (write speed SDO), not skip to 8
3524        assert!(matches!(axis.op, AxisOp::Homing(2)));
3525    }
3526
3527    #[test]
3528    fn soft_homing_edge_during_ack_step() {
3529        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3530        let mut view = MockView::new();
3531        enable_axis(&mut axis, &mut view, &mut client);
3532
3533        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3534        axis.tick(&mut view, &mut client); // step 0 → 1
3535
3536        // Sensor rises during step 1 (before ack)
3537        view.home_sensor = true;
3538        view.position_actual = 2000;
3539        axis.tick(&mut view, &mut client);
3540
3541        // Should jump straight to step 4 (edge detected)
3542        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3543    }
3544
3545    #[test]
3546    fn soft_homing_applies_home_position() {
3547        let mut cfg = soft_homing_config();
3548        cfg.home_position = 90.0;
3549
3550        use tokio::sync::mpsc;
3551        let (write_tx, _write_rx) = mpsc::unbounded_channel();
3552        let (resp_tx, response_rx) = mpsc::unbounded_channel();
3553        let mut client = CommandClient::new(write_tx, response_rx);
3554        let mut axis = Axis::new(cfg, "TestDrive");
3555
3556        let mut view = MockView::new();
3557        enable_axis(&mut axis, &mut view, &mut client);
3558
3559        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3560
3561        // Search phase
3562        axis.tick(&mut view, &mut client);
3563        view.status_word = 0x1027;
3564        axis.tick(&mut view, &mut client);
3565        view.status_word = 0x0027;
3566        axis.tick(&mut view, &mut client);
3567
3568        // Sensor triggers
3569        view.home_sensor = true;
3570        view.position_actual = 5000;
3571        axis.tick(&mut view, &mut client);
3572        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3573
3574        // Complete full sequence (halt, back-off, set home via drive)
3575        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000, |v| {
3576            v.home_sensor = false;
3577        });
3578
3579        // After completion, home_offset = 0 (drive handles it)
3580        assert_eq!(axis.home_offset, 0);
3581    }
3582
3583    #[test]
3584    fn soft_homing_default_home_position_zero() {
3585        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
3586        let mut view = MockView::new();
3587        enable_axis(&mut axis, &mut view, &mut client);
3588
3589        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3590
3591        // Search phase
3592        axis.tick(&mut view, &mut client);
3593        view.status_word = 0x1027;
3594        axis.tick(&mut view, &mut client);
3595        view.status_word = 0x0027;
3596        axis.tick(&mut view, &mut client);
3597
3598        // Sensor triggers
3599        view.home_sensor = true;
3600        view.position_actual = 5000;
3601        axis.tick(&mut view, &mut client);
3602
3603        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000, |v| {
3604            v.home_sensor = false;
3605        });
3606
3607        assert_eq!(axis.home_offset, 0);
3608    }
3609}