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                // Edge detected: halt and capture position, applying home_position
1044                // so the reference point reads as config.home_position in user units.
1045                let mut cw = RawControlWord(view.control_word());
1046                cw.set_bit(8, true); // halt
1047                view.set_control_word(cw.raw());
1048                self.home_offset = view.position_actual()
1049                    - self.config.to_counts(self.config.home_position).round() as i32;
1050                log::info!("Software homing: edge detected, home offset: {}", self.home_offset);
1051                self.op = AxisOp::SoftHoming(5);
1052            }
1053            5 => {
1054                // Wait for halt acknowledged (target_reached bit 10)
1055                let sw = RawStatusWord(view.status_word());
1056                if sw.target_reached() {
1057                    // Clear halt bit, switch to PP
1058                    let mut cw = RawControlWord(view.control_word());
1059                    cw.set_bit(8, false);
1060                    view.set_control_word(cw.raw());
1061                    view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1062                    log::info!("Software homing complete — home offset: {}", self.home_offset);
1063                    self.complete_op();
1064                } else if self.homing_timed_out() {
1065                    self.set_op_error("Software homing timeout: halt not acknowledged");
1066                }
1067            }
1068            _ => self.complete_op(),
1069        }
1070    }
1071
1072    // ── Halting ──
1073    fn tick_halting(&mut self, _view: &mut impl AxisView) {
1074        // Halt bit was already set in halt(). Go idle immediately —
1075        // the drive will decelerate on its own, reflected in velocity outputs.
1076        self.complete_op();
1077    }
1078
1079    // ── Fault Recovery ──
1080    // Step 0: (done in reset_faults()) clear bit 7
1081    // Step 1: assert bit 7 (fault reset rising edge)
1082    // Step 2: wait fault cleared → Idle
1083    fn tick_fault_recovery(&mut self, view: &mut impl AxisView, step: u8) {
1084        match step {
1085            1 => {
1086                // Assert fault reset (rising edge on bit 7)
1087                let mut cw = RawControlWord(view.control_word());
1088                cw.cmd_fault_reset();
1089                view.set_control_word(cw.raw());
1090                self.op = AxisOp::FaultRecovery(2);
1091            }
1092            2 => {
1093                // Wait for fault to clear
1094                let sw = RawStatusWord(view.status_word());
1095                let state = sw.state();
1096                if !matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
1097                    log::info!("Fault cleared (drive state: {})", state);
1098                    self.complete_op();
1099                } else if self.op_timed_out() {
1100                    self.set_op_error("Fault reset timeout: drive still faulted");
1101                }
1102            }
1103            _ => self.complete_op(),
1104        }
1105    }
1106}
1107
1108// ──────────────────────────────────────────────
1109// Tests
1110// ──────────────────────────────────────────────
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    /// Mock AxisView for testing.
1117    struct MockView {
1118        control_word: u16,
1119        status_word: u16,
1120        target_position: i32,
1121        profile_velocity: u32,
1122        profile_acceleration: u32,
1123        profile_deceleration: u32,
1124        modes_of_operation: i8,
1125        modes_of_operation_display: i8,
1126        position_actual: i32,
1127        velocity_actual: i32,
1128        error_code: u16,
1129        positive_limit: bool,
1130        negative_limit: bool,
1131        home_sensor: bool,
1132    }
1133
1134    impl MockView {
1135        fn new() -> Self {
1136            Self {
1137                control_word: 0,
1138                status_word: 0x0040, // SwitchOnDisabled
1139                target_position: 0,
1140                profile_velocity: 0,
1141                profile_acceleration: 0,
1142                profile_deceleration: 0,
1143                modes_of_operation: 0,
1144                modes_of_operation_display: 1, // PP
1145                position_actual: 0,
1146                velocity_actual: 0,
1147                error_code: 0,
1148                positive_limit: false,
1149                negative_limit: false,
1150                home_sensor: false,
1151            }
1152        }
1153
1154        fn set_state(&mut self, state: u16) {
1155            self.status_word = state;
1156        }
1157    }
1158
1159    impl AxisView for MockView {
1160        fn control_word(&self) -> u16 { self.control_word }
1161        fn set_control_word(&mut self, word: u16) { self.control_word = word; }
1162        fn set_target_position(&mut self, pos: i32) { self.target_position = pos; }
1163        fn set_profile_velocity(&mut self, vel: u32) { self.profile_velocity = vel; }
1164        fn set_profile_acceleration(&mut self, accel: u32) { self.profile_acceleration = accel; }
1165        fn set_profile_deceleration(&mut self, decel: u32) { self.profile_deceleration = decel; }
1166        fn set_modes_of_operation(&mut self, mode: i8) { self.modes_of_operation = mode; }
1167        fn modes_of_operation_display(&self) -> i8 { self.modes_of_operation_display }
1168        fn status_word(&self) -> u16 { self.status_word }
1169        fn position_actual(&self) -> i32 { self.position_actual }
1170        fn velocity_actual(&self) -> i32 { self.velocity_actual }
1171        fn error_code(&self) -> u16 { self.error_code }
1172        fn positive_limit_active(&self) -> bool { self.positive_limit }
1173        fn negative_limit_active(&self) -> bool { self.negative_limit }
1174        fn home_sensor_active(&self) -> bool { self.home_sensor }
1175    }
1176
1177    fn test_config() -> AxisConfig {
1178        AxisConfig::new(12_800).with_user_scale(360.0)
1179    }
1180
1181    /// Helper: create axis + mock client channels.
1182    fn test_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
1183        use tokio::sync::mpsc;
1184        let (write_tx, write_rx) = mpsc::unbounded_channel();
1185        let (response_tx, response_rx) = mpsc::unbounded_channel();
1186        let client = CommandClient::new(write_tx, response_rx);
1187        let axis = Axis::new(test_config(), "TestDrive");
1188        (axis, client, response_tx, write_rx)
1189    }
1190
1191    #[test]
1192    fn axis_config_conversion() {
1193        let cfg = test_config();
1194        // 45 degrees = 1600 counts
1195        assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
1196    }
1197
1198    #[test]
1199    fn enable_sequence_sets_pp_mode_and_shutdown() {
1200        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1201        let mut view = MockView::new();
1202
1203        axis.enable(&mut view);
1204
1205        // Should have set PP mode
1206        assert_eq!(view.modes_of_operation, ModesOfOperation::ProfilePosition.as_i8());
1207        // Should have issued shutdown command (bits 1,2 set; 0,3,7 clear)
1208        assert_eq!(view.control_word & 0x008F, 0x0006);
1209        // Should be in Enabling state
1210        assert_eq!(axis.op, AxisOp::Enabling(1));
1211
1212        // Simulate drive reaching ReadyToSwitchOn
1213        view.set_state(0x0021); // ReadyToSwitchOn
1214        axis.tick(&mut view, &mut client);
1215
1216        // Should have issued enable_operation (bits 0-3 set; 7 clear)
1217        assert_eq!(view.control_word & 0x008F, 0x000F);
1218        assert_eq!(axis.op, AxisOp::Enabling(2));
1219
1220        // Simulate drive reaching OperationEnabled
1221        view.set_state(0x0027); // OperationEnabled
1222        axis.tick(&mut view, &mut client);
1223
1224        // Should be idle now, motor_on = true
1225        assert_eq!(axis.op, AxisOp::Idle);
1226        assert!(axis.motor_on);
1227    }
1228
1229    #[test]
1230    fn move_absolute_sets_target() {
1231        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1232        let mut view = MockView::new();
1233        view.set_state(0x0027); // OperationEnabled
1234        axis.tick(&mut view, &mut client); // update outputs
1235
1236        // Move to 45 degrees at 90 deg/s, 180 deg/s² accel/decel
1237        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1238
1239        // Target should be ~1600 counts (45° at 12800 cpr / 360°)
1240        assert_eq!(view.target_position, 1600);
1241        // Velocity: 90 deg/s * (12800/360) ≈ 3200 counts/s
1242        assert_eq!(view.profile_velocity, 3200);
1243        // Accel: 180 deg/s² * (12800/360) ≈ 6400 counts/s²
1244        assert_eq!(view.profile_acceleration, 6400);
1245        assert_eq!(view.profile_deceleration, 6400);
1246        // Bit 4 (new set-point) should be set
1247        assert!(view.control_word & (1 << 4) != 0);
1248        // Bit 6 (relative) should be clear for absolute move
1249        assert!(view.control_word & (1 << 6) == 0);
1250        // Should be in Moving state
1251        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
1252    }
1253
1254    #[test]
1255    fn move_relative_sets_relative_bit() {
1256        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1257        let mut view = MockView::new();
1258        view.set_state(0x0027);
1259        axis.tick(&mut view, &mut client);
1260
1261        axis.move_relative(&mut view, 10.0, 90.0, 180.0, 180.0);
1262
1263        // Bit 6 (relative) should be set
1264        assert!(view.control_word & (1 << 6) != 0);
1265        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Relative, 1)));
1266    }
1267
1268    #[test]
1269    fn move_completes_on_target_reached() {
1270        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1271        let mut view = MockView::new();
1272        view.set_state(0x0027); // OperationEnabled
1273        axis.tick(&mut view, &mut client);
1274
1275        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1276
1277        // Step 1: simulate set-point acknowledge (bit 12)
1278        view.status_word = 0x1027; // OperationEnabled + bit 12
1279        axis.tick(&mut view, &mut client);
1280        // Should have cleared bit 4
1281        assert!(view.control_word & (1 << 4) == 0);
1282
1283        // Step 2: simulate target reached (bit 10)
1284        view.status_word = 0x0427; // OperationEnabled + bit 10
1285        axis.tick(&mut view, &mut client);
1286        // Should be idle now
1287        assert_eq!(axis.op, AxisOp::Idle);
1288        assert!(!axis.in_motion);
1289    }
1290
1291    #[test]
1292    fn fault_detected_sets_error() {
1293        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1294        let mut view = MockView::new();
1295        view.set_state(0x0008); // Fault
1296        view.error_code = 0x1234;
1297
1298        axis.tick(&mut view, &mut client);
1299
1300        assert!(axis.is_error);
1301        assert_eq!(axis.error_code, 0x1234);
1302        assert!(axis.error_message.contains("fault"));
1303    }
1304
1305    #[test]
1306    fn fault_recovery_sequence() {
1307        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1308        let mut view = MockView::new();
1309        view.set_state(0x0008); // Fault
1310
1311        axis.reset_faults(&mut view);
1312        // Step 0: bit 7 should be cleared
1313        assert!(view.control_word & 0x0080 == 0);
1314
1315        // Step 1: tick should assert bit 7
1316        axis.tick(&mut view, &mut client);
1317        assert!(view.control_word & 0x0080 != 0);
1318
1319        // Step 2: simulate fault cleared → SwitchOnDisabled
1320        view.set_state(0x0040);
1321        axis.tick(&mut view, &mut client);
1322        assert_eq!(axis.op, AxisOp::Idle);
1323        assert!(!axis.is_error);
1324    }
1325
1326    #[test]
1327    fn disable_sequence() {
1328        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1329        let mut view = MockView::new();
1330        view.set_state(0x0027); // OperationEnabled
1331
1332        axis.disable(&mut view);
1333        // Should have sent disable_operation command
1334        assert_eq!(view.control_word & 0x008F, 0x0007);
1335
1336        // Simulate drive leaving OperationEnabled
1337        view.set_state(0x0023); // SwitchedOn
1338        axis.tick(&mut view, &mut client);
1339        assert_eq!(axis.op, AxisOp::Idle);
1340    }
1341
1342    #[test]
1343    fn position_tracks_with_home_offset() {
1344        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1345        let mut view = MockView::new();
1346        view.set_state(0x0027);
1347        view.position_actual = 5000;
1348
1349        // Enable to capture home offset
1350        axis.enable(&mut view);
1351        view.set_state(0x0021);
1352        axis.tick(&mut view, &mut client);
1353        view.set_state(0x0027);
1354        axis.tick(&mut view, &mut client);
1355
1356        // Home offset should be 5000
1357        assert_eq!(axis.home_offset, 5000);
1358
1359        // Position should be 0 (at home)
1360        assert!((axis.position - 0.0).abs() < 0.01);
1361
1362        // Move actual position to 5000 + 1600 = 6600
1363        view.position_actual = 6600;
1364        axis.tick(&mut view, &mut client);
1365
1366        // Should read as 45 degrees
1367        assert!((axis.position - 45.0).abs() < 0.1);
1368    }
1369
1370    #[test]
1371    fn set_position_adjusts_home_offset() {
1372        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1373        let mut view = MockView::new();
1374        view.position_actual = 3200;
1375
1376        axis.set_position(&view, 90.0);
1377        axis.tick(&mut view, &mut client);
1378
1379        // home_offset = 3200 - to_counts(90.0) = 3200 - 3200 = 0
1380        assert_eq!(axis.home_offset, 0);
1381        assert!((axis.position - 90.0).abs() < 0.01);
1382    }
1383
1384    #[test]
1385    fn halt_sets_bit_and_goes_idle() {
1386        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1387        let mut view = MockView::new();
1388        view.set_state(0x0027);
1389
1390        axis.halt(&mut view);
1391        // Bit 8 should be set
1392        assert!(view.control_word & (1 << 8) != 0);
1393
1394        // Tick should go idle
1395        axis.tick(&mut view, &mut client);
1396        assert_eq!(axis.op, AxisOp::Idle);
1397    }
1398
1399    #[test]
1400    fn is_busy_tracks_operations() {
1401        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1402        let mut view = MockView::new();
1403
1404        // Idle — not busy
1405        axis.tick(&mut view, &mut client);
1406        assert!(!axis.is_busy);
1407
1408        // Enable — busy
1409        axis.enable(&mut view);
1410        axis.tick(&mut view, &mut client);
1411        assert!(axis.is_busy);
1412
1413        // Complete enable
1414        view.set_state(0x0021);
1415        axis.tick(&mut view, &mut client);
1416        view.set_state(0x0027);
1417        axis.tick(&mut view, &mut client);
1418        assert!(!axis.is_busy);
1419
1420        // Move — busy
1421        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1422        axis.tick(&mut view, &mut client);
1423        assert!(axis.is_busy);
1424        assert!(axis.in_motion);
1425    }
1426
1427    #[test]
1428    fn fault_during_move_cancels_op() {
1429        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1430        let mut view = MockView::new();
1431        view.set_state(0x0027); // OperationEnabled
1432        axis.tick(&mut view, &mut client);
1433
1434        // Start a move
1435        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1436        axis.tick(&mut view, &mut client);
1437        assert!(axis.is_busy);
1438        assert!(!axis.is_error);
1439
1440        // Fault occurs mid-move
1441        view.set_state(0x0008); // Fault
1442        axis.tick(&mut view, &mut client);
1443
1444        // is_busy should be false, is_error should be true
1445        assert!(!axis.is_busy);
1446        assert!(axis.is_error);
1447        assert_eq!(axis.op, AxisOp::Idle);
1448    }
1449
1450    #[test]
1451    fn move_absolute_rejected_by_max_limit() {
1452        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1453        let mut view = MockView::new();
1454        view.set_state(0x0027);
1455        axis.tick(&mut view, &mut client);
1456
1457        axis.set_software_max_limit(90.0);
1458        axis.move_absolute(&mut view, 100.0, 90.0, 180.0, 180.0);
1459
1460        // Should not have started a move — error instead
1461        assert!(axis.is_error);
1462        assert_eq!(axis.op, AxisOp::Idle);
1463        assert!(axis.error_message.contains("max software limit"));
1464    }
1465
1466    #[test]
1467    fn move_absolute_rejected_by_min_limit() {
1468        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1469        let mut view = MockView::new();
1470        view.set_state(0x0027);
1471        axis.tick(&mut view, &mut client);
1472
1473        axis.set_software_min_limit(-10.0);
1474        axis.move_absolute(&mut view, -20.0, 90.0, 180.0, 180.0);
1475
1476        assert!(axis.is_error);
1477        assert_eq!(axis.op, AxisOp::Idle);
1478        assert!(axis.error_message.contains("min software limit"));
1479    }
1480
1481    #[test]
1482    fn move_relative_rejected_by_max_limit() {
1483        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1484        let mut view = MockView::new();
1485        view.set_state(0x0027);
1486        axis.tick(&mut view, &mut client);
1487
1488        // Position is 0, max limit 50 — relative move of +60 should be rejected
1489        axis.set_software_max_limit(50.0);
1490        axis.move_relative(&mut view, 60.0, 90.0, 180.0, 180.0);
1491
1492        assert!(axis.is_error);
1493        assert_eq!(axis.op, AxisOp::Idle);
1494        assert!(axis.error_message.contains("max software limit"));
1495    }
1496
1497    #[test]
1498    fn move_within_limits_allowed() {
1499        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1500        let mut view = MockView::new();
1501        view.set_state(0x0027);
1502        axis.tick(&mut view, &mut client);
1503
1504        axis.set_software_max_limit(90.0);
1505        axis.set_software_min_limit(-90.0);
1506        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1507
1508        // Should have started normally
1509        assert!(!axis.is_error);
1510        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
1511    }
1512
1513    #[test]
1514    fn runtime_limit_halts_move_in_violated_direction() {
1515        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1516        let mut view = MockView::new();
1517        view.set_state(0x0027);
1518        axis.tick(&mut view, &mut client);
1519
1520        axis.set_software_max_limit(45.0);
1521        // Start a move to exactly the limit (allowed)
1522        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1523
1524        // Simulate the drive overshooting past 45° (position_actual in counts)
1525        // home_offset is 0, so 1650 counts = 46.4°
1526        view.position_actual = 1650;
1527        view.velocity_actual = 100; // moving positive
1528
1529        // Simulate set-point ack so we're in Moving step 2
1530        view.status_word = 0x1027;
1531        axis.tick(&mut view, &mut client);
1532        view.status_word = 0x0027;
1533        axis.tick(&mut view, &mut client);
1534
1535        // Should have halted and set error
1536        assert!(axis.is_error);
1537        assert!(axis.at_max_limit);
1538        assert_eq!(axis.op, AxisOp::Idle);
1539        assert!(axis.error_message.contains("Software position limit"));
1540        // Halt bit (bit 8) should be set
1541        assert!(view.control_word & (1 << 8) != 0);
1542    }
1543
1544    #[test]
1545    fn runtime_limit_allows_move_in_opposite_direction() {
1546        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1547        let mut view = MockView::new();
1548        view.set_state(0x0027);
1549        // Start at 50° (past max limit)
1550        view.position_actual = 1778; // ~50°
1551        axis.set_software_max_limit(45.0);
1552        axis.tick(&mut view, &mut client);
1553        assert!(axis.at_max_limit);
1554
1555        // Move back toward 0 — should be allowed even though at max limit
1556        axis.move_absolute(&mut view, 0.0, 90.0, 180.0, 180.0);
1557        assert!(!axis.is_error);
1558        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1)));
1559
1560        // Simulate moving negative — limit check should not halt
1561        view.velocity_actual = -100;
1562        view.status_word = 0x1027; // ack
1563        axis.tick(&mut view, &mut client);
1564        // Still moving, no error from limit
1565        assert!(!axis.is_error);
1566    }
1567
1568    #[test]
1569    fn positive_limit_switch_halts_positive_move() {
1570        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1571        let mut view = MockView::new();
1572        view.set_state(0x0027);
1573        axis.tick(&mut view, &mut client);
1574
1575        // Start a move in the positive direction
1576        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
1577        view.velocity_actual = 100; // moving positive
1578        // Simulate set-point ack so we're in Moving step 2
1579        view.status_word = 0x1027;
1580        axis.tick(&mut view, &mut client);
1581        view.status_word = 0x0027;
1582
1583        // Now the positive limit switch trips
1584        view.positive_limit = true;
1585        axis.tick(&mut view, &mut client);
1586
1587        assert!(axis.is_error);
1588        assert!(axis.at_positive_limit_switch);
1589        assert!(!axis.is_busy);
1590        assert!(axis.error_message.contains("Positive limit switch"));
1591        // Halt bit should be set
1592        assert!(view.control_word & (1 << 8) != 0);
1593    }
1594
1595    #[test]
1596    fn negative_limit_switch_halts_negative_move() {
1597        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1598        let mut view = MockView::new();
1599        view.set_state(0x0027);
1600        axis.tick(&mut view, &mut client);
1601
1602        // Start a move in the negative direction
1603        axis.move_absolute(&mut view, -45.0, 90.0, 180.0, 180.0);
1604        view.velocity_actual = -100; // moving negative
1605        view.status_word = 0x1027;
1606        axis.tick(&mut view, &mut client);
1607        view.status_word = 0x0027;
1608
1609        // Negative limit switch trips
1610        view.negative_limit = true;
1611        axis.tick(&mut view, &mut client);
1612
1613        assert!(axis.is_error);
1614        assert!(axis.at_negative_limit_switch);
1615        assert!(axis.error_message.contains("Negative limit switch"));
1616    }
1617
1618    #[test]
1619    fn limit_switch_allows_move_in_opposite_direction() {
1620        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1621        let mut view = MockView::new();
1622        view.set_state(0x0027);
1623        // Positive limit is active, but we're moving negative (retreating)
1624        view.positive_limit = true;
1625        view.velocity_actual = -100;
1626        axis.tick(&mut view, &mut client);
1627        assert!(axis.at_positive_limit_switch);
1628
1629        // Move in the negative direction should be allowed
1630        axis.move_absolute(&mut view, -10.0, 90.0, 180.0, 180.0);
1631        view.status_word = 0x1027;
1632        axis.tick(&mut view, &mut client);
1633
1634        // Should still be moving, no error
1635        assert!(!axis.is_error);
1636        assert!(matches!(axis.op, AxisOp::Moving(_, _)));
1637    }
1638
1639    #[test]
1640    fn limit_switch_ignored_when_not_moving() {
1641        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1642        let mut view = MockView::new();
1643        view.set_state(0x0027);
1644        view.positive_limit = true;
1645
1646        axis.tick(&mut view, &mut client);
1647
1648        // Output flag is set, but no error since we're not moving
1649        assert!(axis.at_positive_limit_switch);
1650        assert!(!axis.is_error);
1651    }
1652
1653    #[test]
1654    fn home_sensor_output_tracks_view() {
1655        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1656        let mut view = MockView::new();
1657        view.set_state(0x0027);
1658
1659        axis.tick(&mut view, &mut client);
1660        assert!(!axis.home_sensor);
1661
1662        view.home_sensor = true;
1663        axis.tick(&mut view, &mut client);
1664        assert!(axis.home_sensor);
1665
1666        view.home_sensor = false;
1667        axis.tick(&mut view, &mut client);
1668        assert!(!axis.home_sensor);
1669    }
1670
1671    #[test]
1672    fn velocity_output_converted() {
1673        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1674        let mut view = MockView::new();
1675        view.set_state(0x0027);
1676        // 3200 counts/s = 90 deg/s
1677        view.velocity_actual = 3200;
1678
1679        axis.tick(&mut view, &mut client);
1680
1681        assert!((axis.speed - 90.0).abs() < 0.1);
1682        assert!(axis.moving_positive);
1683        assert!(!axis.moving_negative);
1684    }
1685
1686    // ── Software homing tests ──
1687
1688    fn soft_homing_config() -> AxisConfig {
1689        let mut cfg = AxisConfig::new(12_800).with_user_scale(360.0);
1690        cfg.homing_speed = 10.0;
1691        cfg.homing_accel = 20.0;
1692        cfg.homing_decel = 20.0;
1693        cfg
1694    }
1695
1696    fn soft_homing_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
1697        use tokio::sync::mpsc;
1698        let (write_tx, write_rx) = mpsc::unbounded_channel();
1699        let (response_tx, response_rx) = mpsc::unbounded_channel();
1700        let client = CommandClient::new(write_tx, response_rx);
1701        let axis = Axis::new(soft_homing_config(), "TestDrive");
1702        (axis, client, response_tx, write_rx)
1703    }
1704
1705    /// Helper: enable the axis and put it in OperationEnabled state.
1706    fn enable_axis(axis: &mut Axis, view: &mut MockView, client: &mut CommandClient) {
1707        view.set_state(0x0027); // OperationEnabled
1708        axis.tick(view, client);
1709    }
1710
1711    #[test]
1712    fn soft_homing_rising_edge_home_sensor_triggers_home() {
1713        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1714        let mut view = MockView::new();
1715        enable_axis(&mut axis, &mut view, &mut client);
1716
1717        // Start software homing: positive direction, rising edge on home sensor
1718        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
1719        assert!(matches!(axis.op, AxisOp::SoftHoming(0)));
1720
1721        // Step 0 → 1: sets target, trigger
1722        axis.tick(&mut view, &mut client);
1723        assert!(matches!(axis.op, AxisOp::SoftHoming(1)));
1724        // Bit 4 should be set (new set-point)
1725        assert!(view.control_word & (1 << 4) != 0);
1726
1727        // Simulate set-point ack (bit 12)
1728        view.status_word = 0x1027;
1729        axis.tick(&mut view, &mut client);
1730        // Should have cleared bit 4 and moved to step 2
1731        assert!(view.control_word & (1 << 4) == 0);
1732        assert!(matches!(axis.op, AxisOp::SoftHoming(2)));
1733
1734        // Step 2 → 3: monitoring
1735        view.status_word = 0x0027;
1736        axis.tick(&mut view, &mut client);
1737        assert!(matches!(axis.op, AxisOp::SoftHoming(3)));
1738
1739        // Step 3: no edge yet
1740        axis.tick(&mut view, &mut client);
1741        assert!(matches!(axis.op, AxisOp::SoftHoming(3)));
1742
1743        // Simulate home sensor rising edge (false → true)
1744        view.home_sensor = true;
1745        view.position_actual = 5000;
1746        axis.tick(&mut view, &mut client);
1747        // Should have detected edge → step 4 (halt + capture)
1748        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
1749
1750        // Step 4 → 5: halt
1751        axis.tick(&mut view, &mut client);
1752        assert!(view.control_word & (1 << 8) != 0); // halt bit
1753        assert_eq!(axis.home_offset, 5000);
1754        assert!(matches!(axis.op, AxisOp::SoftHoming(5)));
1755
1756        // Simulate target_reached (halt acknowledged)
1757        view.status_word = 0x0427; // bit 10 set
1758        axis.tick(&mut view, &mut client);
1759        // Should be idle now
1760        assert_eq!(axis.op, AxisOp::Idle);
1761        assert!(!axis.is_busy);
1762        assert!(!axis.is_error);
1763    }
1764
1765    #[test]
1766    fn soft_homing_falling_edge_home_sensor_triggers_home() {
1767        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1768        let mut view = MockView::new();
1769        // Start with home sensor active
1770        view.home_sensor = true;
1771        enable_axis(&mut axis, &mut view, &mut client);
1772
1773        // Falling edge: sensor goes true → false
1774        axis.home(&mut view, HomingMethod::HomeSensorPosNpn);
1775        assert!(matches!(axis.op, AxisOp::SoftHoming(0)));
1776
1777        // Step 0 → 1
1778        axis.tick(&mut view, &mut client);
1779        // Step 1: ack
1780        view.status_word = 0x1027;
1781        axis.tick(&mut view, &mut client);
1782        // Step 2 → 3
1783        view.status_word = 0x0027;
1784        axis.tick(&mut view, &mut client);
1785
1786        // Step 3: sensor still active, no edge
1787        axis.tick(&mut view, &mut client);
1788        assert!(matches!(axis.op, AxisOp::SoftHoming(3)));
1789
1790        // Simulate falling edge (true → false)
1791        view.home_sensor = false;
1792        view.position_actual = 3000;
1793        axis.tick(&mut view, &mut client);
1794        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
1795
1796        // Complete the halt
1797        axis.tick(&mut view, &mut client);
1798        view.status_word = 0x0427;
1799        axis.tick(&mut view, &mut client);
1800        assert_eq!(axis.op, AxisOp::Idle);
1801        assert_eq!(axis.home_offset, 3000);
1802    }
1803
1804    #[test]
1805    fn soft_homing_limit_switch_suppresses_halt() {
1806        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1807        let mut view = MockView::new();
1808        enable_axis(&mut axis, &mut view, &mut client);
1809
1810        // Software homing on positive limit switch (rising edge)
1811        axis.home(&mut view, HomingMethod::LimitSwitchPosPnp);
1812
1813        // Progress through initial steps
1814        axis.tick(&mut view, &mut client); // step 0 → 1
1815        view.status_word = 0x1027; // ack
1816        axis.tick(&mut view, &mut client); // step 1 → 2
1817        view.status_word = 0x0027;
1818        axis.tick(&mut view, &mut client); // step 2 → 3
1819
1820        // Positive limit switch trips — should NOT halt (suppressed)
1821        view.positive_limit = true;
1822        view.velocity_actual = 100; // moving positive
1823        view.position_actual = 8000;
1824        axis.tick(&mut view, &mut client);
1825
1826        // Should have detected rising edge → step 4, NOT an error halt
1827        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
1828        assert!(!axis.is_error);
1829    }
1830
1831    #[test]
1832    fn soft_homing_opposite_limit_still_protects() {
1833        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1834        let mut view = MockView::new();
1835        enable_axis(&mut axis, &mut view, &mut client);
1836
1837        // Software homing on home sensor (positive direction)
1838        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
1839
1840        // Progress through initial steps
1841        axis.tick(&mut view, &mut client); // step 0 → 1
1842        view.status_word = 0x1027; // ack
1843        axis.tick(&mut view, &mut client); // step 1 → 2
1844        view.status_word = 0x0027;
1845        axis.tick(&mut view, &mut client); // step 2 → 3
1846
1847        // Negative limit switch trips while searching positive (shouldn't happen
1848        // in practice, but tests protection)
1849        view.negative_limit = true;
1850        view.velocity_actual = -100; // moving negative
1851        axis.tick(&mut view, &mut client);
1852
1853        // Should have halted with error (negative limit protects)
1854        assert!(axis.is_error);
1855        assert!(axis.error_message.contains("Negative limit switch"));
1856    }
1857
1858    #[test]
1859    fn soft_homing_sensor_already_active_rejects() {
1860        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1861        let mut view = MockView::new();
1862        enable_axis(&mut axis, &mut view, &mut client);
1863
1864        // Home sensor is already active (rising edge would never happen)
1865        view.home_sensor = true;
1866        axis.tick(&mut view, &mut client); // update prev state
1867
1868        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
1869
1870        // Should have been rejected immediately
1871        assert!(axis.is_error);
1872        assert!(axis.error_message.contains("already in trigger state"));
1873        assert_eq!(axis.op, AxisOp::Idle);
1874    }
1875
1876    #[test]
1877    fn soft_homing_negative_direction_sets_negative_target() {
1878        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1879        let mut view = MockView::new();
1880        enable_axis(&mut axis, &mut view, &mut client);
1881
1882        axis.home(&mut view, HomingMethod::HomeSensorNegPnp);
1883        axis.tick(&mut view, &mut client); // step 0
1884
1885        // Target should be negative (large negative value in counts)
1886        assert!(view.target_position < 0);
1887    }
1888
1889    #[test]
1890    fn home_integrated_method_starts_hardware_homing() {
1891        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1892        let mut view = MockView::new();
1893        enable_axis(&mut axis, &mut view, &mut client);
1894
1895        axis.home(&mut view, HomingMethod::CurrentPosition);
1896        assert!(matches!(axis.op, AxisOp::Homing(0)));
1897        assert_eq!(axis.homing_method, 37);
1898    }
1899
1900    #[test]
1901    fn home_integrated_arbitrary_code() {
1902        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
1903        let mut view = MockView::new();
1904        enable_axis(&mut axis, &mut view, &mut client);
1905
1906        axis.home(&mut view, HomingMethod::Integrated(35));
1907        assert!(matches!(axis.op, AxisOp::Homing(0)));
1908        assert_eq!(axis.homing_method, 35);
1909    }
1910
1911    #[test]
1912    fn hardware_homing_skips_speed_sdos_when_zero() {
1913        use mechutil::ipc::CommandMessage;
1914
1915        let (mut axis, mut client, resp_tx, mut write_rx) = test_axis();
1916        let mut view = MockView::new();
1917        enable_axis(&mut axis, &mut view, &mut client);
1918
1919        // Config has homing_speed = 0 and homing_accel = 0 (defaults)
1920        axis.home(&mut view, HomingMethod::Integrated(37));
1921
1922        // Step 0: writes homing method SDO
1923        axis.tick(&mut view, &mut client);
1924        assert!(matches!(axis.op, AxisOp::Homing(1)));
1925
1926        // Drain the SDO write message
1927        let _ = write_rx.try_recv();
1928
1929        // Simulate SDO ack — need to use the correct tid from the sdo write
1930        let tid = axis.homing_sdo_tid;
1931        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
1932        client.poll();
1933        axis.tick(&mut view, &mut client);
1934
1935        // Should have skipped to step 8 (set homing mode)
1936        assert!(matches!(axis.op, AxisOp::Homing(8)));
1937    }
1938
1939    #[test]
1940    fn hardware_homing_writes_speed_sdos_when_nonzero() {
1941        use mechutil::ipc::CommandMessage;
1942
1943        let (mut axis, mut client, resp_tx, mut write_rx) = soft_homing_axis();
1944        let mut view = MockView::new();
1945        enable_axis(&mut axis, &mut view, &mut client);
1946
1947        // Config has homing_speed = 10.0, homing_accel = 20.0
1948        axis.home(&mut view, HomingMethod::Integrated(37));
1949
1950        // Step 0: writes homing method SDO
1951        axis.tick(&mut view, &mut client);
1952        assert!(matches!(axis.op, AxisOp::Homing(1)));
1953        let _ = write_rx.try_recv();
1954
1955        // SDO ack for homing method
1956        let tid = axis.homing_sdo_tid;
1957        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
1958        client.poll();
1959        axis.tick(&mut view, &mut client);
1960        // Should go to step 2 (write speed SDO), not skip to 8
1961        assert!(matches!(axis.op, AxisOp::Homing(2)));
1962    }
1963
1964    #[test]
1965    fn soft_homing_edge_during_ack_step() {
1966        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
1967        let mut view = MockView::new();
1968        enable_axis(&mut axis, &mut view, &mut client);
1969
1970        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
1971        axis.tick(&mut view, &mut client); // step 0 → 1
1972
1973        // Sensor rises during step 1 (before ack)
1974        view.home_sensor = true;
1975        view.position_actual = 2000;
1976        axis.tick(&mut view, &mut client);
1977
1978        // Should jump straight to step 4 (edge detected)
1979        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
1980    }
1981
1982    #[test]
1983    fn soft_homing_applies_home_position() {
1984        // Configure home_position = 90.0° so the sensor trigger point
1985        // reads as 90° instead of 0°.
1986        let mut cfg = soft_homing_config();
1987        cfg.home_position = 90.0;
1988
1989        use tokio::sync::mpsc;
1990        let (write_tx, write_rx) = mpsc::unbounded_channel();
1991        let (response_tx, response_rx) = mpsc::unbounded_channel();
1992        let mut client = CommandClient::new(write_tx, response_rx);
1993        let mut axis = Axis::new(cfg, "TestDrive");
1994        let _ = (response_tx, write_rx);
1995
1996        let mut view = MockView::new();
1997        enable_axis(&mut axis, &mut view, &mut client);
1998
1999        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2000
2001        // Progress through steps 0-3
2002        axis.tick(&mut view, &mut client); // step 0 → 1
2003        view.status_word = 0x1027; // ack
2004        axis.tick(&mut view, &mut client); // step 1 → 2
2005        view.status_word = 0x0027;
2006        axis.tick(&mut view, &mut client); // step 2 → 3
2007
2008        // Trigger edge at position 5000 counts
2009        view.home_sensor = true;
2010        view.position_actual = 5000;
2011        axis.tick(&mut view, &mut client); // step 3 → 4
2012
2013        // Step 4: halt + capture
2014        axis.tick(&mut view, &mut client); // step 4 → 5
2015
2016        // home_offset should be position_actual - to_counts(90.0)
2017        // = 5000 - 3200 = 1800
2018        assert_eq!(axis.home_offset, 1800);
2019
2020        // Complete halt
2021        view.status_word = 0x0427;
2022        axis.tick(&mut view, &mut client);
2023        assert_eq!(axis.op, AxisOp::Idle);
2024
2025        // Position at the trigger point should read as 90°
2026        // position = to_user(5000 - 1800) = to_user(3200) = 90.0°
2027        assert!((axis.position - 90.0).abs() < 0.1);
2028    }
2029
2030    #[test]
2031    fn soft_homing_default_home_position_zero() {
2032        // With default home_position = 0, sensor trigger reads as 0°
2033        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2034        let mut view = MockView::new();
2035        enable_axis(&mut axis, &mut view, &mut client);
2036
2037        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2038
2039        // Progress through steps 0-3
2040        axis.tick(&mut view, &mut client);
2041        view.status_word = 0x1027;
2042        axis.tick(&mut view, &mut client);
2043        view.status_word = 0x0027;
2044        axis.tick(&mut view, &mut client);
2045
2046        // Trigger edge at position 5000 counts
2047        view.home_sensor = true;
2048        view.position_actual = 5000;
2049        axis.tick(&mut view, &mut client); // edge detected
2050
2051        // Step 4: halt + capture
2052        axis.tick(&mut view, &mut client);
2053
2054        // home_offset should equal position_actual (5000 - 0 = 5000)
2055        assert_eq!(axis.home_offset, 5000);
2056
2057        // Complete halt
2058        view.status_word = 0x0427;
2059        axis.tick(&mut view, &mut client);
2060
2061        // Position should be 0° at the trigger point
2062        assert!((axis.position - 0.0).abs() < 0.01);
2063    }
2064}