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