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