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};
25
26use serde_json::json;
27use strum_macros::FromRepr;
28
29use crate::command_client::CommandClient;
30use crate::ethercat::{SdoClient, SdoResult};
31use crate::fb::Ton;
32use crate::motion::FbSetModeOfOperation;
33use super::axis_config::AxisConfig;
34use super::axis_view::AxisView;
35use super::homing::HomingMethod;
36use super::cia402::{
37    Cia402Control, Cia402Status, Cia402State,
38    ModesOfOperation, RawControlWord, RawStatusWord,
39};
40
41// ──────────────────────────────────────────────
42// Internal state machine
43// ──────────────────────────────────────────────
44
45#[derive(Debug, Clone, PartialEq)]
46enum AxisOp {
47    Idle,
48    Enabling(u8),
49    Disabling(u8),
50    Moving(MoveKind, u8),
51    Homing(u8),
52    SoftHoming(u8),
53    Halting,
54    FaultRecovery(u8),
55}
56
57#[repr(u8)]
58#[derive(Debug, Clone, PartialEq, FromRepr)]
59enum HomeState {
60    EnsurePpMode = 0,
61    WaitPpMode = 1,
62    Search = 5,
63    WaitSearching = 10,
64    WaitFoundSensor = 20,
65    WaitStoppedFoundSensor = 30,
66    WaitFoundSensorAck = 40,
67    WaitFoundSensorAckClear = 45,
68    DebounceFoundSensor = 50,
69    BackOff = 60,
70    WaitBackingOff = 70,
71    WaitLostSensor = 80,
72    WaitStoppedLostSensor = 90,
73    WaitLostSensorAck = 100,
74    WaitLostSensorAckClear = 120,
75    WaitHomeOffsetDone = 125,
76
77    WriteHomingModeOp = 160,
78    WaitWriteHomingModeOp = 165,
79    
80    WriteHomingMethod = 205,
81    WaitWriteHomingMethodDone = 210,
82    ClearHomingTrigger = 215,
83    TriggerHoming = 217,
84    WaitHomingStarted = 218,
85    WaitHomingDone = 220,
86    ResetHomingTrigger = 222,
87    WaitHomingTriggerCleared = 223,
88    WriteMotionModeOfOperation = 230,
89    WaitWriteMotionModeOfOperation = 235,
90    SendCurrentPositionTarget = 240,
91    WaitCurrentPositionTargetSent = 245
92
93}
94
95#[derive(Debug, Clone, PartialEq)]
96enum MoveKind {
97    Absolute,
98    Relative,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
102enum SoftHomeSensor {
103    PositiveLimit,
104    NegativeLimit,
105    HomeSensor,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
109enum SoftHomeSensorType {
110    /// PNP: sensor reads true when object detected (normally open)
111    Pnp,
112    /// NPN: sensor reads false when object detected (normally closed)
113    Npn,
114}
115
116// ──────────────────────────────────────────────
117// Axis
118// ──────────────────────────────────────────────
119
120/// Stateful motion controller for a CiA 402 servo drive.
121///
122/// Manages the CiA 402 protocol (state machine, PP handshake, homing)
123/// internally. Call [`tick()`](Self::tick) every control cycle to progress
124/// operations and update output fields.
125pub struct Axis {
126    config: AxisConfig,
127    sdo: SdoClient,
128
129    // ── Internal state ──
130    op: AxisOp,
131    home_offset: i32,
132    last_raw_position: i32,
133    op_started: Option<Instant>,
134    op_timeout: Duration,
135    homing_timeout: Duration,
136    move_start_timeout: Duration,
137    pending_move_target: i32,
138    pending_move_vel: u32,
139    pending_move_accel: u32,
140    pending_move_decel: u32,
141    homing_method: i8,
142    homing_sdo_tid: u32,
143    soft_home_sensor: SoftHomeSensor,
144    soft_home_sensor_type: SoftHomeSensorType,
145    soft_home_direction: f64,
146    halt_stable_count: u8,
147    prev_positive_limit: bool,
148    prev_negative_limit: bool,
149    prev_home_sensor: bool,
150
151
152
153    fb_mode_of_operation : FbSetModeOfOperation,
154
155    // ── Outputs (updated every tick) ──
156
157    /// True if a drive fault or operation timeout has occurred.
158    pub is_error: bool,
159    /// Drive error code (from status word or view error_code).
160    pub error_code: u32,
161    /// Human-readable error description.
162    pub error_message: String,
163    /// True when the drive is in Operation Enabled state.
164    pub motor_on: bool,
165    /// True when any operation is in progress (enable, move, home, fault recovery, etc.).
166    ///
167    /// Derived from the internal state machine — immediately true when a command
168    /// is issued, false when the operation completes or a fault cancels it.
169    /// Use this (not [`in_motion`](Self::in_motion)) to wait for operations to finish.
170    pub is_busy: bool,
171    /// True while a move operation specifically is active (subset of [`is_busy`](Self::is_busy)).
172    pub in_motion: bool,
173    /// True when velocity is positive.
174    pub moving_positive: bool,
175    /// True when velocity is negative.
176    pub moving_negative: bool,
177    /// Current position in user units (relative to home).
178    pub position: f64,
179    /// Current position in raw encoder counts (widened from i32).
180    pub raw_position: i64,
181    /// Current speed in user units/s (absolute value).
182    pub speed: f64,
183    /// True when position is at or beyond the maximum software limit.
184    pub at_max_limit: bool,
185    /// True when position is at or beyond the minimum software limit.
186    pub at_min_limit: bool,
187    /// True when the positive-direction hardware limit switch is active.
188    pub at_positive_limit_switch: bool,
189    /// True when the negative-direction hardware limit switch is active.
190    pub at_negative_limit_switch: bool,
191    /// True when the home reference sensor is active.
192    pub home_sensor: bool,
193
194
195    /// Timer used for delays between states.
196    ton : Ton
197}
198
199impl Axis {
200    /// Create a new Axis with the given configuration.
201    ///
202    /// `device_name` must match the device name in `project.json`
203    /// (used for SDO operations during homing).
204    pub fn new(config: AxisConfig, device_name: &str) -> Self {
205        let op_timeout = Duration::from_secs_f64(config.operation_timeout_secs);
206        let homing_timeout = Duration::from_secs_f64(config.homing_timeout_secs);
207        let move_start_timeout = op_timeout; // reuse operation timeout for move handshake
208        Self {
209            config,
210            sdo: SdoClient::new(device_name),
211            op: AxisOp::Idle,
212            home_offset: 0,
213            last_raw_position: 0,
214            op_started: None,
215            op_timeout,
216            homing_timeout,
217            move_start_timeout,
218            pending_move_target: 0,
219            pending_move_vel: 0,
220            pending_move_accel: 0,
221            pending_move_decel: 0,
222            homing_method: 37,
223            homing_sdo_tid: 0,
224            soft_home_sensor: SoftHomeSensor::HomeSensor,
225            soft_home_sensor_type: SoftHomeSensorType::Pnp,
226            soft_home_direction: 1.0,
227            halt_stable_count: 0,
228            prev_positive_limit: false,
229            prev_negative_limit: false,
230            prev_home_sensor: false,
231            is_error: false,
232            error_code: 0,
233            error_message: String::new(),
234            motor_on: false,
235            is_busy: false,
236            in_motion: false,
237            moving_positive: false,
238            moving_negative: false,
239            position: 0.0,
240            raw_position: 0,
241            speed: 0.0,
242            at_max_limit: false,
243            at_min_limit: false,
244            at_positive_limit_switch: false,
245            at_negative_limit_switch: false,
246            home_sensor: false,
247            ton: Ton::new(),
248            fb_mode_of_operation : FbSetModeOfOperation::new()
249        }
250    }
251
252    /// Get a reference to the axis configuration.
253    pub fn config(&self) -> &AxisConfig {
254        &self.config
255    }
256
257    // ═══════════════════════════════════════════
258    // Motion commands
259    // ═══════════════════════════════════════════
260
261    /// Start an absolute move to `target` in user units.
262    ///
263    /// The axis must be enabled (Operation Enabled) before calling this.
264    /// If the target exceeds a software position limit, the move is rejected
265    /// and [`is_error`](Self::is_error) is set.
266    pub fn move_absolute(
267        &mut self,
268        view: &mut impl AxisView,
269        target: f64,
270        vel: f64,
271        accel: f64,
272        decel: f64,
273    ) {
274        if let Some(msg) = self.check_target_limit(target, view) {
275            self.set_op_error(&msg);
276            return;
277        }
278
279        let cpu = self.config.counts_per_user();
280        let raw_target = self.config.to_counts(target).round() as i32 + self.home_offset;
281        let raw_vel = (vel * cpu).round() as u32;
282        let raw_accel = (accel * cpu).round() as u32;
283        let raw_decel = (decel * cpu).round() as u32;
284
285        self.start_move(view, raw_target, raw_vel, raw_accel, raw_decel, MoveKind::Absolute);
286    }
287
288    /// Start a relative move by `distance` user units from the current position.
289    ///
290    /// The axis must be enabled (Operation Enabled) before calling this.
291    /// If the resulting position would exceed a software position limit,
292    /// the move is rejected and [`is_error`](Self::is_error) is set.
293    pub fn move_relative(
294        &mut self,
295        view: &mut impl AxisView,
296        distance: f64,
297        vel: f64,
298        accel: f64,
299        decel: f64,
300    ) {
301        log::info!("Axis: request to move relative dist {} vel {} accel {} decel {}",
302            distance, vel, accel, decel
303        );
304
305        if let Some(msg) = self.check_target_limit(self.position + distance, view) {
306            self.set_op_error(&msg);
307            return;
308        }
309
310        let cpu = self.config.counts_per_user();
311        let raw_distance = self.config.to_counts(distance).round() as i32;
312        let raw_vel = (vel * cpu).round() as u32;
313        let raw_accel = (accel * cpu).round() as u32;
314        let raw_decel = (decel * cpu).round() as u32;
315
316        log::info!("Axis starting relative move: request to move relative raw dist {} raw vel {} raw accel {} raw decel {}",
317            raw_distance, raw_vel, raw_accel, raw_decel
318        );
319
320        // Make sure bit 4 is off so that a new move can be commanded.
321        let mut cw = RawControlWord(view.control_word());        
322        cw.set_bit(4, false); // new set-point
323        view.set_control_word(cw.raw());
324
325        self.start_move(view, raw_distance, raw_vel, raw_accel, raw_decel, MoveKind::Relative);
326    }
327
328    fn start_move(
329        &mut self,
330        view: &mut impl AxisView,
331        raw_target: i32,
332        raw_vel: u32,
333        raw_accel: u32,
334        raw_decel: u32,
335        kind: MoveKind,
336    ) {
337        self.pending_move_target = raw_target;
338        self.pending_move_vel = raw_vel;
339        self.pending_move_accel = raw_accel;
340        self.pending_move_decel = raw_decel;
341
342        // Set parameters on view
343        view.set_target_position(raw_target);
344        view.set_profile_velocity(raw_vel);
345        view.set_profile_acceleration(raw_accel);
346        view.set_profile_deceleration(raw_decel);
347
348        // Set control word: relative bit + trigger (new set-point)
349        let mut cw = RawControlWord(view.control_word());
350        cw.set_bit(6, kind == MoveKind::Relative);
351        cw.set_bit(4, true); // new set-point
352        view.set_control_word(cw.raw());
353
354        self.op = AxisOp::Moving(kind, 1);
355        self.op_started = Some(Instant::now());
356    }
357
358    /// Halt the current move (decelerate to stop).
359    pub fn halt(&mut self, view: &mut impl AxisView) {
360        let mut cw = RawControlWord(view.control_word());
361        cw.set_bit(8, true); // halt
362        view.set_control_word(cw.raw());
363        self.op = AxisOp::Halting;
364    }
365
366    // ═══════════════════════════════════════════
367    // Drive control
368    // ═══════════════════════════════════════════
369
370    /// Start the enable sequence (Shutdown → ReadyToSwitchOn → OperationEnabled).
371    ///
372    /// The sequence is multi-tick. Check [`motor_on`](Self::motor_on) for completion.
373    pub fn enable(&mut self, view: &mut impl AxisView) {
374        // Step 0: set PP mode + cmd_shutdown
375        view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
376        let mut cw = RawControlWord(view.control_word());
377        cw.cmd_shutdown();
378        view.set_control_word(cw.raw());
379
380        self.op = AxisOp::Enabling(1);
381        self.op_started = Some(Instant::now());
382    }
383
384    /// Start the disable sequence (OperationEnabled → SwitchedOn).
385    pub fn disable(&mut self, view: &mut impl AxisView) {
386        let mut cw = RawControlWord(view.control_word());
387        cw.cmd_disable_operation();
388        view.set_control_word(cw.raw());
389
390        self.op = AxisOp::Disabling(1);
391        self.op_started = Some(Instant::now());
392    }
393
394    /// Start a fault reset sequence.
395    ///
396    /// Clears bit 7, then asserts it (rising edge), then waits for fault to clear.
397    pub fn reset_faults(&mut self, view: &mut impl AxisView) {
398        // Step 0: clear bit 7 first (so next step produces a rising edge)
399        let mut cw = RawControlWord(view.control_word());
400        cw.cmd_clear_fault_reset();
401        view.set_control_word(cw.raw());
402
403        self.is_error = false;
404        self.error_code = 0;
405        self.error_message.clear();
406        self.op = AxisOp::FaultRecovery(1);
407        self.op_started = Some(Instant::now());
408    }
409
410    /// Start a homing sequence with the given homing method.
411    ///
412    /// **Integrated** methods delegate to the drive's built-in CiA 402
413    /// homing mode (SDO writes + homing trigger).
414    ///
415    /// **Software** methods are implemented by the Axis, which monitors
416    /// [`AxisView`] sensor signals for edge triggers and captures home.
417    pub fn home(&mut self, view: &mut impl AxisView, method: HomingMethod) {
418        if method.is_integrated() {
419            self.homing_method = method.cia402_code();
420            self.op = AxisOp::Homing(0);
421            self.op_started = Some(Instant::now());
422            let _ = view;
423        } else {
424            self.configure_soft_homing(method);
425            self.start_soft_homing(view);
426        }
427    }
428
429    // ═══════════════════════════════════════════
430    // Position management
431    // ═══════════════════════════════════════════
432
433    /// Set the current position to the given user-unit value.
434    ///
435    /// Adjusts the internal home offset so that the current raw position
436    /// maps to `user_units`. Does not move the motor.
437    pub fn set_position(&mut self, view: &impl AxisView, user_units: f64) {
438        self.home_offset = view.position_actual() - self.config.to_counts(user_units).round() as i32;
439    }
440
441    /// Set the home position in user units. This value is used by the next
442    /// `home()` call to set the axis position at the reference point.
443    /// Can be called at any time before homing.
444    pub fn set_home_position(&mut self, user_units: f64) {
445        self.config.home_position = user_units;
446    }
447
448    /// Set the maximum (positive) software position limit.
449    pub fn set_software_max_limit(&mut self, user_units: f64) {
450        self.config.max_position_limit = user_units;
451        self.config.enable_max_position_limit = true;
452    }
453
454    /// Set the minimum (negative) software position limit.
455    pub fn set_software_min_limit(&mut self, user_units: f64) {
456        self.config.min_position_limit = user_units;
457        self.config.enable_min_position_limit = true;
458    }
459
460    // ═══════════════════════════════════════════
461    // SDO pass-through
462    // ═══════════════════════════════════════════
463
464    /// Write an SDO value to the drive.
465    pub fn sdo_write(
466        &mut self,
467        client: &mut CommandClient,
468        index: u16,
469        sub_index: u8,
470        value: serde_json::Value,
471    ) {
472        self.sdo.write(client, index, sub_index, value);
473    }
474
475    /// Start an SDO read from the drive. Returns a transaction ID.
476    pub fn sdo_read(
477        &mut self,
478        client: &mut CommandClient,
479        index: u16,
480        sub_index: u8,
481    ) -> u32 {
482        self.sdo.read(client, index, sub_index)
483    }
484
485    /// Check the result of a previous SDO read.
486    pub fn sdo_result(
487        &mut self,
488        client: &mut CommandClient,
489        tid: u32,
490    ) -> SdoResult {
491        self.sdo.result(client, tid, Duration::from_secs(5))
492    }
493
494    // ═══════════════════════════════════════════
495    // Tick — call every control cycle
496    // ═══════════════════════════════════════════
497
498    /// Update outputs and progress the current operation.
499    ///
500    /// Must be called every control cycle. Does three things:
501    /// 1. Checks for drive faults
502    /// 2. Progresses the current multi-tick operation
503    /// 3. Updates output fields (position, velocity, status)
504    ///
505    /// Outputs are updated last so they reflect the final state after
506    /// all processing for this tick.
507    pub fn tick(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
508        self.check_faults(view);
509        self.progress_op(view, client);
510        self.update_outputs(view);
511        self.check_limits(view);
512    }
513
514    // ═══════════════════════════════════════════
515    // Internal: output update
516    // ═══════════════════════════════════════════
517
518    fn update_outputs(&mut self, view: &impl AxisView) {
519        let raw = view.position_actual();
520        self.raw_position = raw as i64;
521        self.position = self.config.to_user((raw - self.home_offset) as f64);
522
523        let vel = view.velocity_actual();
524        let user_vel = self.config.to_user(vel as f64);
525        self.speed = user_vel.abs();
526        self.moving_positive = user_vel > 0.0;
527        self.moving_negative = user_vel < 0.0;
528        self.is_busy = self.op != AxisOp::Idle;
529        self.in_motion = matches!(self.op, AxisOp::Moving(_, _) | AxisOp::SoftHoming(_));
530
531        let sw = RawStatusWord(view.status_word());
532        self.motor_on = sw.state() == Cia402State::OperationEnabled;
533
534        self.last_raw_position = raw;
535    }
536
537    // ═══════════════════════════════════════════
538    // Internal: fault check
539    // ═══════════════════════════════════════════
540
541    fn check_faults(&mut self, view: &impl AxisView) {
542        let sw = RawStatusWord(view.status_word());
543        let state = sw.state();
544
545        if matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
546            if !matches!(self.op, AxisOp::FaultRecovery(_)) {
547                self.is_error = true;
548                let ec = view.error_code();
549                if ec != 0 {
550                    self.error_code = ec as u32;
551                }
552                self.error_message = format!("Drive fault (state: {})", state);
553                // Cancel the current operation so is_busy goes false
554                self.op = AxisOp::Idle;
555                self.op_started = None;
556            }
557        }
558    }
559
560    // ═══════════════════════════════════════════
561    // Internal: operation timeout helper
562    // ═══════════════════════════════════════════
563
564    fn op_timed_out(&self) -> bool {
565        self.op_started
566            .map_or(false, |t| t.elapsed() > self.op_timeout)
567    }
568
569    fn homing_timed_out(&self) -> bool {
570        self.op_started
571            .map_or(false, |t| t.elapsed() > self.homing_timeout)
572    }
573
574    fn move_start_timed_out(&self) -> bool {
575        self.op_started
576            .map_or(false, |t| t.elapsed() > self.move_start_timeout)
577    }
578
579    fn set_op_error(&mut self, msg: &str) {
580        self.is_error = true;
581        self.error_message = msg.to_string();
582        self.op = AxisOp::Idle;
583        self.op_started = None;
584        self.is_busy = false;
585        self.in_motion = false;
586        log::error!("Axis error: {}", msg);
587    }
588
589    fn restore_pp_after_error(&mut self, msg: &str) {
590        self.is_error = true;
591        self.error_message = msg.to_string();
592        self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);;
593        log::error!("Axis error: {}", msg);
594    }
595
596    fn finish_op_error(&mut self) {
597        self.op = AxisOp::Idle;
598        self.op_started = None;
599        self.is_busy = false;
600        self.in_motion = false;
601    }
602
603    fn complete_op(&mut self) {
604        self.op = AxisOp::Idle;
605        self.op_started = None;
606    }
607
608    // ═══════════════════════════════════════════
609    // Internal: position limits and limit switches
610    // ═══════════════════════════════════════════
611
612    /// Resolve the effective maximum software limit for this tick, combining
613    /// the static [`AxisConfig`] value (if enabled) with any dynamic limit
614    /// supplied by the [`AxisView`] (e.g. a GM-linked variable). The most
615    /// restrictive (smallest) value wins.
616    fn effective_max_limit(&self, view: &impl AxisView) -> Option<f64> {
617        let static_limit = if self.config.enable_max_position_limit {
618            Some(self.config.max_position_limit)
619        } else {
620            None
621        };
622        match (static_limit, view.dynamic_max_position_limit()) {
623            (Some(s), Some(d)) => Some(s.min(d)),
624            (Some(v), None) | (None, Some(v)) => Some(v),
625            (None, None) => None,
626        }
627    }
628
629    /// Resolve the effective minimum software limit for this tick. See
630    /// [`effective_max_limit`](Self::effective_max_limit) — the most
631    /// restrictive (largest) value wins.
632    fn effective_min_limit(&self, view: &impl AxisView) -> Option<f64> {
633        let static_limit = if self.config.enable_min_position_limit {
634            Some(self.config.min_position_limit)
635        } else {
636            None
637        };
638        match (static_limit, view.dynamic_min_position_limit()) {
639            (Some(s), Some(d)) => Some(s.max(d)),
640            (Some(v), None) | (None, Some(v)) => Some(v),
641            (None, None) => None,
642        }
643    }
644
645    /// Check whether a target position (in user units) exceeds a software limit.
646    /// Returns `Some(error_message)` if the target is out of range, `None` if OK.
647    /// Consults both the static [`AxisConfig`] limits and any dynamic limits
648    /// exposed by the view, taking whichever is most restrictive.
649    fn check_target_limit(&self, target: f64, view: &impl AxisView) -> Option<String> {
650        if let Some(max) = self.effective_max_limit(view) {
651            if target > max {
652                return Some(format!(
653                    "Target {:.3} exceeds max software limit {:.3}",
654                    target, max
655                ));
656            }
657        }
658        if let Some(min) = self.effective_min_limit(view) {
659            if target < min {
660                return Some(format!(
661                    "Target {:.3} exceeds min software limit {:.3}",
662                    target, min
663                ));
664            }
665        }
666        None
667    }
668
669    /// Check software position limits, hardware limit switches, and home sensor.
670    /// If a limit is violated and a move is in progress in that direction,
671    /// halt the drive and set an error. Moving in the opposite direction is
672    /// always allowed so the axis can be recovered.
673    ///
674    /// During software homing on a limit switch (`SoftHoming` + `SoftHomeSensor::PositiveLimit`
675    /// or `NegativeLimit`), the homed-on switch is suppressed so it triggers a home
676    /// event rather than an error halt. The opposite switch still protects.
677    fn check_limits(&mut self, view: &mut impl AxisView) {
678        // ── Software position limits (static config + dynamic GM-linked) ──
679        let eff_max = self.effective_max_limit(view);
680        let eff_min = self.effective_min_limit(view);
681        let sw_max = eff_max.map_or(false, |m| self.position >= m);
682        let sw_min = eff_min.map_or(false, |m| self.position <= m);
683
684        self.at_max_limit = sw_max;
685        self.at_min_limit = sw_min;
686
687        // ── Hardware limit switches ──
688        let hw_pos = view.positive_limit_active();
689        let hw_neg = view.negative_limit_active();
690
691        self.at_positive_limit_switch = hw_pos;
692        self.at_negative_limit_switch = hw_neg;
693
694        // ── Home sensor ──
695        self.home_sensor = view.home_sensor_active();
696
697        // ── Save previous sensor state for next tick's edge detection ──
698        self.prev_positive_limit = hw_pos;
699        self.prev_negative_limit = hw_neg;
700        self.prev_home_sensor = view.home_sensor_active();
701
702        // ── Halt logic (only while moving or soft-homing) ──
703        let is_moving = matches!(self.op, AxisOp::Moving(_, _));
704        let is_soft_homing = matches!(self.op, AxisOp::SoftHoming(_));
705
706        if !is_moving && !is_soft_homing {
707            return;
708        }
709
710        // During software homing, suppress the limit switch being homed on
711        let suppress_pos = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::PositiveLimit;
712        let suppress_neg = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::NegativeLimit;
713
714        let effective_hw_pos = hw_pos && !suppress_pos;
715        let effective_hw_neg = hw_neg && !suppress_neg;
716
717        // During soft homing, suppress software limits too (we need to move freely)
718        let effective_sw_max = sw_max && !is_soft_homing;
719        let effective_sw_min = sw_min && !is_soft_homing;
720
721        let positive_blocked = (effective_sw_max || effective_hw_pos) && self.moving_positive;
722        let negative_blocked = (effective_sw_min || effective_hw_neg) && self.moving_negative;
723
724        if positive_blocked || negative_blocked {
725            let mut cw = RawControlWord(view.control_word());
726            cw.set_bit(8, true); // halt
727            view.set_control_word(cw.raw());
728
729            let msg = if effective_hw_pos && self.moving_positive {
730                "Positive limit switch active".to_string()
731            } else if effective_hw_neg && self.moving_negative {
732                "Negative limit switch active".to_string()
733            } else if effective_sw_max && self.moving_positive {
734                format!(
735                    "Software position limit: position {:.3} >= max {:.3}",
736                    self.position, eff_max.unwrap_or(self.position)
737                )
738            } else {
739                format!(
740                    "Software position limit: position {:.3} <= min {:.3}",
741                    self.position, eff_min.unwrap_or(self.position)
742                )
743            };
744            self.set_op_error(&msg);
745        }
746    }
747
748    // ═══════════════════════════════════════════
749    // Internal: operation progress
750    // ═══════════════════════════════════════════
751
752    fn progress_op(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
753        match self.op.clone() {
754            AxisOp::Idle => {}
755            AxisOp::Enabling(step) => self.tick_enabling(view, step),
756            AxisOp::Disabling(step) => self.tick_disabling(view, step),
757            AxisOp::Moving(kind, step) => self.tick_moving(view, kind, step),
758            AxisOp::Homing(step) => self.tick_homing(view, client, step),
759            AxisOp::SoftHoming(step) => self.tick_soft_homing(view, client, step),
760            AxisOp::Halting => self.tick_halting(view),
761            AxisOp::FaultRecovery(step) => self.tick_fault_recovery(view, step),
762        }
763    }
764
765    // ── Enabling ──
766    // Step 0: (done in enable()) ensure PP + cmd_shutdown
767    // Step 1: wait ReadyToSwitchOn → cmd_enable_operation
768    // Step 2: wait OperationEnabled → capture home → Idle
769    fn tick_enabling(&mut self, view: &mut impl AxisView, step: u8) {
770        match step {
771            1 => {
772                let sw = RawStatusWord(view.status_word());
773                if sw.state() == Cia402State::ReadyToSwitchOn {
774                    let mut cw = RawControlWord(view.control_word());
775                    cw.cmd_enable_operation();
776                    view.set_control_word(cw.raw());
777                    self.op = AxisOp::Enabling(2);
778                } else if self.op_timed_out() {
779                    self.set_op_error("Enable timeout: waiting for ReadyToSwitchOn");
780                }
781            }
782            2 => {
783                let sw = RawStatusWord(view.status_word());
784                if sw.state() == Cia402State::OperationEnabled {
785                    // NO - We do not do software-based encoder. That would break absolute encoders.
786                    // self.home_offset = view.position_actual();
787                    // log::info!("Axis enabled — home captured at {}", self.home_offset);
788
789                    // Possible TODO: Read the home_offset in the drive? 
790
791                    self.complete_op();
792                } else if self.op_timed_out() {
793                    self.set_op_error("Enable timeout: waiting for OperationEnabled");
794                }
795            }
796            _ => self.complete_op(),
797        }
798    }
799
800    // ── Disabling ──
801    // Step 0: (done in disable()) cmd_disable_operation
802    // Step 1: wait not OperationEnabled → Idle
803    fn tick_disabling(&mut self, view: &mut impl AxisView, step: u8) {
804        match step {
805            1 => {
806                let sw = RawStatusWord(view.status_word());
807                if sw.state() != Cia402State::OperationEnabled {
808                    self.complete_op();
809                } else if self.op_timed_out() {
810                    self.set_op_error("Disable timeout: drive still in OperationEnabled");
811                }
812            }
813            _ => self.complete_op(),
814        }
815    }
816
817    // ── Moving ──
818    // Step 0: (done in move_absolute/relative()) set params + trigger
819    // Step 1: wait set_point_acknowledge → ack
820    // Step 2: wait ack cleared (one tick)
821    // Step 3: wait target_reached → Idle
822    fn tick_moving(&mut self, view: &mut impl AxisView, kind: MoveKind, step: u8) {
823        match step {
824            1 => {
825                // Wait for set-point acknowledge (bit 12)
826                let sw = RawStatusWord(view.status_word());
827                if sw.raw() & (1 << 12) != 0 {
828                    // Ack: clear new set-point (bit 4)
829                    let mut cw = RawControlWord(view.control_word());
830                    cw.set_bit(4, false);
831                    view.set_control_word(cw.raw());
832                    self.op = AxisOp::Moving(kind, 2);
833                } else if self.move_start_timed_out() {
834                    self.set_op_error("Move timeout: set-point not acknowledged");
835                }
836            },
837            2 => {
838                // Wait for the drive to confirm it saw Bit 4 go low
839                let sw = RawStatusWord(view.status_word());
840                if sw.raw() & (1 << 12) == 0 {
841                    // Handshake is officially reset. Now wait for physics.
842                    self.op = AxisOp::Moving(kind, 3);
843                }
844            },
845            3 => {
846                // Wait for target reached (bit 10) — no timeout, moves can take arbitrarily long
847                let sw = RawStatusWord(view.status_word());
848                if sw.target_reached() {
849                    self.complete_op();
850                }
851            },
852            _ => self.complete_op(),
853        }
854    }
855
856    // ── Homing (hardware-delegated) ──
857    // Step 0:  write homing method SDO (0x6098:0)
858    // Step 1:  wait SDO ack
859    // Step 2:  write homing speed SDO (0x6099:1 — search for switch)
860    // Step 3:  wait SDO ack
861    // Step 4:  write homing speed SDO (0x6099:2 — search for zero)
862    // Step 5:  wait SDO ack
863    // Step 6:  write homing accel SDO (0x609A:0)
864    // Step 7:  wait SDO ack
865    // Step 8:  set homing mode
866    // Step 9:  wait mode confirmed
867    // Step 10: trigger homing (bit 4)
868    // Step 11: wait homing complete (bits 10+12 set, 13 clear)
869    // Step 12: capture home offset, switch to PP → Idle
870    //
871    // If homing_speed and homing_accel are both 0, steps 2-7 are skipped
872    // (preserves backward compatibility for users who pre-configure via SDO).
873    fn tick_homing(
874        &mut self,
875        view: &mut impl AxisView,
876        client: &mut CommandClient,
877        step: u8,
878    ) {
879        match step {
880            0 => {
881                // Write homing method via SDO (0x6098:0)
882                self.homing_sdo_tid = self.sdo.write(
883                    client,
884                    0x6098,
885                    0,
886                    json!(self.homing_method),
887                );
888                self.op = AxisOp::Homing(1);
889            }
890            1 => {
891                // Wait for SDO write ack
892                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
893                    SdoResult::Ok(_) => {
894                        // Skip speed/accel SDOs if both are zero
895                        if self.config.homing_speed == 0.0 && self.config.homing_accel == 0.0 {
896                            self.op = AxisOp::Homing(8);
897                        } else {
898                            self.op = AxisOp::Homing(2);
899                        }
900                    }
901                    SdoResult::Pending => {
902                        if self.homing_timed_out() {
903                            self.set_op_error("Homing timeout: SDO write for homing method");
904                        }
905                    }
906                    SdoResult::Err(e) => {
907                        self.set_op_error(&format!("Homing SDO error: {}", e));
908                    }
909                    SdoResult::Timeout => {
910                        self.set_op_error("Homing timeout: SDO write timed out");
911                    }
912                }
913            }
914            2 => {
915                // Write homing speed (0x6099:1 — search for switch)
916                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
917                self.homing_sdo_tid = self.sdo.write(
918                    client,
919                    0x6099,
920                    1,
921                    json!(speed_counts),
922                );
923                self.op = AxisOp::Homing(3);
924            }
925            3 => {
926                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
927                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(4); }
928                    SdoResult::Pending => {
929                        if self.homing_timed_out() {
930                            self.set_op_error("Homing timeout: SDO write for homing speed (switch)");
931                        }
932                    }
933                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
934                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
935                }
936            }
937            4 => {
938                // Write homing speed (0x6099:2 — search for zero, same value)
939                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
940                self.homing_sdo_tid = self.sdo.write(
941                    client,
942                    0x6099,
943                    2,
944                    json!(speed_counts),
945                );
946                self.op = AxisOp::Homing(5);
947            }
948            5 => {
949                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
950                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(6); }
951                    SdoResult::Pending => {
952                        if self.homing_timed_out() {
953                            self.set_op_error("Homing timeout: SDO write for homing speed (zero)");
954                        }
955                    }
956                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
957                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
958                }
959            }
960            6 => {
961                // Write homing acceleration (0x609A:0)
962                let accel_counts = self.config.to_counts(self.config.homing_accel).round() as u32;
963                self.homing_sdo_tid = self.sdo.write(
964                    client,
965                    0x609A,
966                    0,
967                    json!(accel_counts),
968                );
969                self.op = AxisOp::Homing(7);
970            }
971            7 => {
972                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
973                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(8); }
974                    SdoResult::Pending => {
975                        if self.homing_timed_out() {
976                            self.set_op_error("Homing timeout: SDO write for homing acceleration");
977                        }
978                    }
979                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
980                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
981                }
982            }
983            8 => {
984                // Set homing mode and ensure CW bit 4 starts LOW so the next
985                // step can issue a clean rising edge.
986                view.set_modes_of_operation(ModesOfOperation::Homing.as_i8());
987                let mut cw = RawControlWord(view.control_word());
988                cw.set_bit(4, false);
989                view.set_control_word(cw.raw());
990                self.op = AxisOp::Homing(9);
991            }
992            9 => {
993                // Wait for mode confirmed
994                if view.modes_of_operation_display() == ModesOfOperation::Homing.as_i8() {
995                    self.op = AxisOp::Homing(10);
996                } else if self.homing_timed_out() {
997                    self.set_op_error("Homing timeout: mode not confirmed");
998                }
999            }
1000            10 => {
1001                // Trigger homing: rising edge on bit 4
1002                let mut cw = RawControlWord(view.control_word());
1003                cw.set_bit(4, true);
1004                view.set_control_word(cw.raw());
1005                self.op = AxisOp::Homing(11);
1006            }
1007            11 => {
1008                // Wait for the drive to clear bit 12 to acknowledge the start
1009                // of homing. Without this, stale bit 12 from the previous mode
1010                // (e.g. PP set-point acknowledge) would let the next step pass
1011                // instantly even though the drive never ran the method.
1012                let sw = view.status_word();
1013                let error = sw & (1 << 13) != 0;
1014                if error {
1015                    self.set_op_error("Homing error: drive reported homing failure");
1016                } else if sw & (1 << 12) == 0 {
1017                    self.op = AxisOp::Homing(12);
1018                } else if self.homing_timed_out() {
1019                    self.set_op_error(&format!("Homing timeout: drive did not acknowledge homing start (sw=0x{:04X})", sw));
1020                }
1021            }
1022            12 => {
1023                // Wait for homing complete
1024                // Bit 13 = error, Bit 12 = attained, Bit 10 = reached
1025                let sw = view.status_word();
1026                let error    = sw & (1 << 13) != 0;
1027                let attained = sw & (1 << 12) != 0;
1028                let reached  = sw & (1 << 10) != 0;
1029
1030                if error {
1031                    self.set_op_error("Homing error: drive reported homing failure");
1032                } else if attained && reached {
1033                    self.op = AxisOp::Homing(13);
1034                } else if self.homing_timed_out() {
1035                    self.set_op_error("Homing timeout: procedure did not complete");
1036                }
1037            }
1038            13 => {
1039                // Capture home offset, applying home_position so the reference
1040                // point reads as config.home_position in user units.
1041                self.home_offset = view.position_actual()
1042                    - self.config.to_counts(self.config.home_position).round() as i32;
1043                // Clear homing start bit in its own cycle before switching modes
1044                let mut cw = RawControlWord(view.control_word());
1045                cw.set_bit(4, false);
1046                view.set_control_word(cw.raw());
1047                self.op = AxisOp::Homing(14);
1048            }
1049            14 => {
1050                // One tick later, switch back to PP mode so the drive sees the
1051                // bit 4 falling edge before the mode change.
1052                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1053                log::info!("Homing complete — home offset: {}", self.home_offset);
1054                self.complete_op();
1055            }
1056            _ => self.complete_op(),
1057        }
1058    }
1059
1060    // ── Software homing helpers ──
1061
1062    fn configure_soft_homing(&mut self, method: HomingMethod) {
1063        match method {
1064            HomingMethod::LimitSwitchPosPnp => {
1065                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1066                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1067                self.soft_home_direction = 1.0;
1068            }
1069            HomingMethod::LimitSwitchNegPnp => {
1070                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1071                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1072                self.soft_home_direction = -1.0;
1073            }
1074            HomingMethod::LimitSwitchPosNpn => {
1075                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1076                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1077                self.soft_home_direction = 1.0;
1078            }
1079            HomingMethod::LimitSwitchNegNpn => {
1080                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1081                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1082                self.soft_home_direction = -1.0;
1083            }
1084            HomingMethod::HomeSensorPosPnp => {
1085                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1086                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1087                self.soft_home_direction = 1.0;
1088            }
1089            HomingMethod::HomeSensorNegPnp => {
1090                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1091                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1092                self.soft_home_direction = -1.0;
1093            }
1094            HomingMethod::HomeSensorPosNpn => {
1095                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1096                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1097                self.soft_home_direction = 1.0;
1098            }
1099            HomingMethod::HomeSensorNegNpn => {
1100                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1101                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1102                self.soft_home_direction = -1.0;
1103            }
1104            _ => {} // integrated methods handled elsewhere
1105        }
1106    }
1107
1108    fn start_soft_homing(&mut self, view: &mut impl AxisView) {
1109        self.op = AxisOp::SoftHoming(HomeState::EnsurePpMode as u8);
1110        self.op_started = Some(Instant::now());
1111    }
1112
1113    fn check_soft_home_trigger(&self, view: &impl AxisView) -> bool {
1114        let raw = match self.soft_home_sensor {
1115            SoftHomeSensor::PositiveLimit => view.positive_limit_active(),
1116            SoftHomeSensor::NegativeLimit => view.negative_limit_active(),
1117            SoftHomeSensor::HomeSensor    => view.home_sensor_active(),
1118        };
1119        match self.soft_home_sensor_type {
1120            SoftHomeSensorType::Pnp => raw,    // PNP: true = detected
1121            SoftHomeSensorType::Npn => !raw,   // NPN: false = detected
1122        }
1123    }
1124
1125
1126    /// Calculate the maximum relative target for the specified direction.
1127    /// The result is adjusted for whether the motor direction has been inverted.
1128    fn calculate_max_relative_target(&self, direction : f64) -> i32 {
1129        let dir = if !self.config.invert_direction  {
1130            direction
1131        } 
1132        else {
1133            -direction
1134        };
1135
1136        let target = if dir > 0.0 {
1137            i32::MAX 
1138        }
1139        else {
1140            i32::MIN
1141        };
1142
1143        return target;
1144    }
1145
1146
1147    /// Convenient macro
1148    /// Configure the command word for an immediate halt
1149    /// and reset the new setpoint bit, which should cause
1150    /// status word bit 12 to clear
1151    fn command_halt(&self, view: &mut impl AxisView) {
1152        let mut cw = RawControlWord(view.control_word());
1153        cw.set_bit(8, true);  // halt
1154        cw.set_bit(4, false);  // reset new setpoint bit
1155        view.set_control_word(cw.raw());        
1156    }
1157
1158
1159    /// Convenient macro.
1160    /// Configures command bits and targets to cancel a previous move.
1161    /// Bit 4 should be off before calling this function.
1162    /// The current absolute position will be used as the target, so there 
1163    /// should be no motion
1164    /// Halt will be turned on, if not already.]
1165    /// After this, wait for bit 12 to be true before clearing the halt bit.
1166    fn command_cancel_move(&self, view: &mut impl AxisView) {
1167
1168        let mut cw = RawControlWord(view.control_word());
1169        cw.set_bit(4, true);  // new set-point
1170        cw.set_bit(5, true);  // single set-point. If false, the new move will be queued!
1171        cw.set_bit(6, false); // absolute move
1172        cw.set_bit(8, true); // clear halt
1173        view.set_control_word(cw.raw());        
1174
1175        let current_pos = view.position_actual();
1176        view.set_target_position(current_pos);
1177        view.set_profile_velocity(0);
1178    }
1179
1180
1181    /// Writes out the scaled homing speed into the bus.
1182    fn command_homing_speed(&self, view: &mut impl AxisView) {
1183        let cpu = self.config.counts_per_user();
1184        let vel = (self.config.homing_speed * cpu).round() as u32;
1185        let accel = (self.config.homing_accel * cpu).round() as u32;
1186        let decel = (self.config.homing_decel * cpu).round() as u32;
1187        view.set_profile_velocity(vel);
1188        view.set_profile_acceleration(accel);
1189        view.set_profile_deceleration(decel);        
1190    }
1191
1192    // ── Software homing state machine ──
1193    //
1194    // Phase 1: SEARCH (steps 0-3)
1195    //   Relative move in search direction until sensor triggers.
1196    //
1197    // Phase 2: HALT (steps 4-6)
1198    //   Stop the motor, cancel the old target.
1199    //
1200    // Phase 3: BACK-OFF (steps 7-11)
1201    //   Move opposite direction until sensor clears, then stop.
1202    //
1203    // Phase 4: SET HOME (steps 12-18)
1204    //   Write home offset to drive via SDO, trigger CurrentPosition homing,
1205    //   send hold set-point, complete.
1206    //
1207    fn tick_soft_homing(&mut self, view: &mut impl AxisView, client: &mut CommandClient, step: u8) {        
1208        match HomeState::from_repr(step) {
1209
1210            Some(HomeState::EnsurePpMode) => {
1211                //
1212                // If the drive crapped out in a previous mode, it might still be in homing mode.
1213                // Make sure we're in Profile Position mode.
1214                //
1215                log::info!("SoftHome: Ensuring PP mode..");
1216                self.fb_mode_of_operation.start(ModesOfOperation::ProfilePosition as i8);
1217                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1218                self.op = AxisOp::SoftHoming(HomeState::WaitPpMode as u8);
1219            },
1220            Some(HomeState::WaitPpMode) => {
1221
1222                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1223                if !self.fb_mode_of_operation.is_busy() {
1224                    if self.fb_mode_of_operation.is_error() {
1225                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1226                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1227                        ));
1228                    }
1229                    else {
1230                        log::info!("SoftHome: Drive is in PP mode!");
1231
1232                        // If sensor is NOT triggered, search for it (issue a move).
1233                        // If sensor IS already triggered, skip search and go straight
1234                        // to the found-sensor halt/back-off sequence.
1235                        if !self.check_soft_home_trigger(view) {
1236                            log::info!("SoftHome: Not on home switch; seek out.");
1237                            self.op = AxisOp::SoftHoming(HomeState::Search as u8);
1238                        } else {
1239                            log::info!("SoftHome: Already on home switch, skipping ahead to back-off stage.");
1240                            self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1241                        }
1242                    }
1243                }
1244
1245
1246            },
1247
1248            // ── Phase 1: SEARCH ──
1249            Some(HomeState::Search) => {
1250                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1251
1252                // // Absolute move to a far-away position in the search direction.
1253                // // Use raw counts directly to avoid overflow with invert_direction.                
1254                // let far_counts = (self.soft_home_direction * 999_999.0 * cpu).round() as i32;
1255                // let target = if self.config.invert_direction { -far_counts } else { far_counts };
1256                // let target = target + view.position_actual(); // offset from current
1257
1258
1259                // move in a relative direction as far as possible
1260                // we will stop when we reach the switch
1261                let target = self.calculate_max_relative_target(self.soft_home_direction);
1262                view.set_target_position(target);
1263
1264                // let cpu = self.config.counts_per_user();
1265                // let vel = (self.config.homing_speed * cpu).round() as u32;
1266                // let accel = (self.config.homing_accel * cpu).round() as u32;
1267                // let decel = (self.config.homing_decel * cpu).round() as u32;
1268                // view.set_profile_velocity(vel);
1269                // view.set_profile_acceleration(accel);
1270                // view.set_profile_deceleration(decel);
1271
1272                self.command_homing_speed(view);
1273
1274                let mut cw = RawControlWord(view.control_word());
1275                cw.set_bit(4, true);  // new set-point
1276                cw.set_bit(6, true); // sets true for relative move
1277                cw.set_bit(8, false); // clear halt
1278                cw.set_bit(13, true); // move relative to the actual current motor position
1279                view.set_control_word(cw.raw());
1280
1281                log::info!("SoftHome[0]: SEARCH relative target={} vel={} dir={} pos={}",
1282                    target, self.config.homing_speed, self.soft_home_direction, view.position_actual());
1283                self.op = AxisOp::SoftHoming(HomeState::WaitSearching as u8);
1284            }
1285            Some(HomeState::WaitSearching) => {
1286                if self.check_soft_home_trigger(view) {
1287                    log::debug!("SoftHome[1]: sensor triggered during ack wait");
1288                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1289                    return;
1290                }
1291                let sw = RawStatusWord(view.status_word());
1292                if sw.raw() & (1 << 12) != 0 {
1293                    let mut cw = RawControlWord(view.control_word());
1294                    cw.set_bit(4, false);
1295                    view.set_control_word(cw.raw());
1296                    log::debug!("SoftHome[1]: set-point ack received, clearing bit 4");
1297                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1298                } else if self.homing_timed_out() {
1299                    self.set_op_error("Software homing timeout: set-point not acknowledged");
1300                }
1301            }
1302            // Some(HomeState::WaitSensor) => {
1303            //     if self.check_soft_home_trigger(view) {
1304            //         log::debug!("SoftHome[2]: sensor triggered during transition");
1305            //         self.op = AxisOp::SoftHoming(4);
1306            //         return;
1307            //     }
1308            //     log::debug!("SoftHome[2]: transition → monitoring");
1309            //     self.op = AxisOp::SoftHoming(3);
1310            // }
1311            Some(HomeState::WaitFoundSensor) => {
1312                if self.check_soft_home_trigger(view) {
1313                    log::info!("SoftHome[3]: sensor triggered at pos={}. HALTING", view.position_actual());
1314                    log::info!("ControlWord is : {} ", view.control_word());
1315
1316                    let mut cw = RawControlWord(view.control_word());
1317                    cw.set_bit(8, true);  // halt
1318                    cw.set_bit(4, false);  // reset new setpoint bit
1319                    view.set_control_word(cw.raw());
1320
1321
1322                    self.halt_stable_count = 0;
1323                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedFoundSensor as u8);
1324                } else if self.homing_timed_out() {
1325                    self.set_op_error("Software homing timeout: sensor not detected");
1326                }
1327            }
1328
1329
1330            Some(HomeState::WaitStoppedFoundSensor) => {
1331                const STABLE_WINDOW: i32 = 1;
1332                const STABLE_TICKS_REQUIRED: u8 = 10;
1333
1334                // let mut cw = RawControlWord(view.control_word());
1335                // cw.set_bit(8, true);
1336                // view.set_control_word(cw.raw());
1337
1338                let pos = view.position_actual();
1339                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1340                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1341                } else {
1342                    self.halt_stable_count = 0;
1343                }
1344
1345                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1346
1347                    log::debug!("SoftHome[5] motor is stopped. Cancel move and wait for bit 12 go true.");
1348                    self.command_cancel_move(view);
1349                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensorAck as u8);
1350
1351                } else if self.homing_timed_out() {
1352                    self.set_op_error("Software homing timeout: motor did not stop after sensor trigger");
1353                }
1354            }
1355            Some(HomeState::WaitFoundSensorAck) => {
1356                let sw = RawStatusWord(view.status_word());
1357                if sw.raw() & (1 << 12) != 0 &&  sw.raw() & (1 << 10) != 0 {
1358
1359                    log::info!("SoftHome[6]: relative move cancel ack received. Waiting before back-off...");
1360
1361                    // reset bit 4 so we're clear for the next move
1362                    let mut cw = RawControlWord(view.control_word());
1363                    cw.set_bit(4, false);  // reset new setpoint bit
1364                    cw.set_bit(5, true); // single setpoint -- flush out any previous
1365                    view.set_control_word(cw.raw());
1366
1367                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensorAckClear as u8);
1368
1369                } else if self.homing_timed_out() {
1370                    self.set_op_error("Software homing timeout: cancel not acknowledged");
1371                }
1372            },
1373            Some(HomeState::WaitFoundSensorAckClear) => {
1374                let sw = RawStatusWord(view.status_word());
1375                // CRITICAL: Wait for the drive to acknowledge that the setpoint is gone
1376                if sw.raw() & (1 << 12) == 0 { 
1377
1378                    // turn off halt and it still shouldn't move
1379                    let mut cw = RawControlWord(view.control_word());
1380                    cw.set_bit(8, false); 
1381                    view.set_control_word(cw.raw());
1382
1383                    log::info!("SoftHome[6]: Handshake cleared (Bit 12 is LOW). Proceeding to delay.");
1384                    self.op = AxisOp::SoftHoming(HomeState::DebounceFoundSensor as u8);
1385                    self.ton.call(false, Duration::from_secs(3));
1386                }                   
1387            },
1388            // Delay before back-off (60 = wait ~1 second for drive to settle)
1389            Some(HomeState::DebounceFoundSensor) => {
1390                self.ton.call(true, Duration::from_secs(3));
1391
1392                let sw = RawStatusWord(view.status_word());
1393                if self.ton.q && sw.raw() & (1 << 12) == 0 { 
1394                    self.ton.call(false, Duration::from_secs(3));
1395                    log::info!("SoftHome[6.a.]: delay complete, starting back-off from pos={} cw=0x{:04X} sw={:04x}",
1396                    view.position_actual(), view.control_word(), view.status_word());
1397                    self.op = AxisOp::SoftHoming(HomeState::BackOff as u8);
1398                }
1399            }
1400
1401            // ── Phase 3: BACK-OFF until sensor clears ──
1402            Some(HomeState::BackOff) => {
1403
1404                let target = (self.calculate_max_relative_target(-self.soft_home_direction)) / 2;
1405                view.set_target_position(target);
1406
1407
1408                self.command_homing_speed(view);            
1409
1410                let mut cw = RawControlWord(view.control_word());
1411                cw.set_bit(4, true);  // new set-point                
1412                cw.set_bit(6, true); // relative move                
1413                cw.set_bit(13, true); // relative from current, actualy position
1414                view.set_control_word(cw.raw());
1415                log::info!("SoftHome[7]: BACK-OFF absolute target={} vel={} pos={} cw=0x{:04X}",
1416                    target, self.config.homing_speed, view.position_actual(), cw.raw());
1417                self.op = AxisOp::SoftHoming(HomeState::WaitBackingOff as u8);
1418            }
1419            Some(HomeState::WaitBackingOff) => {
1420                let sw = RawStatusWord(view.status_word());
1421                if sw.raw() & (1 << 12) != 0 {
1422                    let mut cw = RawControlWord(view.control_word());
1423                    cw.set_bit(4, false);
1424                    view.set_control_word(cw.raw());
1425                    log::debug!("SoftHome[WaitBackingOff]: back-off ack received, pos={}", view.position_actual());
1426                    self.op = AxisOp::SoftHoming(HomeState::WaitLostSensor as u8);
1427                } else if self.homing_timed_out() {
1428                    self.set_op_error("Software homing timeout: back-off not acknowledged");
1429                }
1430            }
1431            Some(HomeState::WaitLostSensor) => {
1432                if !self.check_soft_home_trigger(view) {
1433                    log::info!("SoftHome[WaitLostSensor]: sensor lost at pos={}. Halting...", view.position_actual());
1434
1435                    self.command_halt(view);
1436                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedLostSensor as u8);
1437                } else if self.homing_timed_out() {
1438                    self.set_op_error("Software homing timeout: sensor did not clear during back-off");
1439                }
1440            }
1441            Some(HomeState::WaitStoppedLostSensor)  => {
1442                const STABLE_WINDOW: i32 = 1;
1443                const STABLE_TICKS_REQUIRED: u8 = 10;
1444
1445                let mut cw = RawControlWord(view.control_word());
1446                cw.set_bit(8, true);
1447                view.set_control_word(cw.raw());
1448
1449                let pos = view.position_actual();
1450                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1451                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1452                } else {
1453                    self.halt_stable_count = 0;
1454                }
1455
1456                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1457                    log::debug!("SoftHome[WaitStoppedLostSensor] motor is stopped. Cancel move and wait for bit 12 go true.");
1458                    self.command_cancel_move(view);
1459                    self.op = AxisOp::SoftHoming(HomeState::WaitLostSensorAck as u8);
1460                } else if self.homing_timed_out() {
1461                    self.set_op_error("Software homing timeout: motor did not stop after back-off");
1462                }
1463            }
1464            Some(HomeState::WaitLostSensorAck) => {
1465                let sw = RawStatusWord(view.status_word());
1466                if sw.raw() & (1 << 12) != 0 &&  sw.raw() & (1 << 10) != 0 {
1467
1468                    log::info!("SoftHome[WaitLostSensorAck]: relative move cancel ack received. Waiting before back-off...");
1469
1470                    // reset bit 4 so we're clear for the next move
1471                    let mut cw = RawControlWord(view.control_word());
1472                    cw.set_bit(4, false);  // reset new setpoint bit
1473                    view.set_control_word(cw.raw());
1474
1475                     self.op = AxisOp::SoftHoming(HomeState::WaitLostSensorAckClear as u8);
1476
1477
1478                } else if self.homing_timed_out() {
1479                    self.set_op_error("Software homing timeout: cancel not acknowledged");
1480                }
1481            }
1482            Some(HomeState::WaitLostSensorAckClear) => {
1483                // CRITICAL: Wait for the drive to acknowledge that the setpoint is gone
1484                let sw = RawStatusWord(view.status_word());
1485                if sw.raw() & (1 << 12) == 0 { 
1486
1487                    // turn off halt and it still shouldn't move                    
1488                    let mut cw = RawControlWord(view.control_word());
1489                    cw.set_bit(8, false);   
1490                    view.set_control_word(cw.raw());
1491
1492
1493                    let desired_counts = self.config.to_counts(self.config.home_position).round() as i32;
1494                    // let current_pos = view.position_actual();
1495                    // let offset = desired_counts - current_pos;
1496                    self.homing_sdo_tid = self.sdo.write(
1497                        client, 0x607C, 0, json!(desired_counts),
1498                    );
1499
1500                    log::info!("SoftHome[WaitLostSensorAckClear]: Handshake cleared (Bit 12 is LOW). Writing home offset {} [{} counts].",
1501                        self.config.home_position, desired_counts
1502                    );
1503
1504                    self.op = AxisOp::SoftHoming(HomeState::WaitHomeOffsetDone as u8);
1505
1506                }                
1507            },
1508
1509            Some(HomeState::WaitHomeOffsetDone) => {
1510                // Wait for home offset SDO ack
1511                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1512                    SdoResult::Ok(_) => { self.op = AxisOp::SoftHoming(HomeState::WriteHomingModeOp as u8); }
1513                    SdoResult::Pending => {
1514                        if self.homing_timed_out() {
1515                            self.set_op_error("Software homing timeout: home offset SDO write");
1516                        }
1517                    }
1518                    SdoResult::Err(e) => {
1519                        self.set_op_error(&format!("Software homing SDO error: {}", e));
1520                    }
1521                    SdoResult::Timeout => {
1522                        self.set_op_error("Software homing: home offset SDO timed out");
1523                    }
1524                }
1525            },            
1526            Some(HomeState::WriteHomingModeOp) => {
1527
1528                // Switch the mode of operation into Homing Mode so that we can execute
1529                // the homing command.
1530
1531                self.fb_mode_of_operation.reset();
1532                self.fb_mode_of_operation.start(ModesOfOperation::Homing as i8);
1533                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1534                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingModeOp as u8);
1535
1536                
1537            },       
1538            Some(HomeState::WaitWriteHomingModeOp) => {
1539                // Wait for method SDO ack
1540                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1541
1542                if !self.fb_mode_of_operation.is_busy() {
1543                    if self.fb_mode_of_operation.is_error() {
1544                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1545                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1546                        ));
1547                    }
1548                    else {
1549                        log::info!("SoftHome: Drive is now in Homing Mode.");
1550                        self.op = AxisOp::SoftHoming(HomeState::WriteHomingMethod as u8);
1551                    }
1552                }
1553            },
1554            Some(HomeState::WriteHomingMethod) => {
1555                // Write homing method = 37 (CurrentPosition)
1556                self.homing_sdo_tid = self.sdo.write(
1557                    client, 0x6098, 0, json!(37i8),
1558                );
1559                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingMethodDone as u8);
1560            }
1561            Some(HomeState::WaitWriteHomingMethodDone) => {
1562                // Wait for method SDO ack
1563                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1564                    SdoResult::Ok(_) => { 
1565                        log::info!("SoftHome: Successfully wrote homing method.");
1566                        self.op = AxisOp::SoftHoming(HomeState::ClearHomingTrigger as u8); 
1567                    }
1568                    SdoResult::Pending => {
1569                        if self.homing_timed_out() {
1570                            self.restore_pp_after_error("Software homing timeout: homing method SDO write");
1571                        }
1572                    }
1573                    SdoResult::Err(e) => {
1574                        self.restore_pp_after_error(&format!("Software homing SDO error: {}", e));
1575                    }
1576                    SdoResult::Timeout => {
1577                        self.restore_pp_after_error("Software homing: homing method SDO timed out");
1578                    }
1579                }
1580            }
1581            Some(HomeState::ClearHomingTrigger) => {
1582                // Switch to homing mode and ensure CW bit 4 starts LOW, so the
1583                // next state can issue a clean rising edge the drive will see.
1584                let mut cw = RawControlWord(view.control_word());
1585                cw.set_bit(4, false);
1586                view.set_control_word(cw.raw());
1587                self.op = AxisOp::SoftHoming(HomeState::TriggerHoming as u8);
1588            }
1589            Some(HomeState::TriggerHoming) => {
1590                // Rising edge on CW bit 4 to start homing.
1591                let mut cw = RawControlWord(view.control_word());
1592                cw.set_bit(4, true);
1593                view.set_control_word(cw.raw());
1594                log::info!("SoftHome[TriggerHoming]: start homing");
1595                self.op = AxisOp::SoftHoming(HomeState::WaitHomingStarted as u8);
1596            }
1597            Some(HomeState::WaitHomingStarted) => {
1598                // Wait for the drive to clear bit 12 (Homing attained) to acknowledge
1599                // the start of homing. Without this handshake, stale bit 12 carried
1600                // over from the previous mode (e.g. PP set-point acknowledge) would
1601                // cause WaitHomingDone to pass instantly, and the drive would never
1602                // actually perform the homing method.
1603                let sw = view.status_word();
1604                let error = sw & (1 << 13) != 0;
1605                if error {
1606                    self.restore_pp_after_error("Software homing: drive reported homing error");
1607                } else if sw & (1 << 12) == 0 {
1608                    self.op = AxisOp::SoftHoming(HomeState::WaitHomingDone as u8);
1609                } else if self.homing_timed_out() {
1610                    self.restore_pp_after_error(&format!("Software homing timeout: drive did not acknowledge homing start (sw=0x{:04X})", sw));
1611                }
1612            }
1613            Some(HomeState::WaitHomingDone) => {
1614                // Wait for homing complete (bit 12 attained + bit 10 reached).
1615                let sw = view.status_word();
1616                let error    = sw & (1 << 13) != 0;
1617                let attained = sw & (1 << 12) != 0;
1618                let reached  = sw & (1 << 10) != 0;
1619
1620                if error {
1621                    self.restore_pp_after_error("Software homing: drive reported homing error");
1622                } else if attained && reached {
1623                    log::info!("SoftHome[WaitHomingDone]: homing complete (sw=0x{:04X})", sw);
1624                    self.op = AxisOp::SoftHoming(HomeState::ResetHomingTrigger as u8);
1625                } else if self.homing_timed_out() {
1626                    self.restore_pp_after_error(&format!("Software homing timeout: drive homing did not complete (sw=0x{:04X} attained={} reached={})", sw, attained, reached));
1627                }
1628            }
1629            Some(HomeState::ResetHomingTrigger) => {
1630                // Clear CW bit 4 first, in its own RxPDO cycle, so the drive sees
1631                // the falling edge *before* we change modes_of_operation away from
1632                // Homing. Changing both at once can leave the drive committing
1633                // ambiguous state.
1634                let mut cw = RawControlWord(view.control_word());
1635                cw.set_bit(4, false);
1636                view.set_control_word(cw.raw());
1637                self.op = AxisOp::SoftHoming(HomeState::WaitHomingTriggerCleared as u8);
1638            }
1639            Some(HomeState::WaitHomingTriggerCleared) => {
1640                // One tick later, switch back to PP mode and record that the drive
1641                // now owns the offset so our software-side offset is zero.
1642                self.home_offset = 0; // drive handles it now
1643                self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);
1644            }
1645
1646
1647            Some(HomeState::WriteMotionModeOfOperation) => {
1648
1649                // Switch back to PP motion mode
1650
1651                self.fb_mode_of_operation.reset();
1652                self.fb_mode_of_operation.start(ModesOfOperation::ProfilePosition as i8);
1653                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1654                self.op = AxisOp::SoftHoming(HomeState::WaitWriteMotionModeOfOperation  as u8);
1655                
1656            },       
1657            Some(HomeState::WaitWriteMotionModeOfOperation) => {
1658                // Wait for method SDO ack
1659                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1660
1661                if !self.fb_mode_of_operation.is_busy() {
1662                    if self.fb_mode_of_operation.is_error() {
1663                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1664                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1665                        ));
1666                    }
1667                    else {
1668                        if self.is_error {
1669                            log::error!("Drive back in PP mode after error. Homing sequence did not complete!");
1670                            self.finish_op_error();
1671                        }
1672                        else {
1673                            // Set the target position so this drive doesn't go wandering off after homing
1674                            // changed the position
1675                            self.op = AxisOp::SoftHoming(HomeState::SendCurrentPositionTarget as u8);
1676                        }
1677                        
1678                    }
1679                }
1680            },
1681
1682            Some(HomeState::SendCurrentPositionTarget) => {
1683                // Hold position: send set-point to current position
1684                let current_pos = view.position_actual();
1685                view.set_target_position(current_pos);
1686                view.set_profile_velocity(0);
1687                let mut cw = RawControlWord(view.control_word());
1688                cw.set_bit(4, true);
1689                cw.set_bit(5, true);
1690                cw.set_bit(6, false); // absolute
1691                view.set_control_word(cw.raw());
1692                self.op = AxisOp::SoftHoming(HomeState::WaitCurrentPositionTargetSent as u8);
1693            }
1694            Some(HomeState::WaitCurrentPositionTargetSent) => {
1695                // Wait for hold ack
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::info!("Software homing complete — position set to {} user units",
1702                        self.config.home_position);
1703                    self.complete_op();
1704                } else if self.homing_timed_out() {
1705                    self.set_op_error("Software homing timeout: hold position not acknowledged");
1706                }
1707            }
1708            _ => self.complete_op(),
1709        }
1710    }
1711
1712    // ── Halting ──
1713    fn tick_halting(&mut self, _view: &mut impl AxisView) {
1714        // Halt bit was already set in halt(). Go idle immediately —
1715        // the drive will decelerate on its own, reflected in velocity outputs.
1716        self.complete_op();
1717    }
1718
1719    // ── Fault Recovery ──
1720    // Step 0: (done in reset_faults()) clear bit 7
1721    // Step 1: assert bit 7 (fault reset rising edge)
1722    // Step 2: wait fault cleared → Idle
1723    fn tick_fault_recovery(&mut self, view: &mut impl AxisView, step: u8) {
1724        match step {
1725            1 => {
1726                // Assert fault reset (rising edge on bit 7)
1727                let mut cw = RawControlWord(view.control_word());
1728                cw.cmd_fault_reset();
1729                view.set_control_word(cw.raw());
1730                self.op = AxisOp::FaultRecovery(2);
1731            }
1732            2 => {
1733                // Wait for fault to clear
1734                let sw = RawStatusWord(view.status_word());
1735                let state = sw.state();
1736                if !matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
1737                    log::info!("Fault cleared (drive state: {})", state);
1738                    self.complete_op();
1739                } else if self.op_timed_out() {
1740                    self.set_op_error("Fault reset timeout: drive still faulted");
1741                }
1742            }
1743            _ => self.complete_op(),
1744        }
1745    }
1746}
1747
1748// ──────────────────────────────────────────────
1749// Tests
1750// ──────────────────────────────────────────────
1751
1752#[cfg(test)]
1753mod tests {
1754    use super::*;
1755
1756    /// Mock AxisView for testing.
1757    struct MockView {
1758        control_word: u16,
1759        status_word: u16,
1760        target_position: i32,
1761        profile_velocity: u32,
1762        profile_acceleration: u32,
1763        profile_deceleration: u32,
1764        modes_of_operation: i8,
1765        modes_of_operation_display: i8,
1766        position_actual: i32,
1767        velocity_actual: i32,
1768        error_code: u16,
1769        positive_limit: bool,
1770        negative_limit: bool,
1771        home_sensor: bool,
1772    }
1773
1774    impl MockView {
1775        fn new() -> Self {
1776            Self {
1777                control_word: 0,
1778                status_word: 0x0040, // SwitchOnDisabled
1779                target_position: 0,
1780                profile_velocity: 0,
1781                profile_acceleration: 0,
1782                profile_deceleration: 0,
1783                modes_of_operation: 0,
1784                modes_of_operation_display: 1, // PP
1785                position_actual: 0,
1786                velocity_actual: 0,
1787                error_code: 0,
1788                positive_limit: false,
1789                negative_limit: false,
1790                home_sensor: false,
1791            }
1792        }
1793
1794        fn set_state(&mut self, state: u16) {
1795            self.status_word = state;
1796        }
1797    }
1798
1799    impl AxisView for MockView {
1800        fn control_word(&self) -> u16 { self.control_word }
1801        fn set_control_word(&mut self, word: u16) { self.control_word = word; }
1802        fn set_target_position(&mut self, pos: i32) { self.target_position = pos; }
1803        fn set_profile_velocity(&mut self, vel: u32) { self.profile_velocity = vel; }
1804        fn set_profile_acceleration(&mut self, accel: u32) { self.profile_acceleration = accel; }
1805        fn set_profile_deceleration(&mut self, decel: u32) { self.profile_deceleration = decel; }
1806        fn set_modes_of_operation(&mut self, mode: i8) { self.modes_of_operation = mode; }
1807        fn modes_of_operation_display(&self) -> i8 { self.modes_of_operation_display }
1808        fn status_word(&self) -> u16 { self.status_word }
1809        fn position_actual(&self) -> i32 { self.position_actual }
1810        fn velocity_actual(&self) -> i32 { self.velocity_actual }
1811        fn error_code(&self) -> u16 { self.error_code }
1812        fn positive_limit_active(&self) -> bool { self.positive_limit }
1813        fn negative_limit_active(&self) -> bool { self.negative_limit }
1814        fn home_sensor_active(&self) -> bool { self.home_sensor }
1815    }
1816
1817    fn test_config() -> AxisConfig {
1818        AxisConfig::new(12_800).with_user_scale(360.0)
1819    }
1820
1821    /// Helper: create axis + mock client channels.
1822    fn test_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
1823        use tokio::sync::mpsc;
1824        let (write_tx, write_rx) = mpsc::unbounded_channel();
1825        let (response_tx, response_rx) = mpsc::unbounded_channel();
1826        let client = CommandClient::new(write_tx, response_rx);
1827        let axis = Axis::new(test_config(), "TestDrive");
1828        (axis, client, response_tx, write_rx)
1829    }
1830
1831    #[test]
1832    fn axis_config_conversion() {
1833        let cfg = test_config();
1834        // 45 degrees = 1600 counts
1835        assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
1836    }
1837
1838    #[test]
1839    fn enable_sequence_sets_pp_mode_and_shutdown() {
1840        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1841        let mut view = MockView::new();
1842
1843        axis.enable(&mut view);
1844
1845        // Should have set PP mode
1846        assert_eq!(view.modes_of_operation, ModesOfOperation::ProfilePosition.as_i8());
1847        // Should have issued shutdown command (bits 1,2 set; 0,3,7 clear)
1848        assert_eq!(view.control_word & 0x008F, 0x0006);
1849        // Should be in Enabling state
1850        assert_eq!(axis.op, AxisOp::Enabling(1));
1851
1852        // Simulate drive reaching ReadyToSwitchOn
1853        view.set_state(0x0021); // ReadyToSwitchOn
1854        axis.tick(&mut view, &mut client);
1855
1856        // Should have issued enable_operation (bits 0-3 set; 7 clear)
1857        assert_eq!(view.control_word & 0x008F, 0x000F);
1858        assert_eq!(axis.op, AxisOp::Enabling(2));
1859
1860        // Simulate drive reaching OperationEnabled
1861        view.set_state(0x0027); // OperationEnabled
1862        axis.tick(&mut view, &mut client);
1863
1864        // Should be idle now, motor_on = true
1865        assert_eq!(axis.op, AxisOp::Idle);
1866        assert!(axis.motor_on);
1867    }
1868
1869    #[test]
1870    fn move_absolute_sets_target() {
1871        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1872        let mut view = MockView::new();
1873        view.set_state(0x0027); // OperationEnabled
1874        axis.tick(&mut view, &mut client); // update outputs
1875
1876        // Move to 45 degrees at 90 deg/s, 180 deg/s² accel/decel
1877        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1878
1879        // Target should be ~1600 counts (45° at 12800 cpr / 360°)
1880        assert_eq!(view.target_position, 1600);
1881        // Velocity: 90 deg/s * (12800/360) ≈ 3200 counts/s
1882        assert_eq!(view.profile_velocity, 3200);
1883        // Accel: 180 deg/s² * (12800/360) ≈ 6400 counts/s²
1884        assert_eq!(view.profile_acceleration, 6400);
1885        assert_eq!(view.profile_deceleration, 6400);
1886        // Bit 4 (new set-point) should be set
1887        assert!(view.control_word & (1 << 4) != 0);
1888        // Bit 6 (relative) should be clear for absolute move
1889        assert!(view.control_word & (1 << 6) == 0);
1890        // Should be in Moving state
1891        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
1892    }
1893
1894    #[test]
1895    fn move_relative_sets_relative_bit() {
1896        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1897        let mut view = MockView::new();
1898        view.set_state(0x0027);
1899        axis.tick(&mut view, &mut client);
1900
1901        axis.move_relative(&mut view, 10.0, 90.0, 180.0, 180.0);
1902
1903        // Bit 6 (relative) should be set
1904        assert!(view.control_word & (1 << 6) != 0);
1905        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Relative, 1)));
1906    }
1907
1908    #[test]
1909    fn move_completes_on_target_reached() {
1910        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1911        let mut view = MockView::new();
1912        view.set_state(0x0027); // OperationEnabled
1913        axis.tick(&mut view, &mut client);
1914
1915        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1916
1917        // Step 1: simulate set-point acknowledge (bit 12)
1918        view.status_word = 0x1027; // OperationEnabled + bit 12
1919        axis.tick(&mut view, &mut client);
1920        // Should have cleared bit 4
1921        assert!(view.control_word & (1 << 4) == 0);
1922
1923        // Step 2: simulate target reached (bit 10)
1924        view.status_word = 0x0427; // OperationEnabled + bit 10
1925        axis.tick(&mut view, &mut client);
1926        // Should be idle now
1927        assert_eq!(axis.op, AxisOp::Idle);
1928        assert!(!axis.in_motion);
1929    }
1930
1931    #[test]
1932    fn fault_detected_sets_error() {
1933        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1934        let mut view = MockView::new();
1935        view.set_state(0x0008); // Fault
1936        view.error_code = 0x1234;
1937
1938        axis.tick(&mut view, &mut client);
1939
1940        assert!(axis.is_error);
1941        assert_eq!(axis.error_code, 0x1234);
1942        assert!(axis.error_message.contains("fault"));
1943    }
1944
1945    #[test]
1946    fn fault_recovery_sequence() {
1947        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1948        let mut view = MockView::new();
1949        view.set_state(0x0008); // Fault
1950
1951        axis.reset_faults(&mut view);
1952        // Step 0: bit 7 should be cleared
1953        assert!(view.control_word & 0x0080 == 0);
1954
1955        // Step 1: tick should assert bit 7
1956        axis.tick(&mut view, &mut client);
1957        assert!(view.control_word & 0x0080 != 0);
1958
1959        // Step 2: simulate fault cleared → SwitchOnDisabled
1960        view.set_state(0x0040);
1961        axis.tick(&mut view, &mut client);
1962        assert_eq!(axis.op, AxisOp::Idle);
1963        assert!(!axis.is_error);
1964    }
1965
1966    #[test]
1967    fn disable_sequence() {
1968        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1969        let mut view = MockView::new();
1970        view.set_state(0x0027); // OperationEnabled
1971
1972        axis.disable(&mut view);
1973        // Should have sent disable_operation command
1974        assert_eq!(view.control_word & 0x008F, 0x0007);
1975
1976        // Simulate drive leaving OperationEnabled
1977        view.set_state(0x0023); // SwitchedOn
1978        axis.tick(&mut view, &mut client);
1979        assert_eq!(axis.op, AxisOp::Idle);
1980    }
1981
1982    #[test]
1983    fn position_tracks_with_home_offset() {
1984        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1985        let mut view = MockView::new();
1986        view.set_state(0x0027);
1987        view.position_actual = 5000;
1988
1989        // Enable to capture home offset
1990        axis.enable(&mut view);
1991        view.set_state(0x0021);
1992        axis.tick(&mut view, &mut client);
1993        view.set_state(0x0027);
1994        axis.tick(&mut view, &mut client);
1995
1996        // Home offset should be 5000
1997        assert_eq!(axis.home_offset, 5000);
1998
1999        // Position should be 0 (at home)
2000        assert!((axis.position - 0.0).abs() < 0.01);
2001
2002        // Move actual position to 5000 + 1600 = 6600
2003        view.position_actual = 6600;
2004        axis.tick(&mut view, &mut client);
2005
2006        // Should read as 45 degrees
2007        assert!((axis.position - 45.0).abs() < 0.1);
2008    }
2009
2010    #[test]
2011    fn set_position_adjusts_home_offset() {
2012        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2013        let mut view = MockView::new();
2014        view.position_actual = 3200;
2015
2016        axis.set_position(&view, 90.0);
2017        axis.tick(&mut view, &mut client);
2018
2019        // home_offset = 3200 - to_counts(90.0) = 3200 - 3200 = 0
2020        assert_eq!(axis.home_offset, 0);
2021        assert!((axis.position - 90.0).abs() < 0.01);
2022    }
2023
2024    #[test]
2025    fn halt_sets_bit_and_goes_idle() {
2026        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2027        let mut view = MockView::new();
2028        view.set_state(0x0027);
2029
2030        axis.halt(&mut view);
2031        // Bit 8 should be set
2032        assert!(view.control_word & (1 << 8) != 0);
2033
2034        // Tick should go idle
2035        axis.tick(&mut view, &mut client);
2036        assert_eq!(axis.op, AxisOp::Idle);
2037    }
2038
2039    #[test]
2040    fn is_busy_tracks_operations() {
2041        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2042        let mut view = MockView::new();
2043
2044        // Idle — not busy
2045        axis.tick(&mut view, &mut client);
2046        assert!(!axis.is_busy);
2047
2048        // Enable — busy
2049        axis.enable(&mut view);
2050        axis.tick(&mut view, &mut client);
2051        assert!(axis.is_busy);
2052
2053        // Complete enable
2054        view.set_state(0x0021);
2055        axis.tick(&mut view, &mut client);
2056        view.set_state(0x0027);
2057        axis.tick(&mut view, &mut client);
2058        assert!(!axis.is_busy);
2059
2060        // Move — busy
2061        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2062        axis.tick(&mut view, &mut client);
2063        assert!(axis.is_busy);
2064        assert!(axis.in_motion);
2065    }
2066
2067    #[test]
2068    fn fault_during_move_cancels_op() {
2069        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2070        let mut view = MockView::new();
2071        view.set_state(0x0027); // OperationEnabled
2072        axis.tick(&mut view, &mut client);
2073
2074        // Start a move
2075        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2076        axis.tick(&mut view, &mut client);
2077        assert!(axis.is_busy);
2078        assert!(!axis.is_error);
2079
2080        // Fault occurs mid-move
2081        view.set_state(0x0008); // Fault
2082        axis.tick(&mut view, &mut client);
2083
2084        // is_busy should be false, is_error should be true
2085        assert!(!axis.is_busy);
2086        assert!(axis.is_error);
2087        assert_eq!(axis.op, AxisOp::Idle);
2088    }
2089
2090    #[test]
2091    fn move_absolute_rejected_by_max_limit() {
2092        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2093        let mut view = MockView::new();
2094        view.set_state(0x0027);
2095        axis.tick(&mut view, &mut client);
2096
2097        axis.set_software_max_limit(90.0);
2098        axis.move_absolute(&mut view, 100.0, 90.0, 180.0, 180.0);
2099
2100        // Should not have started a move — error instead
2101        assert!(axis.is_error);
2102        assert_eq!(axis.op, AxisOp::Idle);
2103        assert!(axis.error_message.contains("max software limit"));
2104    }
2105
2106    #[test]
2107    fn move_absolute_rejected_by_min_limit() {
2108        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2109        let mut view = MockView::new();
2110        view.set_state(0x0027);
2111        axis.tick(&mut view, &mut client);
2112
2113        axis.set_software_min_limit(-10.0);
2114        axis.move_absolute(&mut view, -20.0, 90.0, 180.0, 180.0);
2115
2116        assert!(axis.is_error);
2117        assert_eq!(axis.op, AxisOp::Idle);
2118        assert!(axis.error_message.contains("min software limit"));
2119    }
2120
2121    #[test]
2122    fn move_relative_rejected_by_max_limit() {
2123        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2124        let mut view = MockView::new();
2125        view.set_state(0x0027);
2126        axis.tick(&mut view, &mut client);
2127
2128        // Position is 0, max limit 50 — relative move of +60 should be rejected
2129        axis.set_software_max_limit(50.0);
2130        axis.move_relative(&mut view, 60.0, 90.0, 180.0, 180.0);
2131
2132        assert!(axis.is_error);
2133        assert_eq!(axis.op, AxisOp::Idle);
2134        assert!(axis.error_message.contains("max software limit"));
2135    }
2136
2137    #[test]
2138    fn move_within_limits_allowed() {
2139        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2140        let mut view = MockView::new();
2141        view.set_state(0x0027);
2142        axis.tick(&mut view, &mut client);
2143
2144        axis.set_software_max_limit(90.0);
2145        axis.set_software_min_limit(-90.0);
2146        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2147
2148        // Should have started normally
2149        assert!(!axis.is_error);
2150        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
2151    }
2152
2153    #[test]
2154    fn runtime_limit_halts_move_in_violated_direction() {
2155        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2156        let mut view = MockView::new();
2157        view.set_state(0x0027);
2158        axis.tick(&mut view, &mut client);
2159
2160        axis.set_software_max_limit(45.0);
2161        // Start a move to exactly the limit (allowed)
2162        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2163
2164        // Simulate the drive overshooting past 45° (position_actual in counts)
2165        // home_offset is 0, so 1650 counts = 46.4°
2166        view.position_actual = 1650;
2167        view.velocity_actual = 100; // moving positive
2168
2169        // Simulate set-point ack so we're in Moving step 2
2170        view.status_word = 0x1027;
2171        axis.tick(&mut view, &mut client);
2172        view.status_word = 0x0027;
2173        axis.tick(&mut view, &mut client);
2174
2175        // Should have halted and set error
2176        assert!(axis.is_error);
2177        assert!(axis.at_max_limit);
2178        assert_eq!(axis.op, AxisOp::Idle);
2179        assert!(axis.error_message.contains("Software position limit"));
2180        // Halt bit (bit 8) should be set
2181        assert!(view.control_word & (1 << 8) != 0);
2182    }
2183
2184    #[test]
2185    fn runtime_limit_allows_move_in_opposite_direction() {
2186        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2187        let mut view = MockView::new();
2188        view.set_state(0x0027);
2189        // Start at 50° (past max limit)
2190        view.position_actual = 1778; // ~50°
2191        axis.set_software_max_limit(45.0);
2192        axis.tick(&mut view, &mut client);
2193        assert!(axis.at_max_limit);
2194
2195        // Move back toward 0 — should be allowed even though at max limit
2196        axis.move_absolute(&mut view, 0.0, 90.0, 180.0, 180.0);
2197        assert!(!axis.is_error);
2198        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
2199
2200        // Simulate moving negative — limit check should not halt
2201        view.velocity_actual = -100;
2202        view.status_word = 0x1027; // ack
2203        axis.tick(&mut view, &mut client);
2204        // Still moving, no error from limit
2205        assert!(!axis.is_error);
2206    }
2207
2208    #[test]
2209    fn positive_limit_switch_halts_positive_move() {
2210        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2211        let mut view = MockView::new();
2212        view.set_state(0x0027);
2213        axis.tick(&mut view, &mut client);
2214
2215        // Start a move in the positive direction
2216        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2217        view.velocity_actual = 100; // moving positive
2218        // Simulate set-point ack so we're in Moving step 2
2219        view.status_word = 0x1027;
2220        axis.tick(&mut view, &mut client);
2221        view.status_word = 0x0027;
2222
2223        // Now the positive limit switch trips
2224        view.positive_limit = true;
2225        axis.tick(&mut view, &mut client);
2226
2227        assert!(axis.is_error);
2228        assert!(axis.at_positive_limit_switch);
2229        assert!(!axis.is_busy);
2230        assert!(axis.error_message.contains("Positive limit switch"));
2231        // Halt bit should be set
2232        assert!(view.control_word & (1 << 8) != 0);
2233    }
2234
2235    #[test]
2236    fn negative_limit_switch_halts_negative_move() {
2237        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2238        let mut view = MockView::new();
2239        view.set_state(0x0027);
2240        axis.tick(&mut view, &mut client);
2241
2242        // Start a move in the negative direction
2243        axis.move_absolute(&mut view, -45.0, 90.0, 180.0, 180.0);
2244        view.velocity_actual = -100; // moving negative
2245        view.status_word = 0x1027;
2246        axis.tick(&mut view, &mut client);
2247        view.status_word = 0x0027;
2248
2249        // Negative limit switch trips
2250        view.negative_limit = true;
2251        axis.tick(&mut view, &mut client);
2252
2253        assert!(axis.is_error);
2254        assert!(axis.at_negative_limit_switch);
2255        assert!(axis.error_message.contains("Negative limit switch"));
2256    }
2257
2258    #[test]
2259    fn limit_switch_allows_move_in_opposite_direction() {
2260        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2261        let mut view = MockView::new();
2262        view.set_state(0x0027);
2263        // Positive limit is active, but we're moving negative (retreating)
2264        view.positive_limit = true;
2265        view.velocity_actual = -100;
2266        axis.tick(&mut view, &mut client);
2267        assert!(axis.at_positive_limit_switch);
2268
2269        // Move in the negative direction should be allowed
2270        axis.move_absolute(&mut view, -10.0, 90.0, 180.0, 180.0);
2271        view.status_word = 0x1027;
2272        axis.tick(&mut view, &mut client);
2273
2274        // Should still be moving, no error
2275        assert!(!axis.is_error);
2276        assert!(matches!(axis.op, AxisOp::Moving(_, _)));
2277    }
2278
2279    #[test]
2280    fn limit_switch_ignored_when_not_moving() {
2281        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2282        let mut view = MockView::new();
2283        view.set_state(0x0027);
2284        view.positive_limit = true;
2285
2286        axis.tick(&mut view, &mut client);
2287
2288        // Output flag is set, but no error since we're not moving
2289        assert!(axis.at_positive_limit_switch);
2290        assert!(!axis.is_error);
2291    }
2292
2293    #[test]
2294    fn home_sensor_output_tracks_view() {
2295        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2296        let mut view = MockView::new();
2297        view.set_state(0x0027);
2298
2299        axis.tick(&mut view, &mut client);
2300        assert!(!axis.home_sensor);
2301
2302        view.home_sensor = true;
2303        axis.tick(&mut view, &mut client);
2304        assert!(axis.home_sensor);
2305
2306        view.home_sensor = false;
2307        axis.tick(&mut view, &mut client);
2308        assert!(!axis.home_sensor);
2309    }
2310
2311    #[test]
2312    fn velocity_output_converted() {
2313        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2314        let mut view = MockView::new();
2315        view.set_state(0x0027);
2316        // 3200 counts/s = 90 deg/s
2317        view.velocity_actual = 3200;
2318
2319        axis.tick(&mut view, &mut client);
2320
2321        assert!((axis.speed - 90.0).abs() < 0.1);
2322        assert!(axis.moving_positive);
2323        assert!(!axis.moving_negative);
2324    }
2325
2326    // ── Software homing tests ──
2327
2328    fn soft_homing_config() -> AxisConfig {
2329        let mut cfg = AxisConfig::new(12_800).with_user_scale(360.0);
2330        cfg.homing_speed = 10.0;
2331        cfg.homing_accel = 20.0;
2332        cfg.homing_decel = 20.0;
2333        cfg
2334    }
2335
2336    fn soft_homing_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
2337        use tokio::sync::mpsc;
2338        let (write_tx, write_rx) = mpsc::unbounded_channel();
2339        let (response_tx, response_rx) = mpsc::unbounded_channel();
2340        let client = CommandClient::new(write_tx, response_rx);
2341        let axis = Axis::new(soft_homing_config(), "TestDrive");
2342        (axis, client, response_tx, write_rx)
2343    }
2344
2345    /// Helper: enable the axis and put it in OperationEnabled state.
2346    fn enable_axis(axis: &mut Axis, view: &mut MockView, client: &mut CommandClient) {
2347        view.set_state(0x0027); // OperationEnabled
2348        axis.tick(view, client);
2349    }
2350
2351    /// Helper: drive the soft homing state machine through phases 2-4
2352    /// (halt, back-off, set home). Call after sensor triggers (step 4).
2353    /// `trigger_pos`: position where sensor triggered
2354    /// `clear_sensor`: closure to deactivate the sensor on the view
2355    fn complete_soft_homing(
2356        axis: &mut Axis,
2357        view: &mut MockView,
2358        client: &mut CommandClient,
2359        resp_tx: &tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>,
2360        trigger_pos: i32,
2361        clear_sensor: impl FnOnce(&mut MockView),
2362    ) {
2363        use mechutil::ipc::CommandMessage as IpcMsg;
2364
2365        // Phase 2: HALT (steps 4-6)
2366        // Step 4: halt
2367        axis.tick(view, client);
2368        assert!(matches!(axis.op, AxisOp::SoftHoming(5)));
2369
2370        // Step 5: motor decelerating then stopped
2371        view.position_actual = trigger_pos + 100;
2372        axis.tick(view, client);
2373        view.position_actual = trigger_pos + 120;
2374        axis.tick(view, client);
2375        // 10 stable ticks
2376        for _ in 0..10 { axis.tick(view, client); }
2377        assert!(matches!(axis.op, AxisOp::SoftHoming(6)));
2378
2379        // Step 6: cancel ack → delay step 60
2380        view.status_word = 0x1027;
2381        axis.tick(view, client);
2382        assert!(matches!(axis.op, AxisOp::SoftHoming(60)));
2383        view.status_word = 0x0027;
2384
2385        // Step 60: delay (~1 second = 100 ticks)
2386        for _ in 0..100 { axis.tick(view, client); }
2387        assert!(matches!(axis.op, AxisOp::SoftHoming(7)));
2388
2389        // Phase 3: BACK-OFF (steps 7-11)
2390        // Step 7: start back-off move
2391        axis.tick(view, client);
2392        assert!(matches!(axis.op, AxisOp::SoftHoming(8)));
2393
2394        // Step 8: ack
2395        view.status_word = 0x1027;
2396        axis.tick(view, client);
2397        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
2398        view.status_word = 0x0027;
2399
2400        // Step 9: sensor still active, then clears
2401        axis.tick(view, client);
2402        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
2403        clear_sensor(view);
2404        view.position_actual = trigger_pos - 200;
2405        axis.tick(view, client);
2406        assert!(matches!(axis.op, AxisOp::SoftHoming(10)));
2407
2408        // Step 10-11: halt after back-off, wait stable
2409        axis.tick(view, client);
2410        assert!(matches!(axis.op, AxisOp::SoftHoming(11)));
2411        for _ in 0..10 { axis.tick(view, client); }
2412        assert!(matches!(axis.op, AxisOp::SoftHoming(12)));
2413
2414        // Phase 4: SET HOME (steps 12-19)
2415        // Step 12: cancel ack + SDO write home offset
2416        view.status_word = 0x1027;
2417        axis.tick(view, client);
2418        view.status_word = 0x0027;
2419        assert!(matches!(axis.op, AxisOp::SoftHoming(13)));
2420
2421        // Step 13: SDO ack for home offset
2422        let tid = axis.homing_sdo_tid;
2423        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
2424        client.poll();
2425        axis.tick(view, client);
2426        assert!(matches!(axis.op, AxisOp::SoftHoming(14)));
2427
2428        // Step 14→15: SDO write homing method, ack
2429        axis.tick(view, client);
2430        let tid = axis.homing_sdo_tid;
2431        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
2432        client.poll();
2433        axis.tick(view, client);
2434        assert!(matches!(axis.op, AxisOp::SoftHoming(16)));
2435
2436        // Step 16: switch to homing mode + trigger
2437        view.modes_of_operation_display = ModesOfOperation::Homing.as_i8();
2438        axis.tick(view, client);
2439        assert!(matches!(axis.op, AxisOp::SoftHoming(17)));
2440
2441        // Step 17: homing complete (attained + reached)
2442        view.status_word = 0x1427; // bit 12 + bit 10
2443        axis.tick(view, client);
2444        assert!(matches!(axis.op, AxisOp::SoftHoming(18)));
2445        view.modes_of_operation_display = ModesOfOperation::ProfilePosition.as_i8();
2446        view.status_word = 0x0027;
2447
2448        // Step 18: hold position
2449        axis.tick(view, client);
2450        assert!(matches!(axis.op, AxisOp::SoftHoming(19)));
2451
2452        // Step 19: hold ack
2453        view.status_word = 0x1027;
2454        axis.tick(view, client);
2455        view.status_word = 0x0027;
2456
2457        assert_eq!(axis.op, AxisOp::Idle);
2458        assert!(!axis.is_busy);
2459        assert!(!axis.is_error);
2460        assert_eq!(axis.home_offset, 0); // drive handles it now
2461    }
2462
2463    #[test]
2464    fn soft_homing_pnp_home_sensor_full_sequence() {
2465        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
2466        let mut view = MockView::new();
2467        enable_axis(&mut axis, &mut view, &mut client);
2468
2469        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2470
2471        // Phase 1: search
2472        axis.tick(&mut view, &mut client); // step 0→1
2473        view.status_word = 0x1027;
2474        axis.tick(&mut view, &mut client); // step 1→2 (ack)
2475        view.status_word = 0x0027;
2476        axis.tick(&mut view, &mut client); // step 2→3
2477
2478        // Sensor triggers
2479        view.home_sensor = true;
2480        view.position_actual = 5000;
2481        axis.tick(&mut view, &mut client);
2482        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2483
2484        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
2485            |v| { v.home_sensor = false; });
2486    }
2487
2488    #[test]
2489    fn soft_homing_npn_home_sensor_full_sequence() {
2490        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
2491        let mut view = MockView::new();
2492        // NPN: sensor reads true normally, false when detected
2493        view.home_sensor = true;
2494        enable_axis(&mut axis, &mut view, &mut client);
2495
2496        axis.home(&mut view, HomingMethod::HomeSensorPosNpn);
2497
2498        // Phase 1: search
2499        axis.tick(&mut view, &mut client);
2500        view.status_word = 0x1027;
2501        axis.tick(&mut view, &mut client);
2502        view.status_word = 0x0027;
2503        axis.tick(&mut view, &mut client);
2504
2505        // NPN: sensor goes false = detected
2506        view.home_sensor = false;
2507        view.position_actual = 3000;
2508        axis.tick(&mut view, &mut client);
2509        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2510
2511        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 3000,
2512            |v| { v.home_sensor = true; }); // NPN: back to true = cleared
2513    }
2514
2515    #[test]
2516    fn soft_homing_limit_switch_suppresses_halt() {
2517        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2518        let mut view = MockView::new();
2519        enable_axis(&mut axis, &mut view, &mut client);
2520
2521        // Software homing on positive limit switch (rising edge)
2522        axis.home(&mut view, HomingMethod::LimitSwitchPosPnp);
2523
2524        // Progress through initial steps
2525        axis.tick(&mut view, &mut client); // step 0 → 1
2526        view.status_word = 0x1027; // ack
2527        axis.tick(&mut view, &mut client); // step 1 → 2
2528        view.status_word = 0x0027;
2529        axis.tick(&mut view, &mut client); // step 2 → 3
2530
2531        // Positive limit switch trips — should NOT halt (suppressed)
2532        view.positive_limit = true;
2533        view.velocity_actual = 100; // moving positive
2534        view.position_actual = 8000;
2535        axis.tick(&mut view, &mut client);
2536
2537        // Should have detected rising edge → step 4, NOT an error halt
2538        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2539        assert!(!axis.is_error);
2540    }
2541
2542    #[test]
2543    fn soft_homing_opposite_limit_still_protects() {
2544        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2545        let mut view = MockView::new();
2546        enable_axis(&mut axis, &mut view, &mut client);
2547
2548        // Software homing on home sensor (positive direction)
2549        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2550
2551        // Progress through initial steps
2552        axis.tick(&mut view, &mut client); // step 0 → 1
2553        view.status_word = 0x1027; // ack
2554        axis.tick(&mut view, &mut client); // step 1 → 2
2555        view.status_word = 0x0027;
2556        axis.tick(&mut view, &mut client); // step 2 → 3
2557
2558        // Negative limit switch trips while searching positive (shouldn't happen
2559        // in practice, but tests protection)
2560        view.negative_limit = true;
2561        view.velocity_actual = -100; // moving negative
2562        axis.tick(&mut view, &mut client);
2563
2564        // Should have halted with error (negative limit protects)
2565        assert!(axis.is_error);
2566        assert!(axis.error_message.contains("Negative limit switch"));
2567    }
2568
2569    #[test]
2570    // fn soft_homing_sensor_already_active_rejects() {
2571    //     let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2572    //     let mut view = MockView::new();
2573    //     enable_axis(&mut axis, &mut view, &mut client);
2574
2575    //     // Home sensor is already active (rising edge would never happen)
2576    //     view.home_sensor = true;
2577    //     axis.tick(&mut view, &mut client); // update prev state
2578
2579    //     axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2580
2581    //     // Should have been rejected immediately
2582    //     assert!(axis.is_error);
2583    //     assert!(axis.error_message.contains("already in trigger state"));
2584    //     assert_eq!(axis.op, AxisOp::Idle);
2585    // }
2586
2587    #[test]
2588    fn soft_homing_negative_direction_sets_negative_target() {
2589        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2590        let mut view = MockView::new();
2591        enable_axis(&mut axis, &mut view, &mut client);
2592
2593        axis.home(&mut view, HomingMethod::HomeSensorNegPnp);
2594        axis.tick(&mut view, &mut client); // step 0
2595
2596        // Target should be negative (large negative value in counts)
2597        assert!(view.target_position < 0);
2598    }
2599
2600    #[test]
2601    fn home_integrated_method_starts_hardware_homing() {
2602        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2603        let mut view = MockView::new();
2604        enable_axis(&mut axis, &mut view, &mut client);
2605
2606        axis.home(&mut view, HomingMethod::CurrentPosition);
2607        assert!(matches!(axis.op, AxisOp::Homing(0)));
2608        assert_eq!(axis.homing_method, 37);
2609    }
2610
2611    #[test]
2612    fn home_integrated_arbitrary_code() {
2613        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2614        let mut view = MockView::new();
2615        enable_axis(&mut axis, &mut view, &mut client);
2616
2617        axis.home(&mut view, HomingMethod::Integrated(35));
2618        assert!(matches!(axis.op, AxisOp::Homing(0)));
2619        assert_eq!(axis.homing_method, 35);
2620    }
2621
2622    #[test]
2623    fn hardware_homing_skips_speed_sdos_when_zero() {
2624        use mechutil::ipc::CommandMessage;
2625
2626        let (mut axis, mut client, resp_tx, mut write_rx) = test_axis();
2627        let mut view = MockView::new();
2628        enable_axis(&mut axis, &mut view, &mut client);
2629
2630        // Config has homing_speed = 0 and homing_accel = 0 (defaults)
2631        axis.home(&mut view, HomingMethod::Integrated(37));
2632
2633        // Step 0: writes homing method SDO
2634        axis.tick(&mut view, &mut client);
2635        assert!(matches!(axis.op, AxisOp::Homing(1)));
2636
2637        // Drain the SDO write message
2638        let _ = write_rx.try_recv();
2639
2640        // Simulate SDO ack — need to use the correct tid from the sdo write
2641        let tid = axis.homing_sdo_tid;
2642        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
2643        client.poll();
2644        axis.tick(&mut view, &mut client);
2645
2646        // Should have skipped to step 8 (set homing mode)
2647        assert!(matches!(axis.op, AxisOp::Homing(8)));
2648    }
2649
2650    #[test]
2651    fn hardware_homing_writes_speed_sdos_when_nonzero() {
2652        use mechutil::ipc::CommandMessage;
2653
2654        let (mut axis, mut client, resp_tx, mut write_rx) = soft_homing_axis();
2655        let mut view = MockView::new();
2656        enable_axis(&mut axis, &mut view, &mut client);
2657
2658        // Config has homing_speed = 10.0, homing_accel = 20.0
2659        axis.home(&mut view, HomingMethod::Integrated(37));
2660
2661        // Step 0: writes homing method SDO
2662        axis.tick(&mut view, &mut client);
2663        assert!(matches!(axis.op, AxisOp::Homing(1)));
2664        let _ = write_rx.try_recv();
2665
2666        // SDO ack for homing method
2667        let tid = axis.homing_sdo_tid;
2668        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
2669        client.poll();
2670        axis.tick(&mut view, &mut client);
2671        // Should go to step 2 (write speed SDO), not skip to 8
2672        assert!(matches!(axis.op, AxisOp::Homing(2)));
2673    }
2674
2675    #[test]
2676    fn soft_homing_edge_during_ack_step() {
2677        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2678        let mut view = MockView::new();
2679        enable_axis(&mut axis, &mut view, &mut client);
2680
2681        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2682        axis.tick(&mut view, &mut client); // step 0 → 1
2683
2684        // Sensor rises during step 1 (before ack)
2685        view.home_sensor = true;
2686        view.position_actual = 2000;
2687        axis.tick(&mut view, &mut client);
2688
2689        // Should jump straight to step 4 (edge detected)
2690        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2691    }
2692
2693    #[test]
2694    fn soft_homing_applies_home_position() {
2695        let mut cfg = soft_homing_config();
2696        cfg.home_position = 90.0;
2697
2698        use tokio::sync::mpsc;
2699        let (write_tx, _write_rx) = mpsc::unbounded_channel();
2700        let (resp_tx, response_rx) = mpsc::unbounded_channel();
2701        let mut client = CommandClient::new(write_tx, response_rx);
2702        let mut axis = Axis::new(cfg, "TestDrive");
2703
2704        let mut view = MockView::new();
2705        enable_axis(&mut axis, &mut view, &mut client);
2706
2707        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2708
2709        // Search phase
2710        axis.tick(&mut view, &mut client);
2711        view.status_word = 0x1027;
2712        axis.tick(&mut view, &mut client);
2713        view.status_word = 0x0027;
2714        axis.tick(&mut view, &mut client);
2715
2716        // Sensor triggers
2717        view.home_sensor = true;
2718        view.position_actual = 5000;
2719        axis.tick(&mut view, &mut client);
2720        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2721
2722        // Complete full sequence (halt, back-off, set home via drive)
2723        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
2724            |v| { v.home_sensor = false; });
2725
2726        // After completion, home_offset = 0 (drive handles it)
2727        assert_eq!(axis.home_offset, 0);
2728    }
2729
2730    #[test]
2731    fn soft_homing_default_home_position_zero() {
2732        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
2733        let mut view = MockView::new();
2734        enable_axis(&mut axis, &mut view, &mut client);
2735
2736        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2737
2738        // Search phase
2739        axis.tick(&mut view, &mut client);
2740        view.status_word = 0x1027;
2741        axis.tick(&mut view, &mut client);
2742        view.status_word = 0x0027;
2743        axis.tick(&mut view, &mut client);
2744
2745        // Sensor triggers
2746        view.home_sensor = true;
2747        view.position_actual = 5000;
2748        axis.tick(&mut view, &mut client);
2749
2750        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
2751            |v| { v.home_sensor = false; });
2752
2753        assert_eq!(axis.home_offset, 0);
2754    }
2755}