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