Skip to main content

autocore_std/motion/
axis.rs

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