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