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