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