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