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