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