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;
27use strum_macros::FromRepr;
28
29use crate::command_client::CommandClient;
30use crate::ethercat::{SdoClient, SdoResult};
31use crate::fb::Ton;
32use crate::motion::FbSetModeOfOperation;
33use super::axis_config::AxisConfig;
34use super::axis_view::AxisView;
35use super::homing::HomingMethod;
36use super::cia402::{
37    Cia402Control, Cia402Status, Cia402State,
38    ModesOfOperation, RawControlWord, RawStatusWord,
39};
40
41// ──────────────────────────────────────────────
42// Internal state machine
43// ──────────────────────────────────────────────
44
45#[derive(Debug, Clone, PartialEq)]
46enum AxisOp {
47    Idle,
48    Enabling(u8),
49    Disabling(u8),
50    Moving(MoveKind, u8, bool, bool),
51    Homing(u8),
52    SoftHoming(u8),
53    Halting(u8),
54    FaultRecovery(u8),
55}
56
57/// Sub-steps of `AxisOp::Halting`. Mirrors the multi-stage close-out used
58/// by soft-homing: wait for motion to stop, issue cancel_move, wait for
59/// setpoint_ack, clear new_setpoint, wait for setpoint_ack to drop.
60/// Without this sequence the PP handshake is left half-finished and the
61/// next `move_absolute` gets rejected with "set-point not acknowledged."
62#[repr(u8)]
63#[derive(Debug, Clone, PartialEq, FromRepr)]
64enum HaltState {
65    /// Halt bit set + new_setpoint cleared. Polling position for
66    /// stability before issuing cancel_move.
67    WaitStopped        = 0,
68    /// cancel_move issued. Waiting for SW bit 12 (setpoint_ack) AND
69    /// bit 10 (target_reached) — drive has accepted the cancel.
70    WaitCancelAck      = 10,
71    /// Ack received, new_setpoint cleared, single-setpoint flush bit
72    /// asserted. Waiting for SW bit 12 to drop so the next move's
73    /// rising edge is clean.
74    WaitCancelAckClear = 20,
75}
76
77/// How long each halt sub-stage may take before we error out.
78const HALT_STAGE_TIMEOUT: Duration = Duration::from_secs(3);
79
80/// Raw-encoder-count window within which the axis is considered "stopped."
81/// Sized to tolerate servo micro-oscillation during closed-loop hold.
82/// On a 10 000 cnt/mm drive this is 0.005 mm — below any meaningful
83/// motion but well above typical ±5 count hold jitter.
84const HALT_STABLE_WINDOW: i32 = 50;
85
86/// Velocity magnitude (in raw drive units, typically counts/s) at or
87/// below which the axis is considered "not moving" for the purposes of
88/// completing a halt. Used alongside position stability so we don't
89/// require *both* perfect position and zero velocity — either reliable
90/// indicator counts.
91const HALT_STOPPED_VELOCITY: i32 = 100;
92
93/// Consecutive ticks of stability required before issuing cancel_move.
94/// At a 10 ms scan period, 5 ticks = ~50 ms dwell — long enough for the
95/// drive to have actually settled, short enough not to stall the cycle.
96const HALT_STABLE_TICKS_REQUIRED: u8 = 5;
97
98#[repr(u8)]
99#[derive(Debug, Clone, PartialEq, FromRepr)]
100enum HomeState {
101    EnsurePpMode = 0,
102    WaitPpMode = 1,
103    Search = 5,
104    WaitSearching = 10,
105    WaitFoundSensor = 20,
106    WaitStoppedFoundSensor = 30,
107    WaitFoundSensorAck = 40,
108    WaitFoundSensorAckClear = 45,
109    DebounceFoundSensor = 50,
110    BackOff = 60,
111    WaitBackingOff = 70,
112    WaitLostSensor = 80,
113    WaitStoppedLostSensor = 90,
114    WaitLostSensorAck = 100,
115    WaitLostSensorAckClear = 120,
116    WaitHomeOffsetDone = 125,
117
118    WriteHomingModeOp = 160,
119    WaitWriteHomingModeOp = 165,
120    
121    WriteHomingMethod = 205,
122    WaitWriteHomingMethodDone = 210,
123    ClearHomingTrigger = 215,
124    TriggerHoming = 217,
125    WaitHomingStarted = 218,
126    WaitHomingDone = 220,
127    ResetHomingTrigger = 222,
128    WaitHomingTriggerCleared = 223,
129    WriteMotionModeOfOperation = 230,
130    WaitWriteMotionModeOfOperation = 235,
131    SendCurrentPositionTarget = 240,
132    WaitCurrentPositionTargetSent = 245
133
134}
135
136#[derive(Debug, Clone, PartialEq)]
137enum MoveKind {
138    Absolute,
139    Relative,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq)]
143enum SoftHomeSensor {
144    PositiveLimit,
145    NegativeLimit,
146    HomeSensor,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq)]
150enum SoftHomeSensorType {
151    /// PNP: sensor reads true when object detected (normally open)
152    Pnp,
153    /// NPN: sensor reads false when object detected (normally closed)
154    Npn,
155}
156
157// ──────────────────────────────────────────────
158// Axis
159// ──────────────────────────────────────────────
160
161/// Stateful motion controller for a CiA 402 servo drive.
162///
163/// Manages the CiA 402 protocol (state machine, PP handshake, homing)
164/// internally. Call [`tick()`](Self::tick) every control cycle to progress
165/// operations and update output fields.
166pub struct Axis {
167    config: AxisConfig,
168    sdo: SdoClient,
169
170    // ── Internal state ──
171    op: AxisOp,
172    home_offset: i32,
173    last_raw_position: i32,
174    op_started: Option<Instant>,
175    op_timeout: Duration,
176    homing_timeout: Duration,
177    move_start_timeout: Duration,
178    pending_move_target: i32,
179    pending_move_vel: u32,
180    pending_move_accel: u32,
181    pending_move_decel: u32,
182    homing_method: i8,
183    homing_sdo_tid: u32,
184    soft_home_sensor: SoftHomeSensor,
185    soft_home_sensor_type: SoftHomeSensorType,
186    soft_home_direction: f64,
187    halt_stable_count: u8,
188    prev_positive_limit: bool,
189    prev_negative_limit: bool,
190    prev_home_sensor: bool,
191
192
193
194    fb_mode_of_operation : FbSetModeOfOperation,
195
196    // ── Outputs (updated every tick) ──
197
198    /// True if a drive fault or operation timeout has occurred.
199    pub is_error: bool,
200    /// Drive error code (from status word or view error_code).
201    pub error_code: u32,
202    /// Human-readable error description.
203    pub error_message: String,
204    /// True when the drive is in Operation Enabled state.
205    pub motor_on: bool,
206    /// True when any operation is in progress (enable, move, home, fault recovery, etc.).
207    ///
208    /// Derived from the internal state machine — immediately true when a command
209    /// is issued, false when the operation completes or a fault cancels it.
210    /// Use this (not [`in_motion`](Self::in_motion)) to wait for operations to finish.
211    pub is_busy: bool,
212    /// True while a move operation specifically is active (subset of [`is_busy`](Self::is_busy)).
213    pub in_motion: bool,
214    /// True when velocity is positive.
215    pub moving_positive: bool,
216    /// True when velocity is negative.
217    pub moving_negative: bool,
218    /// Current position in user units (relative to home).
219    pub position: f64,
220    /// Current position in raw encoder counts (widened from i32).
221    pub raw_position: i64,
222    /// Current speed in user units/s (absolute value).
223    pub speed: f64,
224    /// True when position is at or beyond the maximum software limit.
225    pub at_max_limit: bool,
226    /// True when position is at or beyond the minimum software limit.
227    pub at_min_limit: bool,
228    /// True when the positive-direction hardware limit switch is active.
229    pub at_positive_limit_switch: bool,
230    /// True when the negative-direction hardware limit switch is active.
231    pub at_negative_limit_switch: bool,
232    /// True when the home reference sensor is active.
233    pub home_sensor: bool,
234
235
236    /// Timer used for delays between states.
237    ton : Ton
238}
239
240impl Axis {
241    /// Create a new Axis with the given configuration.
242    ///
243    /// `device_name` must match the device name in `project.json`
244    /// (used for SDO operations during homing).
245    pub fn new(config: AxisConfig, device_name: &str) -> Self {
246        let op_timeout = Duration::from_secs_f64(config.operation_timeout_secs);
247        let homing_timeout = Duration::from_secs_f64(config.homing_timeout_secs);
248        let move_start_timeout = op_timeout; // reuse operation timeout for move handshake
249        Self {
250            config,
251            sdo: SdoClient::new(device_name),
252            op: AxisOp::Idle,
253            home_offset: 0,
254            last_raw_position: 0,
255            op_started: None,
256            op_timeout,
257            homing_timeout,
258            move_start_timeout,
259            pending_move_target: 0,
260            pending_move_vel: 0,
261            pending_move_accel: 0,
262            pending_move_decel: 0,
263            homing_method: 37,
264            homing_sdo_tid: 0,
265            soft_home_sensor: SoftHomeSensor::HomeSensor,
266            soft_home_sensor_type: SoftHomeSensorType::Pnp,
267            soft_home_direction: 1.0,
268            halt_stable_count: 0,
269            prev_positive_limit: false,
270            prev_negative_limit: false,
271            prev_home_sensor: false,
272            is_error: false,
273            error_code: 0,
274            error_message: String::new(),
275            motor_on: false,
276            is_busy: false,
277            in_motion: false,
278            moving_positive: false,
279            moving_negative: false,
280            position: 0.0,
281            raw_position: 0,
282            speed: 0.0,
283            at_max_limit: false,
284            at_min_limit: false,
285            at_positive_limit_switch: false,
286            at_negative_limit_switch: false,
287            home_sensor: false,
288            ton: Ton::new(),
289            fb_mode_of_operation : FbSetModeOfOperation::new()
290        }
291    }
292
293    /// Get a reference to the axis configuration.
294    pub fn config(&self) -> &AxisConfig {
295        &self.config
296    }
297
298    // ═══════════════════════════════════════════
299    // Motion commands
300    // ═══════════════════════════════════════════
301
302    /// Start an absolute move to `target` in user units.
303    ///
304    /// The axis must be enabled (Operation Enabled) before calling this.
305    /// If the target exceeds a software position limit, the move is rejected
306    /// and [`is_error`](Self::is_error) is set.
307    pub fn move_absolute(
308        &mut self,
309        view: &mut impl AxisView,
310        target: f64,
311        vel: f64,
312        accel: f64,
313        decel: f64,
314    ) {
315        if let Some(msg) = self.check_target_limit(target, view) {
316            self.set_op_error(&msg);
317            return;
318        }
319
320        let cpu = self.config.counts_per_user();
321        let raw_target = self.config.to_counts(target).round() as i32 + self.home_offset;
322        let raw_vel = (vel * cpu).round() as u32;
323        let raw_accel = (accel * cpu).round() as u32;
324        let raw_decel = (decel * cpu).round() as u32;
325
326        // User-frame direction (consistent with sw_max/sw_min and hw_pos/hw_neg).
327        let user_pos = target > self.position;
328        let user_neg = target < self.position;
329
330        self.start_move(view, raw_target, raw_vel, raw_accel, raw_decel, MoveKind::Absolute, user_pos, user_neg);
331    }
332
333    /// Start a relative move by `distance` user units from the current position.
334    ///
335    /// The axis must be enabled (Operation Enabled) before calling this.
336    /// If the resulting position would exceed a software position limit,
337    /// the move is rejected and [`is_error`](Self::is_error) is set.
338    pub fn move_relative(
339        &mut self,
340        view: &mut impl AxisView,
341        distance: f64,
342        vel: f64,
343        accel: f64,
344        decel: f64,
345    ) {
346        log::info!("Axis: request to move relative dist {} vel {} accel {} decel {}",
347            distance, vel, accel, decel
348        );
349
350        if let Some(msg) = self.check_target_limit(self.position + distance, view) {
351            self.set_op_error(&msg);
352            return;
353        }
354
355        let cpu = self.config.counts_per_user();
356        let raw_distance = self.config.to_counts(distance).round() as i32;
357        let raw_vel = (vel * cpu).round() as u32;
358        let raw_accel = (accel * cpu).round() as u32;
359        let raw_decel = (decel * cpu).round() as u32;
360
361        log::info!("Axis starting relative move: request to move relative raw dist {} raw vel {} raw accel {} raw decel {}",
362            raw_distance, raw_vel, raw_accel, raw_decel
363        );
364
365        // Make sure bit 4 is off so that a new move can be commanded.
366        let mut cw = RawControlWord(view.control_word());
367        cw.set_bit(4, false); // new set-point
368        view.set_control_word(cw.raw());
369
370        // User-frame direction is the sign of `distance` (which is in user units).
371        let user_pos = distance > 0.0;
372        let user_neg = distance < 0.0;
373
374        self.start_move(view, raw_distance, raw_vel, raw_accel, raw_decel, MoveKind::Relative, user_pos, user_neg);
375    }
376
377    fn start_move(
378        &mut self,
379        view: &mut impl AxisView,
380        raw_target: i32,
381        raw_vel: u32,
382        raw_accel: u32,
383        raw_decel: u32,
384        kind: MoveKind,
385        user_pos: bool,
386        user_neg: bool,
387    ) {
388        self.pending_move_target = raw_target;
389        self.pending_move_vel = raw_vel;
390        self.pending_move_accel = raw_accel;
391        self.pending_move_decel = raw_decel;
392
393        // Set parameters on view
394        view.set_target_position(raw_target);
395        view.set_profile_velocity(raw_vel);
396        view.set_profile_acceleration(raw_accel);
397        view.set_profile_deceleration(raw_decel);
398
399        // Set control word: relative bit + trigger (new set-point).
400        // We also clear halt as belt-and-suspenders — limit-switch halts and
401        // explicit halt() both flow through the Halting state machine, which
402        // ends with halt already cleared, so this is normally a no-op.
403        let mut cw = RawControlWord(view.control_word());
404        cw.set_bit(6, kind == MoveKind::Relative);
405        cw.set_bit(8, false); // clear halt
406        cw.set_bit(4, true);  // new set-point
407        view.set_control_word(cw.raw());
408
409        // pos/neg stored in AxisOp::Moving are USER-frame so that check_limits
410        // can compare them directly against user-frame software limits and
411        // the integrator's user-frame limit-switch wiring without an
412        // axis-inversion swap.
413        self.op = AxisOp::Moving(kind, 1, user_pos, user_neg);
414        self.op_started = Some(Instant::now());
415    }
416
417    /// Halt the current move (decelerate to stop).
418    ///
419    /// This is a **multi-tick** operation. `halt()` starts the sequence:
420    ///
421    /// 1. Halt bit (CW 8) set, new_setpoint (CW 4) cleared.
422    /// 2. Wait for motor position to stabilize for ~100 ms.
423    /// 3. Issue cancel_move with current_position as target.
424    /// 4. Wait for setpoint_ack (SW 12) + target_reached (SW 10).
425    /// 5. Clear new_setpoint, set single_setpoint (CW 5).
426    /// 6. Wait for setpoint_ack to drop.
427    /// 7. Return to Idle.
428    ///
429    /// [`is_busy`](Self::is_busy) stays `true` for the whole sequence.
430    /// Callers that wait on `!is_busy()` after `halt()` (e.g.
431    /// [`super::move_to_load::MoveToLoad`]) will correctly block until
432    /// the drive's PP handshake is fully cleaned up, preventing a
433    /// "set-point not acknowledged" timeout on the *next* move.
434    pub fn halt(&mut self, view: &mut impl AxisView) {
435        self.command_halt(view);
436        self.halt_stable_count = 0;
437        self.last_raw_position = view.position_actual();
438        self.op_started = Some(Instant::now());
439        self.op = AxisOp::Halting(HaltState::WaitStopped as u8);
440    }
441
442    // ═══════════════════════════════════════════
443    // Drive control
444    // ═══════════════════════════════════════════
445
446    /// Start the enable sequence (Shutdown → ReadyToSwitchOn → OperationEnabled).
447    ///
448    /// The sequence is multi-tick. Check [`motor_on`](Self::motor_on) for completion.
449    pub fn enable(&mut self, view: &mut impl AxisView) {
450        // Step 0: set PP mode + cmd_shutdown
451        view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
452        let mut cw = RawControlWord(view.control_word());
453        cw.cmd_shutdown();
454        view.set_control_word(cw.raw());
455
456        self.op = AxisOp::Enabling(1);
457        self.op_started = Some(Instant::now());
458    }
459
460    /// Start the disable sequence (OperationEnabled → SwitchedOn).
461    pub fn disable(&mut self, view: &mut impl AxisView) {
462        let mut cw = RawControlWord(view.control_word());
463        cw.cmd_disable_operation();
464        cw.set_bit(4, false);
465        cw.set_bit(8, false);
466        cw.set_bit(7, false);
467        cw.set_bit(2, true);
468        view.set_control_word(cw.raw());
469
470        self.op = AxisOp::Disabling(1);
471        self.op_started = Some(Instant::now());
472    }
473
474    /// Clear the axis error state.
475    ///
476    /// Two paths depending on whether the drive itself is faulted:
477    ///
478    /// - **Drive in CiA 402 Fault / FaultReactionActive**: runs the full
479    ///   reset sequence — clear bit 7, then assert it next tick (rising
480    ///   edge on the drive's fault-reset bit), then wait for the drive to
481    ///   leave the Fault state. `is_busy` stays true through this sequence.
482    ///
483    /// - **Drive healthy** (e.g. the `is_error` was set by a software
484    ///   software-limit overshoot, an operation timeout, or a limit-switch
485    ///   close-out timeout — the drive itself is still in OperationEnabled):
486    ///   no CiA 402 handshake is needed. Just clears the software error
487    ///   flags and returns the axis to Idle synchronously. `is_busy` goes
488    ///   to false on the same call.
489    pub fn reset_faults(&mut self, view: &mut impl AxisView) {
490        let sw = RawStatusWord(view.status_word());
491        let drive_in_fault = matches!(
492            sw.state(),
493            Cia402State::Fault | Cia402State::FaultReactionActive
494        );
495
496        self.is_error = false;
497        self.error_code = 0;
498        self.error_message.clear();
499
500        if drive_in_fault {
501            // Step 0: clear bit 7 first (so next step produces a rising edge).
502            let mut cw = RawControlWord(view.control_word());
503            cw.cmd_clear_fault_reset();
504            view.set_control_word(cw.raw());
505            self.op = AxisOp::FaultRecovery(1);
506            self.op_started = Some(Instant::now());
507        } else {
508            // Software-only error — no drive handshake needed.
509            self.op = AxisOp::Idle;
510            self.op_started = None;
511            self.is_busy = false;
512            self.in_motion = false;
513        }
514    }
515
516    /// Start a homing sequence with the given homing method.
517    ///
518    /// **Integrated** methods delegate to the drive's built-in CiA 402
519    /// homing mode (SDO writes + homing trigger).
520    ///
521    /// **Software** methods are implemented by the Axis, which monitors
522    /// [`AxisView`] sensor signals for edge triggers and captures home.
523    pub fn home(&mut self, view: &mut impl AxisView, method: HomingMethod) {
524        if method.is_integrated() {
525            self.homing_method = match method {
526                HomingMethod::CurrentPosition => self.config.soft_home_method,
527                _ => method.cia402_code(),
528            };
529            self.op = AxisOp::Homing(0);
530            self.op_started = Some(Instant::now());
531            let _ = view;
532        } else {
533            self.configure_soft_homing(method);
534            self.start_soft_homing(view);
535        }
536    }
537
538    // ═══════════════════════════════════════════
539    // Position management
540    // ═══════════════════════════════════════════
541
542    /// Set the current position to the given user-unit value.
543    ///
544    /// Adjusts the internal home offset so that the current raw position
545    /// maps to `user_units`. Does not move the motor.
546    pub fn set_position(&mut self, view: &impl AxisView, user_units: f64) {
547        self.home_offset = view.position_actual() - self.config.to_counts(user_units).round() as i32;
548    }
549
550    /// Set the current position to `user_units` and persist it to the drive.
551    ///
552    /// Writes `0x607C` (home offset) to the drive in encoder counts, then runs
553    /// a CiA 402 homing cycle with method [`AxisConfig::soft_home_method`] so
554    /// the drive's reported position (`0x6064`) reads `user_units` afterward.
555    /// No motor motion occurs.
556    ///
557    /// For drives with absolute encoders configured in linear mode (e.g.
558    /// Inovance SV660N with `0x2002:02 = 1`), the resulting encoder offset
559    /// (`0x2005:2F`/`0x2005:31`) is auto-saved to EEPROM, so the new reference
560    /// survives a power cycle.
561    ///
562    /// **Preconditions:** drive must be enabled (OperationEnabled) and at rest
563    /// (no move in progress). The operation switches to HM mode internally and
564    /// back to PP on completion. Poll [`is_busy`](Self::is_busy) to know when
565    /// it finishes.
566    pub fn set_position_persistent(
567        &mut self,
568        client: &mut CommandClient,
569        user_units: f64,
570    ) {
571        // Reuse the SoftHoming state machine starting from the post-607C-write
572        // step (WaitHomeOffsetDone). We issue the 607C SDO write here; the
573        // state machine handles the rest: wait for ack, switch to HM, write
574        // 6098 = soft_home_method, trigger homing, wait for completion, switch
575        // back to PP, and zero the software home_offset since the drive owns
576        // it now.
577        self.config.home_position = user_units;
578        let desired_counts = self.config.to_counts(user_units).round() as i32;
579        self.homing_sdo_tid = self.sdo.write(client, 0x607C, 0, json!(desired_counts));
580        self.op = AxisOp::SoftHoming(HomeState::WaitHomeOffsetDone as u8);
581        self.op_started = Some(Instant::now());
582        log::info!(
583            "set_position_persistent: writing 0x607C = {} counts ({} user units)",
584            desired_counts, user_units
585        );
586    }
587
588    /// Set the home position in user units. This value is used by the next
589    /// `home()` call to set the axis position at the reference point.
590    /// Can be called at any time before homing.
591    pub fn set_home_position(&mut self, user_units: f64) {
592        self.config.home_position = user_units;
593    }
594
595    /// Set the maximum (positive) software position limit.
596    pub fn set_software_max_limit(&mut self, user_units: f64) {
597        self.config.max_position_limit = user_units;
598        self.config.enable_max_position_limit = true;
599    }
600
601    /// Set the minimum (negative) software position limit.
602    pub fn set_software_min_limit(&mut self, user_units: f64) {
603        self.config.min_position_limit = user_units;
604        self.config.enable_min_position_limit = true;
605    }
606
607    // ═══════════════════════════════════════════
608    // SDO pass-through
609    // ═══════════════════════════════════════════
610
611    /// Write an SDO value to the drive.
612    pub fn sdo_write(
613        &mut self,
614        client: &mut CommandClient,
615        index: u16,
616        sub_index: u8,
617        value: serde_json::Value,
618    ) {
619        self.sdo.write(client, index, sub_index, value);
620    }
621
622    /// Start an SDO read from the drive. Returns a transaction ID.
623    pub fn sdo_read(
624        &mut self,
625        client: &mut CommandClient,
626        index: u16,
627        sub_index: u8,
628    ) -> u32 {
629        self.sdo.read(client, index, sub_index)
630    }
631
632    /// Check the result of a previous SDO read.
633    pub fn sdo_result(
634        &mut self,
635        client: &mut CommandClient,
636        tid: u32,
637    ) -> SdoResult {
638        self.sdo.result(client, tid, Duration::from_secs(5))
639    }
640
641    // ═══════════════════════════════════════════
642    // Tick — call every control cycle
643    // ═══════════════════════════════════════════
644
645    /// Update outputs and progress the current operation.
646    ///
647    /// Must be called every control cycle. Does three things:
648    /// 1. Checks for drive faults
649    /// 2. Progresses the current multi-tick operation
650    /// 3. Updates output fields (position, velocity, status)
651    ///
652    /// Outputs are updated last so they reflect the final state after
653    /// all processing for this tick.
654    pub fn tick(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
655        self.check_faults(view);
656        self.progress_op(view, client);
657        self.update_outputs(view);
658        self.check_limits(view);
659    }
660
661    // ═══════════════════════════════════════════
662    // Internal: output update
663    // ═══════════════════════════════════════════
664
665    fn update_outputs(&mut self, view: &impl AxisView) {
666        let raw = view.position_actual();
667        self.raw_position = raw as i64;
668        self.position = self.config.to_user((raw - self.home_offset) as f64);
669
670        let vel = view.velocity_actual();
671        let user_vel = self.config.to_user(vel as f64);
672        self.speed = user_vel.abs();
673        self.moving_positive = user_vel > 0.0;
674        self.moving_negative = user_vel < 0.0;
675        self.is_busy = self.op != AxisOp::Idle;
676        self.in_motion = matches!(self.op, AxisOp::Moving(_, _, _, _) | AxisOp::SoftHoming(_));
677
678        let sw = RawStatusWord(view.status_word());
679        self.motor_on = sw.state() == Cia402State::OperationEnabled;
680
681        self.last_raw_position = raw;
682    }
683
684    // ═══════════════════════════════════════════
685    // Internal: fault check
686    // ═══════════════════════════════════════════
687
688    fn check_faults(&mut self, view: &impl AxisView) {
689        let sw = RawStatusWord(view.status_word());
690        let state = sw.state();
691
692        if matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
693            if !matches!(self.op, AxisOp::FaultRecovery(_)) {
694                self.is_error = true;
695                let ec = view.error_code();
696                if ec != 0 {
697                    self.error_code = ec as u32;
698                }
699                self.error_message = format!("Drive fault (state: {})", state);
700                // Cancel the current operation so is_busy goes false
701                self.op = AxisOp::Idle;
702                self.op_started = None;
703            }
704        }
705    }
706
707    // ═══════════════════════════════════════════
708    // Internal: operation timeout helper
709    // ═══════════════════════════════════════════
710
711    fn op_timed_out(&self) -> bool {
712        self.op_started
713            .map_or(false, |t| t.elapsed() > self.op_timeout)
714    }
715
716    fn homing_timed_out(&self) -> bool {
717        self.op_started
718            .map_or(false, |t| t.elapsed() > self.homing_timeout)
719    }
720
721    fn move_start_timed_out(&self) -> bool {
722        self.op_started
723            .map_or(false, |t| t.elapsed() > self.move_start_timeout)
724    }
725
726    /// Has the current operation exceeded the supplied stage timeout?
727    /// Used by the halt state machine so each sub-stage gets its own
728    /// budget rather than sharing the general `op_timeout`.
729    fn op_stage_timed_out(&self, limit: Duration) -> bool {
730        self.op_started
731            .map_or(false, |t| t.elapsed() > limit)
732    }
733
734    fn set_op_error(&mut self, msg: &str) {
735        self.is_error = true;
736        self.error_message = msg.to_string();
737        self.op = AxisOp::Idle;
738        self.op_started = None;
739        self.is_busy = false;
740        self.in_motion = false;
741        log::error!("Axis error: {}", msg);
742    }
743
744    fn restore_pp_after_error(&mut self, msg: &str) {
745        self.is_error = true;
746        self.error_message = msg.to_string();
747        self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);;
748        log::error!("Axis error: {}", msg);
749    }
750
751    fn finish_op_error(&mut self) {
752        self.op = AxisOp::Idle;
753        self.op_started = None;
754        self.is_busy = false;
755        self.in_motion = false;
756    }
757
758    fn complete_op(&mut self) {
759        self.op = AxisOp::Idle;
760        self.op_started = None;
761        // Clear busy/motion flags directly so callers that observe state on
762        // the same tick (e.g. check_limits, which runs after update_outputs)
763        // see Idle immediately. For complete_op calls inside progress_op,
764        // update_outputs would recompute these to the same values anyway.
765        self.is_busy = false;
766        self.in_motion = false;
767    }
768
769    // ═══════════════════════════════════════════
770    // Internal: position limits and limit switches
771    // ═══════════════════════════════════════════
772
773    /// Resolve the effective maximum software limit for this tick, combining
774    /// the static [`AxisConfig`] value (if enabled) with any dynamic limit
775    /// supplied by the [`AxisView`] (e.g. a GM-linked variable). The most
776    /// restrictive (smallest) value wins.
777    fn effective_max_limit(&self, view: &impl AxisView) -> Option<f64> {
778        let static_limit = if self.config.enable_max_position_limit {
779            Some(self.config.max_position_limit)
780        } else {
781            None
782        };
783        match (static_limit, view.dynamic_max_position_limit()) {
784            (Some(s), Some(d)) => Some(s.min(d)),
785            (Some(v), None) | (None, Some(v)) => Some(v),
786            (None, None) => None,
787        }
788    }
789
790    /// Resolve the effective minimum software limit for this tick. See
791    /// [`effective_max_limit`](Self::effective_max_limit) — the most
792    /// restrictive (largest) value wins.
793    fn effective_min_limit(&self, view: &impl AxisView) -> Option<f64> {
794        let static_limit = if self.config.enable_min_position_limit {
795            Some(self.config.min_position_limit)
796        } else {
797            None
798        };
799        match (static_limit, view.dynamic_min_position_limit()) {
800            (Some(s), Some(d)) => Some(s.max(d)),
801            (Some(v), None) | (None, Some(v)) => Some(v),
802            (None, None) => None,
803        }
804    }
805
806    /// Check whether a target position (in user units) exceeds a software limit.
807    /// Returns `Some(error_message)` if the target is out of range, `None` if OK.
808    /// Consults both the static [`AxisConfig`] limits and any dynamic limits
809    /// exposed by the view, taking whichever is most restrictive.
810    fn check_target_limit(&self, target: f64, view: &impl AxisView) -> Option<String> {
811        if let Some(max) = self.effective_max_limit(view) {
812            if target > max {
813                return Some(format!(
814                    "Target {:.3} exceeds max software limit {:.3}",
815                    target, max
816                ));
817            }
818        }
819        if let Some(min) = self.effective_min_limit(view) {
820            if target < min {
821                return Some(format!(
822                    "Target {:.3} exceeds min software limit {:.3}",
823                    target, min
824                ));
825            }
826        }
827        None
828    }
829
830    /// Check software position limits, hardware limit switches, and home sensor.
831    /// If a limit is violated and a move is in progress in that direction,
832    /// halt the drive and set an error. Moving in the opposite direction is
833    /// always allowed so the axis can be recovered.
834    ///
835    /// During software homing on a limit switch (`SoftHoming` + `SoftHomeSensor::PositiveLimit`
836    /// or `NegativeLimit`), the homed-on switch is suppressed so it triggers a home
837    /// event rather than an error halt. The opposite switch still protects.
838    fn check_limits(&mut self, view: &mut impl AxisView) {
839        // ── Software position limits (static config + dynamic GM-linked) ──
840        let eff_max = self.effective_max_limit(view);
841        let eff_min = self.effective_min_limit(view);
842        let sw_max = eff_max.map_or(false, |m| self.position >= m);
843        let sw_min = eff_min.map_or(false, |m| self.position <= m);
844
845        self.at_max_limit = sw_max;
846        self.at_min_limit = sw_min;
847
848        // ── Hardware limit switches ──
849        let hw_pos = view.positive_limit_active();
850        let hw_neg = view.negative_limit_active();
851
852        self.at_positive_limit_switch = hw_pos;
853        self.at_negative_limit_switch = hw_neg;
854
855        // ── Home sensor ──
856        self.home_sensor = view.home_sensor_active();
857
858        // ── Save previous sensor state for next tick's edge detection ──
859        self.prev_positive_limit = hw_pos;
860        self.prev_negative_limit = hw_neg;
861        self.prev_home_sensor = view.home_sensor_active();
862
863        // ── Halt logic (only while moving or soft-homing) ──
864        let mut commanded_positive = false;
865        let mut commanded_negative = false;
866
867        let is_moving = matches!(self.op, AxisOp::Moving(_, _, _, _));
868        let is_soft_homing = matches!(self.op, AxisOp::SoftHoming(_));
869
870        if !is_moving && !is_soft_homing {
871            return; // Only halt actively if we are currently driving into the limit
872        }
873
874        match &self.op {
875            AxisOp::Moving(_, _, pos, neg) => {
876                // Already user-frame (set by move_absolute / move_relative).
877                commanded_positive = *pos;
878                commanded_negative = *neg;
879            }
880            AxisOp::SoftHoming(_) => {
881                match self.soft_home_sensor {
882                    SoftHomeSensor::PositiveLimit => commanded_positive = true,
883                    SoftHomeSensor::NegativeLimit => commanded_negative = true,
884                    SoftHomeSensor::HomeSensor => {
885                        commanded_positive = self.moving_positive;
886                        commanded_negative = self.moving_negative;
887                    }
888                }
889            }
890            _ => {}
891        }
892
893        // During software homing, suppress the limit switch being homed on
894        let suppress_pos = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::PositiveLimit;
895        let suppress_neg = is_soft_homing && self.soft_home_sensor == SoftHomeSensor::NegativeLimit;
896
897        let effective_hw_pos = hw_pos && !suppress_pos;
898        let effective_hw_neg = hw_neg && !suppress_neg;
899
900        // During soft homing, suppress software limits too (we need to move freely)
901        let effective_sw_max = sw_max && !is_soft_homing;
902        let effective_sw_min = sw_min && !is_soft_homing;
903
904        let positive_blocked = (effective_sw_max || effective_hw_pos) && commanded_positive;
905        let negative_blocked = (effective_sw_min || effective_hw_neg) && commanded_negative;
906
907
908        if positive_blocked || negative_blocked {
909            let mut cw = RawControlWord(view.control_word());
910            cw.set_bit(8, true); // halt
911            view.set_control_word(cw.raw());
912
913            let msg = if effective_hw_pos && commanded_positive {
914                "Positive limit switch active".to_string()
915            } else if effective_hw_neg && commanded_negative {
916                "Negative limit switch active".to_string()
917            } else if effective_sw_max && commanded_positive {
918                format!(
919                    "Software position limit: position {:.3} >= max {:.3}",
920                    self.position, eff_max.unwrap_or(self.position)
921                )
922            } else {
923                format!(
924                    "Software position limit: position {:.3} <= min {:.3}",
925                    self.position, eff_min.unwrap_or(self.position)
926                )
927            };
928
929            if is_soft_homing {
930                // Hitting the *opposite* limit during soft homing is a real
931                // fault — the axis went the wrong way and must be reset.
932                self.set_op_error(&msg);
933            } else {
934                // Regular move into a limit: enter the multi-stage Halting
935                // close-out (wait for motor stop → cancel queued setpoint →
936                // clear halt) so the drive ends in a clean state. Just
937                // clearing halt isn't enough — the previous move's target is
938                // still queued in the drive's PP buffer, and on the next
939                // start_move some drives ignore the new setpoint until that
940                // queued one is canceled.
941                //
942                // is_busy stays true through the close-out. Callers detect
943                // why the move ended by inspecting
944                // `at_positive_limit_switch` / `at_negative_limit_switch` /
945                // `at_max_limit` / `at_min_limit` once `!is_busy()`.
946                log::info!("Axis move halted by limit: {}", msg);
947                self.command_halt(view);
948                self.halt_stable_count = 0;
949                self.last_raw_position = view.position_actual();
950                self.op_started = Some(Instant::now());
951                self.op = AxisOp::Halting(HaltState::WaitStopped as u8);
952            }
953        }
954    }
955
956    // ═══════════════════════════════════════════
957    // Internal: operation progress
958    // ═══════════════════════════════════════════
959
960    fn progress_op(&mut self, view: &mut impl AxisView, client: &mut CommandClient) {
961        match self.op.clone() {
962            AxisOp::Idle => {}
963            AxisOp::Enabling(step) => self.tick_enabling(view, step),
964            AxisOp::Disabling(step) => self.tick_disabling(view, step),
965            AxisOp::Moving(kind, step, pos, neg) => self.tick_moving(view, kind, step, pos, neg),
966            AxisOp::Homing(step) => self.tick_homing(view, client, step),
967            AxisOp::SoftHoming(step) => self.tick_soft_homing(view, client, step),
968            AxisOp::Halting(step) => self.tick_halting(view, step),
969            AxisOp::FaultRecovery(step) => self.tick_fault_recovery(view, step),
970        }
971    }
972
973    // ── Enabling ──
974    // Step 0: (done in enable()) ensure PP + cmd_shutdown
975    // Step 1: wait ReadyToSwitchOn → cmd_enable_operation
976    // Step 2: wait OperationEnabled → capture home → Idle
977    fn tick_enabling(&mut self, view: &mut impl AxisView, step: u8) {
978        match step {
979            1 => {
980                let sw = RawStatusWord(view.status_word());
981                if sw.state() == Cia402State::ReadyToSwitchOn {
982                    let mut cw = RawControlWord(view.control_word());
983                    cw.cmd_enable_operation();
984                    view.set_control_word(cw.raw());
985                    self.op = AxisOp::Enabling(2);
986                } else if self.op_timed_out() {
987                    self.set_op_error("Enable timeout: waiting for ReadyToSwitchOn");
988                }
989            }
990            2 => {
991                let sw = RawStatusWord(view.status_word());
992                if sw.state() == Cia402State::OperationEnabled {
993                    // NO - We do not do software-based encoder. That would break absolute encoders.
994                    // self.home_offset = view.position_actual();
995                    // log::info!("Axis enabled — home captured at {}", self.home_offset);
996
997                    // Possible TODO: Read the home_offset in the drive? 
998
999                    self.complete_op();
1000                } else if self.op_timed_out() {
1001                    self.set_op_error("Enable timeout: waiting for OperationEnabled");
1002                }
1003            }
1004            _ => self.complete_op(),
1005        }
1006    }
1007
1008    // ── Disabling ──
1009    // Step 0: (done in disable()) cmd_disable_operation
1010    // Step 1: wait not OperationEnabled → Idle
1011    fn tick_disabling(&mut self, view: &mut impl AxisView, step: u8) {
1012        match step {
1013            1 => {
1014                let sw = RawStatusWord(view.status_word());
1015                if sw.state() != Cia402State::OperationEnabled {
1016                    self.complete_op();
1017                } else if self.op_timed_out() {
1018                    self.set_op_error("Disable timeout: drive still in OperationEnabled");
1019                }
1020            }
1021            _ => self.complete_op(),
1022        }
1023    }
1024
1025    // ── Moving ──
1026    // Step 0: (done in move_absolute/relative()) set params + trigger
1027    // Step 1: wait set_point_acknowledge → ack
1028    // Step 2: wait ack cleared (one tick)
1029    // Step 3: wait target_reached → Idle
1030    fn tick_moving(&mut self, view: &mut impl AxisView, kind: MoveKind, step: u8, pos: bool, neg: bool) {
1031        match step {
1032            1 => {
1033                // Wait for set-point acknowledge (bit 12)
1034                let sw = RawStatusWord(view.status_word());
1035                if sw.raw() & (1 << 12) != 0 {
1036                    // Ack: clear new set-point (bit 4)
1037                    let mut cw = RawControlWord(view.control_word());
1038                    cw.set_bit(4, false);
1039                    view.set_control_word(cw.raw());
1040                    self.op = AxisOp::Moving(kind, 2, pos, neg);
1041                } else if self.move_start_timed_out() {
1042                    self.set_op_error("Move timeout: set-point not acknowledged");
1043                }
1044            },
1045            2 => {
1046                // Wait for the drive to confirm it saw Bit 4 go low
1047                let sw = RawStatusWord(view.status_word());
1048                if sw.raw() & (1 << 12) == 0 {
1049                    // Handshake is officially reset. Now wait for physics.
1050                    self.op = AxisOp::Moving(kind, 3, pos, neg);
1051                }
1052            },
1053            3 => {
1054                // Wait for target reached (bit 10) — no timeout, moves can take arbitrarily long
1055                let sw = RawStatusWord(view.status_word());
1056                if sw.target_reached() {
1057                    self.complete_op();
1058                }
1059            },
1060            _ => self.complete_op(),
1061        }
1062    }
1063
1064    // ── Homing (hardware-delegated) ──
1065    // Step 0:  write homing method SDO (0x6098:0)
1066    // Step 1:  wait SDO ack
1067    // Step 2:  write homing speed SDO (0x6099:1 — search for switch)
1068    // Step 3:  wait SDO ack
1069    // Step 4:  write homing speed SDO (0x6099:2 — search for zero)
1070    // Step 5:  wait SDO ack
1071    // Step 6:  write homing accel SDO (0x609A:0)
1072    // Step 7:  wait SDO ack
1073    // Step 8:  switch to Homing mode (PDO field + SDO write 0x6060)
1074    // Step 9:  wait for Homing mode written + verified via SDO read-back (0x6061)
1075    // Step 10: trigger homing (rising edge on CW bit 4)
1076    // Step 11: wait for drive to acknowledge start (bit 12 clears)
1077    // Step 12: wait homing complete (bits 10+12 set, 13 clear)
1078    // Step 13: capture home offset, clear CW bit 4
1079    // Step 14: switch back to PP mode (PDO field + SDO write 0x6060)
1080    // Step 15: wait for PP mode written + verified, then → Idle
1081    //
1082    // If homing_speed and homing_accel are both 0, steps 2-7 are skipped
1083    // (preserves backward compatibility for users who pre-configure via SDO).
1084    //
1085    // Mode is switched AND verified over SDO (via FbSetModeOfOperation) rather
1086    // than trusting modes_of_operation_display(): on drives that don't PDO-map
1087    // 0x6060/0x6061 (e.g. Kollmorgen AKD) the cyclic display field is a codegen
1088    // stub mirrored from the commanded value, so it would falsely confirm.
1089    fn tick_homing(
1090        &mut self,
1091        view: &mut impl AxisView,
1092        client: &mut CommandClient,
1093        step: u8,
1094    ) {
1095        match step {
1096            0 => {
1097                // Write homing method via SDO (0x6098:0)
1098                self.homing_sdo_tid = self.sdo.write(
1099                    client,
1100                    0x6098,
1101                    0,
1102                    json!(self.homing_method),
1103                );
1104                self.op = AxisOp::Homing(1);
1105            }
1106            1 => {
1107                // Wait for SDO write ack
1108                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1109                    SdoResult::Ok(_) => {
1110                        // Skip speed/accel SDOs if both are zero
1111                        if self.config.homing_speed == 0.0 && self.config.homing_accel == 0.0 {
1112                            self.op = AxisOp::Homing(8);
1113                        } else {
1114                            self.op = AxisOp::Homing(2);
1115                        }
1116                    }
1117                    SdoResult::Pending => {
1118                        if self.homing_timed_out() {
1119                            self.set_op_error("Homing timeout: SDO write for homing method");
1120                        }
1121                    }
1122                    SdoResult::Err(e) => {
1123                        self.set_op_error(&format!("Homing SDO error: {}", e));
1124                    }
1125                    SdoResult::Timeout => {
1126                        self.set_op_error("Homing timeout: SDO write timed out");
1127                    }
1128                }
1129            }
1130            2 => {
1131                // Write homing speed (0x6099:1 — search for switch)
1132                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
1133                self.homing_sdo_tid = self.sdo.write(
1134                    client,
1135                    0x6099,
1136                    1,
1137                    json!(speed_counts),
1138                );
1139                self.op = AxisOp::Homing(3);
1140            }
1141            3 => {
1142                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1143                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(4); }
1144                    SdoResult::Pending => {
1145                        if self.homing_timed_out() {
1146                            self.set_op_error("Homing timeout: SDO write for homing speed (switch)");
1147                        }
1148                    }
1149                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
1150                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
1151                }
1152            }
1153            4 => {
1154                // Write homing speed (0x6099:2 — search for zero, same value)
1155                let speed_counts = self.config.to_counts(self.config.homing_speed).round() as u32;
1156                self.homing_sdo_tid = self.sdo.write(
1157                    client,
1158                    0x6099,
1159                    2,
1160                    json!(speed_counts),
1161                );
1162                self.op = AxisOp::Homing(5);
1163            }
1164            5 => {
1165                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1166                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(6); }
1167                    SdoResult::Pending => {
1168                        if self.homing_timed_out() {
1169                            self.set_op_error("Homing timeout: SDO write for homing speed (zero)");
1170                        }
1171                    }
1172                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
1173                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
1174                }
1175            }
1176            6 => {
1177                // Write homing acceleration (0x609A:0)
1178                let accel_counts = self.config.to_counts(self.config.homing_accel).round() as u32;
1179                self.homing_sdo_tid = self.sdo.write(
1180                    client,
1181                    0x609A,
1182                    0,
1183                    json!(accel_counts),
1184                );
1185                self.op = AxisOp::Homing(7);
1186            }
1187            7 => {
1188                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1189                    SdoResult::Ok(_) => { self.op = AxisOp::Homing(8); }
1190                    SdoResult::Pending => {
1191                        if self.homing_timed_out() {
1192                            self.set_op_error("Homing timeout: SDO write for homing acceleration");
1193                        }
1194                    }
1195                    SdoResult::Err(e) => { self.set_op_error(&format!("Homing SDO error: {}", e)); }
1196                    SdoResult::Timeout => { self.set_op_error("Homing timeout: SDO write timed out"); }
1197                }
1198            }
1199            8 => {
1200                // Switch into Homing mode on BOTH channels:
1201                //  - the cyclic PDO field, so drives that PDO-map 0x6060 carry the
1202                //    correct mode on the wire, and
1203                //  - an SDO write + read-back of 0x6060/0x6061 (FbSetModeOfOperation),
1204                //    so drives that pin mode by SDO (e.g. Kollmorgen AKD, which does
1205                //    not PDO-map mode) actually transition AND are verified.
1206                // The SDO read-back replaces the old modes_of_operation_display()
1207                // check, which silently passed on PDO-unmapped drives because codegen
1208                // mirrors the commanded value into the display stub.
1209                view.set_modes_of_operation(ModesOfOperation::Homing.as_i8());
1210                // Ensure CW bit 4 starts LOW so the next step issues a clean rising edge.
1211                let mut cw = RawControlWord(view.control_word());
1212                cw.set_bit(4, false);
1213                view.set_control_word(cw.raw());
1214                self.fb_mode_of_operation.reset();
1215                self.fb_mode_of_operation.start(ModesOfOperation::Homing as i8);
1216                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1217                self.op = AxisOp::Homing(9);
1218            }
1219            9 => {
1220                // Wait for the mode switch to be written and verified over SDO.
1221                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1222                if !self.fb_mode_of_operation.is_busy() {
1223                    if self.fb_mode_of_operation.is_error() {
1224                        self.set_op_error(&format!(
1225                            "Homing: failed to enter Homing mode: {} {}",
1226                            self.fb_mode_of_operation.error_code(),
1227                            self.fb_mode_of_operation.error_message()
1228                        ));
1229                    } else {
1230                        self.op = AxisOp::Homing(10);
1231                    }
1232                }
1233            }
1234            10 => {
1235                // Trigger homing: rising edge on bit 4
1236                let mut cw = RawControlWord(view.control_word());
1237                cw.set_bit(4, true);
1238                view.set_control_word(cw.raw());
1239                self.op = AxisOp::Homing(11);
1240            }
1241            11 => {
1242                // Wait for the drive to clear bit 12 to acknowledge the start
1243                // of homing. Without this, stale bit 12 from the previous mode
1244                // (e.g. PP set-point acknowledge) would let the next step pass
1245                // instantly even though the drive never ran the method.
1246                let sw = view.status_word();
1247                let error = sw & (1 << 13) != 0;
1248                if error {
1249                    self.set_op_error("Homing error: drive reported homing failure");
1250                } else if sw & (1 << 12) == 0 {
1251                    self.op = AxisOp::Homing(12);
1252                } else if self.homing_timed_out() {
1253                    self.set_op_error(&format!("Homing timeout: drive did not acknowledge homing start (sw=0x{:04X})", sw));
1254                }
1255            }
1256            12 => {
1257                // Wait for homing complete
1258                // Bit 13 = error, Bit 12 = attained, Bit 10 = reached
1259                let sw = view.status_word();
1260                let error    = sw & (1 << 13) != 0;
1261                let attained = sw & (1 << 12) != 0;
1262                let reached  = sw & (1 << 10) != 0;
1263
1264                if error {
1265                    self.set_op_error("Homing error: drive reported homing failure");
1266                } else if attained && reached {
1267                    self.op = AxisOp::Homing(13);
1268                } else if self.homing_timed_out() {
1269                    self.set_op_error("Homing timeout: procedure did not complete");
1270                }
1271            }
1272            13 => {
1273                // Capture home offset, applying home_position so the reference
1274                // point reads as config.home_position in user units.
1275                self.home_offset = view.position_actual()
1276                    - self.config.to_counts(self.config.home_position).round() as i32;
1277                // Clear homing start bit in its own cycle before switching modes
1278                let mut cw = RawControlWord(view.control_word());
1279                cw.set_bit(4, false);
1280                view.set_control_word(cw.raw());
1281                self.op = AxisOp::Homing(14);
1282            }
1283            14 => {
1284                // One tick later (bit 4 already cleared in step 13, so the drive
1285                // sees the falling edge before the mode change), switch back to PP
1286                // mode. Same dual-channel approach as the switch into Homing: cyclic
1287                // PDO field plus an SDO write + read-back so it lands and is verified
1288                // on drives that don't PDO-map mode.
1289                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1290                self.fb_mode_of_operation.reset();
1291                self.fb_mode_of_operation.start(ModesOfOperation::ProfilePosition as i8);
1292                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1293                self.op = AxisOp::Homing(15);
1294            }
1295            15 => {
1296                // Wait for the switch back to PP mode to be written and verified.
1297                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1298                if !self.fb_mode_of_operation.is_busy() {
1299                    if self.fb_mode_of_operation.is_error() {
1300                        self.set_op_error(&format!(
1301                            "Homing: failed to restore PP mode: {} {}",
1302                            self.fb_mode_of_operation.error_code(),
1303                            self.fb_mode_of_operation.error_message()
1304                        ));
1305                    } else {
1306                        log::info!("Homing complete — home offset: {}", self.home_offset);
1307                        self.complete_op();
1308                    }
1309                }
1310            }
1311            _ => self.complete_op(),
1312        }
1313    }
1314
1315    // ── Software homing helpers ──
1316
1317    fn configure_soft_homing(&mut self, method: HomingMethod) {
1318        match method {
1319            HomingMethod::LimitSwitchPosPnp => {
1320                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1321                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1322                self.soft_home_direction = 1.0;
1323            }
1324            HomingMethod::LimitSwitchNegPnp => {
1325                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1326                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1327                self.soft_home_direction = -1.0;
1328            }
1329            HomingMethod::LimitSwitchPosNpn => {
1330                self.soft_home_sensor = SoftHomeSensor::PositiveLimit;
1331                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1332                self.soft_home_direction = 1.0;
1333            }
1334            HomingMethod::LimitSwitchNegNpn => {
1335                self.soft_home_sensor = SoftHomeSensor::NegativeLimit;
1336                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1337                self.soft_home_direction = -1.0;
1338            }
1339            HomingMethod::HomeSensorPosPnp => {
1340                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1341                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1342                self.soft_home_direction = 1.0;
1343            }
1344            HomingMethod::HomeSensorNegPnp => {
1345                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1346                self.soft_home_sensor_type = SoftHomeSensorType::Pnp;
1347                self.soft_home_direction = -1.0;
1348            }
1349            HomingMethod::HomeSensorPosNpn => {
1350                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1351                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1352                self.soft_home_direction = 1.0;
1353            }
1354            HomingMethod::HomeSensorNegNpn => {
1355                self.soft_home_sensor = SoftHomeSensor::HomeSensor;
1356                self.soft_home_sensor_type = SoftHomeSensorType::Npn;
1357                self.soft_home_direction = -1.0;
1358            }
1359            _ => {} // integrated methods handled elsewhere
1360        }
1361    }
1362
1363    fn start_soft_homing(&mut self, view: &mut impl AxisView) {
1364        self.op = AxisOp::SoftHoming(HomeState::EnsurePpMode as u8);
1365        self.op_started = Some(Instant::now());
1366    }
1367
1368    fn check_soft_home_trigger(&self, view: &impl AxisView) -> bool {
1369        let raw = match self.soft_home_sensor {
1370            SoftHomeSensor::PositiveLimit => view.positive_limit_active(),
1371            SoftHomeSensor::NegativeLimit => view.negative_limit_active(),
1372            SoftHomeSensor::HomeSensor    => view.home_sensor_active(),
1373        };
1374        match self.soft_home_sensor_type {
1375            SoftHomeSensorType::Pnp => raw,    // PNP: true = detected
1376            SoftHomeSensorType::Npn => !raw,   // NPN: false = detected
1377        }
1378    }
1379
1380
1381    /// Calculate the maximum relative target for the specified direction.
1382    /// The result is adjusted for whether the motor direction has been inverted.
1383    fn calculate_max_relative_target(&self, direction : f64) -> i32 {
1384        let dir = if !self.config.invert_direction  {
1385            direction
1386        } 
1387        else {
1388            -direction
1389        };
1390
1391        let target = if dir > 0.0 {
1392            i32::MAX 
1393        }
1394        else {
1395            i32::MIN
1396        };
1397
1398        return target;
1399    }
1400
1401
1402    /// Convenient macro
1403    /// Configure the command word for an immediate halt
1404    /// and reset the new setpoint bit, which should cause
1405    /// status word bit 12 to clear
1406    pub fn command_halt(&self, view: &mut impl AxisView) {
1407        let mut cw = RawControlWord(view.control_word());
1408        cw.set_bit(8, true);  // halt
1409        cw.set_bit(4, false);  // reset new setpoint bit
1410        cw.set_bit(5, true);  // single set-point. If false, the new move will be queued!
1411        cw.set_bit(6, false); // absolute move
1412        view.set_control_word(cw.raw());        
1413    }
1414
1415    /// Convenient macro
1416    /// Configure the command word to clear the halt bit
1417    pub fn command_clear_halt(&self, view: &mut impl AxisView) {
1418        let mut cw = RawControlWord(view.control_word());
1419        cw.set_bit(8, false);  // reset halt
1420        view.set_control_word(cw.raw());        
1421    }
1422
1423
1424    /// Convenient macro.
1425    /// Configures command bits and targets to cancel a previous move.
1426    /// Bit 4 should be off before calling this function.
1427    /// The current absolute position will be used as the target, so there 
1428    /// should be no motion
1429    /// Halt will be turned on, if not already.]
1430    /// After this, wait for bit 12 to be true before clearing the halt bit.
1431    pub fn command_cancel_move(&self, view: &mut impl AxisView) {
1432
1433        let mut cw = RawControlWord(view.control_word());
1434
1435        cw.set_bit(4, true);  // new set-point
1436        cw.set_bit(5, true);  // single set-point. If false, the new move will be queued!
1437        cw.set_bit(6, false); // absolute move
1438
1439        if self.config().clear_halt_during_cancel {
1440            // Doesn't work with Teknic!
1441            cw.set_bit(8, false); // clear halt
1442        }
1443        
1444        view.set_control_word(cw.raw());        
1445
1446        let current_pos = view.position_actual();
1447        view.set_target_position(current_pos);
1448        view.set_profile_velocity(0);
1449    }
1450
1451
1452    /// Writes out the scaled homing speed into the bus.
1453    fn command_homing_speed(&self, view: &mut impl AxisView) {
1454        let cpu = self.config.counts_per_user();
1455        let vel = (self.config.homing_speed * cpu).round() as u32;
1456        let accel = (self.config.homing_accel * cpu).round() as u32;
1457        let decel = (self.config.homing_decel * cpu).round() as u32;
1458        view.set_profile_velocity(vel);
1459        view.set_profile_acceleration(accel);
1460        view.set_profile_deceleration(decel);        
1461    }
1462
1463    // ── Software homing state machine ──
1464    //
1465    // Phase 1: SEARCH (steps 0-3)
1466    //   Relative move in search direction until sensor triggers.
1467    //
1468    // Phase 2: HALT (steps 4-6)
1469    //   Stop the motor, cancel the old target.
1470    //
1471    // Phase 3: BACK-OFF (steps 7-11)
1472    //   Move opposite direction until sensor clears, then stop.
1473    //
1474    // Phase 4: SET HOME (steps 12-18)
1475    //   Write home offset to drive via SDO, trigger CurrentPosition homing,
1476    //   send hold set-point, complete.
1477    //
1478    fn tick_soft_homing(&mut self, view: &mut impl AxisView, client: &mut CommandClient, step: u8) {        
1479        match HomeState::from_repr(step) {
1480
1481            Some(HomeState::EnsurePpMode) => {
1482                //
1483                // If the drive crapped out in a previous mode, it might still be in homing mode.
1484                // Make sure we're in Profile Position mode.
1485                //
1486                log::info!("SoftHome: Ensuring PP mode..");
1487                self.fb_mode_of_operation.start(ModesOfOperation::ProfilePosition as i8);
1488                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1489                self.op = AxisOp::SoftHoming(HomeState::WaitPpMode as u8);
1490            },
1491            Some(HomeState::WaitPpMode) => {
1492
1493                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1494                if !self.fb_mode_of_operation.is_busy() {
1495                    if self.fb_mode_of_operation.is_error() {
1496                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1497                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1498                        ));
1499                    }
1500                    else {
1501                        log::info!("SoftHome: Drive is in PP mode!");
1502
1503                        // If sensor is NOT triggered, search for it (issue a move).
1504                        // If sensor IS already triggered, skip search and go straight
1505                        // to the found-sensor halt/back-off sequence.
1506                        if !self.check_soft_home_trigger(view) {
1507                            log::info!("SoftHome: Not on home switch; seek out.");
1508                            self.op = AxisOp::SoftHoming(HomeState::Search as u8);
1509                        } else {
1510                            log::info!("SoftHome: Already on home switch, skipping ahead to back-off stage.");
1511                            self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1512                        }
1513                    }
1514                }
1515
1516
1517            },
1518
1519            // ── Phase 1: SEARCH ──
1520            Some(HomeState::Search) => {
1521                view.set_modes_of_operation(ModesOfOperation::ProfilePosition.as_i8());
1522
1523                // // Absolute move to a far-away position in the search direction.
1524                // // Use raw counts directly to avoid overflow with invert_direction.                
1525                // let far_counts = (self.soft_home_direction * 999_999.0 * cpu).round() as i32;
1526                // let target = if self.config.invert_direction { -far_counts } else { far_counts };
1527                // let target = target + view.position_actual(); // offset from current
1528
1529
1530                // move in a relative direction as far as possible
1531                // we will stop when we reach the switch
1532                let target = self.calculate_max_relative_target(self.soft_home_direction);
1533                view.set_target_position(target);
1534
1535                // let cpu = self.config.counts_per_user();
1536                // let vel = (self.config.homing_speed * cpu).round() as u32;
1537                // let accel = (self.config.homing_accel * cpu).round() as u32;
1538                // let decel = (self.config.homing_decel * cpu).round() as u32;
1539                // view.set_profile_velocity(vel);
1540                // view.set_profile_acceleration(accel);
1541                // view.set_profile_deceleration(decel);
1542
1543                self.command_homing_speed(view);
1544
1545                let mut cw = RawControlWord(view.control_word());
1546                cw.set_bit(4, true);  // new set-point
1547                cw.set_bit(6, true); // sets true for relative move
1548                cw.set_bit(8, false); // clear halt
1549                cw.set_bit(13, true); // move relative to the actual current motor position
1550                view.set_control_word(cw.raw());
1551
1552                log::info!("SoftHome[0]: SEARCH relative target={} vel={} dir={} pos={}",
1553                    target, self.config.homing_speed, self.soft_home_direction, view.position_actual());
1554                self.op = AxisOp::SoftHoming(HomeState::WaitSearching as u8);
1555            }
1556            Some(HomeState::WaitSearching) => {
1557                if self.check_soft_home_trigger(view) {
1558                    log::debug!("SoftHome[1]: sensor triggered during ack wait");
1559                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1560                    return;
1561                }
1562                let sw = RawStatusWord(view.status_word());
1563                if sw.raw() & (1 << 12) != 0 {
1564                    let mut cw = RawControlWord(view.control_word());
1565                    cw.set_bit(4, false);
1566                    view.set_control_word(cw.raw());
1567                    log::debug!("SoftHome[1]: set-point ack received, clearing bit 4");
1568                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensor as u8);
1569                } else if self.homing_timed_out() {
1570                    self.set_op_error("Software homing timeout: set-point not acknowledged");
1571                }
1572            }
1573            // Some(HomeState::WaitSensor) => {
1574            //     if self.check_soft_home_trigger(view) {
1575            //         log::debug!("SoftHome[2]: sensor triggered during transition");
1576            //         self.op = AxisOp::SoftHoming(4);
1577            //         return;
1578            //     }
1579            //     log::debug!("SoftHome[2]: transition → monitoring");
1580            //     self.op = AxisOp::SoftHoming(3);
1581            // }
1582            Some(HomeState::WaitFoundSensor) => {
1583                if self.check_soft_home_trigger(view) {
1584                    log::info!("SoftHome[3]: sensor triggered at pos={}. HALTING", view.position_actual());
1585                    log::info!("ControlWord is : {} ", view.control_word());
1586
1587                    let mut cw = RawControlWord(view.control_word());
1588                    cw.set_bit(8, true);  // halt
1589                    cw.set_bit(4, false);  // reset new setpoint bit
1590                    view.set_control_word(cw.raw());
1591
1592
1593                    self.halt_stable_count = 0;
1594                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedFoundSensor as u8);
1595                } else if self.homing_timed_out() {
1596                    self.set_op_error("Software homing timeout: sensor not detected");
1597                }
1598            }
1599
1600
1601            Some(HomeState::WaitStoppedFoundSensor) => {
1602                const STABLE_WINDOW: i32 = 1;
1603                const STABLE_TICKS_REQUIRED: u8 = 10;
1604
1605                // let mut cw = RawControlWord(view.control_word());
1606                // cw.set_bit(8, true);
1607                // view.set_control_word(cw.raw());
1608
1609                let pos = view.position_actual();
1610                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1611                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1612                } else {
1613                    self.halt_stable_count = 0;
1614                }
1615
1616                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1617
1618                    log::info!("SoftHome[5] motor is stopped. Cancel move and wait for bit 12 go true.");
1619                    self.command_cancel_move(view);
1620                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensorAck as u8);
1621
1622                } else if self.homing_timed_out() {
1623                    self.set_op_error("Software homing timeout: motor did not stop after sensor trigger");
1624                }
1625            }
1626            Some(HomeState::WaitFoundSensorAck) => {
1627                let sw = RawStatusWord(view.status_word());
1628                if sw.raw() & (1 << 12) != 0 &&  sw.raw() & (1 << 10) != 0 {
1629
1630                    log::info!("SoftHome[6]: relative move cancel ack received. Waiting before back-off...");
1631
1632                    // reset bit 4 so we're clear for the next move
1633                    let mut cw = RawControlWord(view.control_word());
1634                    cw.set_bit(4, false);  // reset new setpoint bit
1635                    cw.set_bit(5, true); // single setpoint -- flush out any previous
1636                    view.set_control_word(cw.raw());
1637
1638                    self.op = AxisOp::SoftHoming(HomeState::WaitFoundSensorAckClear as u8);
1639
1640                } else if self.homing_timed_out() {
1641                    self.set_op_error("Software homing timeout: cancel not acknowledged");
1642                }
1643            },
1644            Some(HomeState::WaitFoundSensorAckClear) => {
1645                let sw = RawStatusWord(view.status_word());
1646                // CRITICAL: Wait for the drive to acknowledge that the setpoint is gone
1647                if sw.raw() & (1 << 12) == 0 { 
1648
1649                    // turn off halt and it still shouldn't move
1650                    self.command_clear_halt(view);
1651
1652                    log::info!("SoftHome[6]: Handshake cleared (Bit 12 is LOW). Proceeding to delay.");
1653                    self.op = AxisOp::SoftHoming(HomeState::DebounceFoundSensor as u8);
1654                    self.ton.call(false, Duration::from_secs(3));
1655                }                   
1656            },
1657            // Delay before back-off (60 = wait ~1 second for drive to settle)
1658            Some(HomeState::DebounceFoundSensor) => {
1659                self.ton.call(true, Duration::from_secs(3));
1660
1661                let sw = RawStatusWord(view.status_word());
1662                if self.ton.q && sw.raw() & (1 << 12) == 0 { 
1663                    self.ton.call(false, Duration::from_secs(3));
1664                    log::info!("SoftHome[6.a.]: delay complete, starting back-off from pos={} cw=0x{:04X} sw={:04x}",
1665                    view.position_actual(), view.control_word(), view.status_word());
1666                    self.op = AxisOp::SoftHoming(HomeState::BackOff as u8);
1667                }
1668            }
1669
1670            // ── Phase 3: BACK-OFF until sensor clears ──
1671            Some(HomeState::BackOff) => {
1672
1673                let target = (self.calculate_max_relative_target(-self.soft_home_direction)) / 2;
1674                view.set_target_position(target);
1675
1676
1677                self.command_homing_speed(view);            
1678
1679                let mut cw = RawControlWord(view.control_word());
1680                cw.set_bit(4, true);  // new set-point                
1681                cw.set_bit(6, true); // relative move                
1682                cw.set_bit(13, true); // relative from current, actualy position
1683                view.set_control_word(cw.raw());
1684                log::info!("SoftHome[7]: BACK-OFF absolute target={} vel={} pos={} cw=0x{:04X}",
1685                    target, self.config.homing_speed, view.position_actual(), cw.raw());
1686                self.op = AxisOp::SoftHoming(HomeState::WaitBackingOff as u8);
1687            }
1688            Some(HomeState::WaitBackingOff) => {
1689                let sw = RawStatusWord(view.status_word());
1690                if sw.raw() & (1 << 12) != 0 {
1691                    let mut cw = RawControlWord(view.control_word());
1692                    cw.set_bit(4, false);
1693                    view.set_control_word(cw.raw());
1694                    log::info!("SoftHome[WaitBackingOff]: back-off ack received, pos={}", view.position_actual());
1695                    self.op = AxisOp::SoftHoming(HomeState::WaitLostSensor as u8);
1696                } else if self.homing_timed_out() {
1697                    self.set_op_error("Software homing timeout: back-off not acknowledged");
1698                }
1699            }
1700            Some(HomeState::WaitLostSensor) => {
1701                if !self.check_soft_home_trigger(view) {
1702                    log::info!("SoftHome[WaitLostSensor]: sensor lost at pos={}. Halting...", view.position_actual());
1703
1704                    self.command_halt(view);
1705                    self.op = AxisOp::SoftHoming(HomeState::WaitStoppedLostSensor as u8);
1706                } else if self.homing_timed_out() {
1707                    self.set_op_error("Software homing timeout: sensor did not clear during back-off");
1708                }
1709            }
1710            Some(HomeState::WaitStoppedLostSensor)  => {
1711                const STABLE_WINDOW: i32 = 1;
1712                const STABLE_TICKS_REQUIRED: u8 = 10;
1713
1714                // let mut cw = RawControlWord(view.control_word());
1715                // cw.set_bit(8, true);
1716                // view.set_control_word(cw.raw());
1717
1718                let pos = view.position_actual();
1719                if (pos - self.last_raw_position).abs() <= STABLE_WINDOW {
1720                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
1721                } else {
1722                    self.halt_stable_count = 0;
1723                }
1724
1725                if self.halt_stable_count >= STABLE_TICKS_REQUIRED {
1726                    log::info!("SoftHome[WaitStoppedLostSensor] motor is stopped. Cancel move and wait for bit 12 go true.");
1727                    self.command_cancel_move(view);
1728                    self.op = AxisOp::SoftHoming(HomeState::WaitLostSensorAck as u8);
1729                } else if self.homing_timed_out() {
1730                    self.set_op_error("Software homing timeout: motor did not stop after back-off");
1731                }
1732            }
1733            Some(HomeState::WaitLostSensorAck) => {
1734                let sw = RawStatusWord(view.status_word());
1735                if sw.raw() & (1 << 12) != 0 &&  sw.raw() & (1 << 10) != 0 {
1736
1737                    log::info!("SoftHome[WaitLostSensorAck]: relative move cancel ack received. Waiting before back-off...");
1738
1739                    // reset bit 4 so we're clear for the next move
1740                    let mut cw = RawControlWord(view.control_word());
1741                    cw.set_bit(4, false);  // reset new setpoint bit
1742                    view.set_control_word(cw.raw());
1743
1744                     self.op = AxisOp::SoftHoming(HomeState::WaitLostSensorAckClear as u8);
1745
1746
1747                } else if self.homing_timed_out() {
1748                    self.set_op_error("Software homing timeout: cancel not acknowledged");
1749                }
1750            }
1751            Some(HomeState::WaitLostSensorAckClear) => {
1752                // CRITICAL: Wait for the drive to acknowledge that the setpoint is gone
1753                let sw = RawStatusWord(view.status_word());
1754                if sw.raw() & (1 << 12) == 0 { 
1755
1756                    self.command_clear_halt(view);
1757
1758                    let desired_counts = self.config.to_counts(self.config.home_position).round() as i32;
1759                    // let current_pos = view.position_actual();
1760                    // let offset = desired_counts - current_pos;
1761                    self.homing_sdo_tid = self.sdo.write(
1762                        client, 0x607C, 0, json!(desired_counts),
1763                    );
1764
1765                    log::info!("SoftHome[WaitLostSensorAckClear]: Handshake cleared (Bit 12 is LOW). Writing home offset {} [{} counts].",
1766                        self.config.home_position, desired_counts
1767                    );
1768
1769                    self.op = AxisOp::SoftHoming(HomeState::WaitHomeOffsetDone as u8);
1770
1771                }                
1772            },
1773
1774            Some(HomeState::WaitHomeOffsetDone) => {
1775                // Wait for home offset SDO ack
1776                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1777                    SdoResult::Ok(_) => { self.op = AxisOp::SoftHoming(HomeState::WriteHomingModeOp as u8); }
1778                    SdoResult::Pending => {
1779                        if self.homing_timed_out() {
1780                            self.set_op_error("Software homing timeout: home offset SDO write");
1781                        }
1782                    }
1783                    SdoResult::Err(e) => {
1784                        self.set_op_error(&format!("Software homing SDO error: {}", e));
1785                    }
1786                    SdoResult::Timeout => {
1787                        self.set_op_error("Software homing: home offset SDO timed out");
1788                    }
1789                }
1790            },            
1791            Some(HomeState::WriteHomingModeOp) => {
1792
1793                // Switch the mode of operation into Homing Mode so that we can execute
1794                // the homing command.
1795
1796                self.fb_mode_of_operation.reset();
1797                self.fb_mode_of_operation.start(ModesOfOperation::Homing as i8);
1798                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1799                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingModeOp as u8);
1800
1801                
1802            },       
1803            Some(HomeState::WaitWriteHomingModeOp) => {
1804                // Wait for method SDO ack
1805                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1806
1807                if !self.fb_mode_of_operation.is_busy() {
1808                    if self.fb_mode_of_operation.is_error() {
1809                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1810                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1811                        ));
1812                    }
1813                    else {
1814                        log::info!("SoftHome: Drive is now in Homing Mode.");
1815                        self.op = AxisOp::SoftHoming(HomeState::WriteHomingMethod as u8);
1816                    }
1817                }
1818            },
1819            Some(HomeState::WriteHomingMethod) => {
1820                // Write homing method = CurrentPosition (default 37, configurable
1821                // via AxisConfig::soft_home_method — Inovance SV660N needs 35).
1822                self.homing_sdo_tid = self.sdo.write(
1823                    client, 0x6098, 0, json!(self.config.soft_home_method),
1824                );
1825                self.op = AxisOp::SoftHoming(HomeState::WaitWriteHomingMethodDone as u8);
1826            }
1827            Some(HomeState::WaitWriteHomingMethodDone) => {
1828                // Wait for method SDO ack
1829                match self.sdo.result(client, self.homing_sdo_tid, Duration::from_secs(5)) {
1830                    SdoResult::Ok(_) => { 
1831                        log::info!("SoftHome: Successfully wrote homing method.");
1832                        self.op = AxisOp::SoftHoming(HomeState::ClearHomingTrigger as u8); 
1833                    }
1834                    SdoResult::Pending => {
1835                        if self.homing_timed_out() {
1836                            self.restore_pp_after_error("Software homing timeout: homing method SDO write");
1837                        }
1838                    }
1839                    SdoResult::Err(e) => {
1840                        self.restore_pp_after_error(&format!("Software homing SDO error: {}", e));
1841                    }
1842                    SdoResult::Timeout => {
1843                        self.restore_pp_after_error("Software homing: homing method SDO timed out");
1844                    }
1845                }
1846            }
1847            Some(HomeState::ClearHomingTrigger) => {
1848                // Switch to homing mode and ensure CW bit 4 starts LOW, so the
1849                // next state can issue a clean rising edge the drive will see.
1850                let mut cw = RawControlWord(view.control_word());
1851                cw.set_bit(4, false);
1852                view.set_control_word(cw.raw());
1853                self.op = AxisOp::SoftHoming(HomeState::TriggerHoming as u8);
1854            }
1855            Some(HomeState::TriggerHoming) => {
1856                // Rising edge on CW bit 4 to start homing.
1857                let mut cw = RawControlWord(view.control_word());
1858                cw.set_bit(4, true);
1859                view.set_control_word(cw.raw());
1860                log::info!("SoftHome[TriggerHoming]: start homing");
1861                self.op = AxisOp::SoftHoming(HomeState::WaitHomingStarted as u8);
1862            }
1863            Some(HomeState::WaitHomingStarted) => {
1864                // Wait for the drive to clear bit 12 (Homing attained) to acknowledge
1865                // the start of homing. Without this handshake, stale bit 12 carried
1866                // over from the previous mode (e.g. PP set-point acknowledge) would
1867                // cause WaitHomingDone to pass instantly, and the drive would never
1868                // actually perform the homing method.
1869                let sw = view.status_word();
1870                let error = sw & (1 << 13) != 0;
1871                if error {
1872                    self.restore_pp_after_error("Software homing: drive reported homing error");
1873                } else if sw & (1 << 12) == 0 {
1874                    self.op = AxisOp::SoftHoming(HomeState::WaitHomingDone as u8);
1875                } else if self.homing_timed_out() {
1876                    self.restore_pp_after_error(&format!("Software homing timeout: drive did not acknowledge homing start (sw=0x{:04X})", sw));
1877                }
1878            }
1879            Some(HomeState::WaitHomingDone) => {
1880                // Wait for homing complete (bit 12 attained + bit 10 reached).
1881                let sw = view.status_word();
1882                let error    = sw & (1 << 13) != 0;
1883                let attained = sw & (1 << 12) != 0;
1884                let reached  = sw & (1 << 10) != 0;
1885
1886                if error {
1887                    self.restore_pp_after_error("Software homing: drive reported homing error");
1888                } else if attained && reached {
1889                    log::info!("SoftHome[WaitHomingDone]: homing complete (sw=0x{:04X})", sw);
1890                    self.op = AxisOp::SoftHoming(HomeState::ResetHomingTrigger as u8);
1891                } else if self.homing_timed_out() {
1892                    self.restore_pp_after_error(&format!("Software homing timeout: drive homing did not complete (sw=0x{:04X} attained={} reached={})", sw, attained, reached));
1893                }
1894            }
1895            Some(HomeState::ResetHomingTrigger) => {
1896                // Clear CW bit 4 first, in its own RxPDO cycle, so the drive sees
1897                // the falling edge *before* we change modes_of_operation away from
1898                // Homing. Changing both at once can leave the drive committing
1899                // ambiguous state.
1900                let mut cw = RawControlWord(view.control_word());
1901                cw.set_bit(4, false);
1902                view.set_control_word(cw.raw());
1903                self.op = AxisOp::SoftHoming(HomeState::WaitHomingTriggerCleared as u8);
1904            }
1905            Some(HomeState::WaitHomingTriggerCleared) => {
1906                // One tick later, switch back to PP mode and record that the drive
1907                // now owns the offset so our software-side offset is zero.
1908                self.home_offset = 0; // drive handles it now
1909                self.op = AxisOp::SoftHoming(HomeState::WriteMotionModeOfOperation as u8);
1910            }
1911
1912
1913            Some(HomeState::WriteMotionModeOfOperation) => {
1914
1915                // Switch back to PP motion mode
1916
1917                self.fb_mode_of_operation.reset();
1918                self.fb_mode_of_operation.start(ModesOfOperation::ProfilePosition as i8);
1919                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1920                self.op = AxisOp::SoftHoming(HomeState::WaitWriteMotionModeOfOperation  as u8);
1921                
1922            },       
1923            Some(HomeState::WaitWriteMotionModeOfOperation) => {
1924                // Wait for method SDO ack
1925                self.fb_mode_of_operation.tick(client, &mut self.sdo);
1926
1927                if !self.fb_mode_of_operation.is_busy() {
1928                    if self.fb_mode_of_operation.is_error() {
1929                        self.set_op_error(&format!("Software homing SDO error writing homing mode of operation: {} {}", 
1930                            self.fb_mode_of_operation.error_code(), self.fb_mode_of_operation.error_message()
1931                        ));
1932                    }
1933                    else {
1934                        if self.is_error {
1935                            log::error!("Drive back in PP mode after error. Homing sequence did not complete!");
1936                            self.finish_op_error();
1937                        }
1938                        else {
1939                            // Set the target position so this drive doesn't go wandering off after homing
1940                            // changed the position
1941                            self.op = AxisOp::SoftHoming(HomeState::SendCurrentPositionTarget as u8);
1942                        }
1943                        
1944                    }
1945                }
1946            },
1947
1948            Some(HomeState::SendCurrentPositionTarget) => {
1949                // Hold position: send set-point to current position
1950                let current_pos = view.position_actual();
1951                view.set_target_position(current_pos);
1952                view.set_profile_velocity(0);
1953                let mut cw = RawControlWord(view.control_word());
1954                cw.set_bit(4, true);
1955                cw.set_bit(5, true);
1956                cw.set_bit(6, false); // absolute
1957                view.set_control_word(cw.raw());
1958                self.op = AxisOp::SoftHoming(HomeState::WaitCurrentPositionTargetSent as u8);
1959            }
1960            Some(HomeState::WaitCurrentPositionTargetSent) => {
1961                // Wait for hold ack
1962                let sw = RawStatusWord(view.status_word());
1963                if sw.raw() & (1 << 12) != 0 {
1964                    let mut cw = RawControlWord(view.control_word());
1965                    cw.set_bit(4, false);
1966                    view.set_control_word(cw.raw());
1967                    log::info!("Software homing complete — position set to {} user units",
1968                        self.config.home_position);
1969                    self.complete_op();
1970                } else if self.homing_timed_out() {
1971                    self.set_op_error("Software homing timeout: hold position not acknowledged");
1972                }
1973            }
1974            _ => self.complete_op(),
1975        }
1976    }
1977
1978    // ── Halting ──
1979    //
1980    // Three-stage close-out of the PP handshake, mirroring the soft-home
1981    // stop sequence (WaitStoppedFoundSensor → WaitFoundSensorAck →
1982    // WaitFoundSensorAckClear). Leaving any stage out results in a dirty
1983    // handshake that makes the next `move_absolute` time out at
1984    // "set-point not acknowledged."
1985    //
1986    // Step 0: (done in halt()) command_halt — bit 8 set, bit 4 cleared.
1987    // Step 1: wait for position to be stable → command_cancel_move.
1988    // Step 2: wait for SW bit 12 AND bit 10 → clear bit 4, set bit 5.
1989    // Step 3: wait for SW bit 12 to drop → Idle.
1990    fn tick_halting(&mut self, view: &mut impl AxisView, step: u8) {
1991        match HaltState::from_repr(step) {
1992            Some(HaltState::WaitStopped) => {
1993                // `update_outputs` writes `last_raw_position` at the end
1994                // of the previous tick, so this compares delta across
1995                // exactly one scan period.
1996                let pos = view.position_actual();
1997                let pos_stable = (pos - self.last_raw_position).abs() <= HALT_STABLE_WINDOW;
1998
1999                let vel = view.velocity_actual().abs();
2000                let vel_stopped = vel <= HALT_STOPPED_VELOCITY;
2001
2002                // Either signal is sufficient — position jitter during
2003                // servo hold can exceed the window even when the drive
2004                // reports ~0 velocity, and vice versa on slow drives
2005                // that report stale velocity briefly.
2006                if pos_stable || vel_stopped {
2007                    self.halt_stable_count = self.halt_stable_count.saturating_add(1);
2008                } else {
2009                    self.halt_stable_count = 0;
2010                }
2011
2012                if self.halt_stable_count >= HALT_STABLE_TICKS_REQUIRED {
2013                    self.command_cancel_move(view);
2014                    self.op_started = Some(Instant::now());
2015                    self.op = AxisOp::Halting(HaltState::WaitCancelAck as u8);
2016                } else if self.op_stage_timed_out(HALT_STAGE_TIMEOUT) {
2017                    self.set_op_error("Halt timeout: motor did not stop");
2018                }
2019            }
2020            Some(HaltState::WaitCancelAck) => {
2021                let sw = RawStatusWord(view.status_word());
2022                let setpoint_ack   = sw.raw() & (1 << 12) != 0;
2023                // let target_reached = sw.raw() & (1 << 10) != 0;
2024                if setpoint_ack /* && target_reached */  {
2025                    // Reset the rising edge for the next move; bit 5 flushes
2026                    // any queued setpoint so we start clean.
2027                    let mut cw = RawControlWord(view.control_word());
2028                    cw.set_bit(4, false);
2029                    cw.set_bit(5, true);
2030                    view.set_control_word(cw.raw());
2031                    self.op_started = Some(Instant::now());
2032                    self.op = AxisOp::Halting(HaltState::WaitCancelAckClear as u8);
2033                } else if self.op_stage_timed_out(HALT_STAGE_TIMEOUT) {
2034                    self.set_op_error("Halt timeout: cancel not acknowledged");
2035                }
2036            }
2037            Some(HaltState::WaitCancelAckClear) => {
2038                let sw = RawStatusWord(view.status_word());
2039                if sw.raw() & (1 << 12) == 0 {
2040                    // setpoint_ack dropped — drive is ready for the next move.
2041                    self.command_clear_halt(view);                    
2042                    self.complete_op();
2043                } else if self.op_stage_timed_out(HALT_STAGE_TIMEOUT) {
2044                    self.set_op_error("Halt timeout: ack did not clear");
2045                }
2046            }
2047            None => {
2048                log::warn!("Axis halt: unknown sub-step {}, forcing idle", step);
2049                self.complete_op();
2050            }
2051        }
2052    }
2053
2054    // ── Fault Recovery ──
2055    // Step 0: (done in reset_faults()) clear bit 7
2056    // Step 1: assert bit 7 (fault reset rising edge)
2057    // Step 2: wait fault cleared → Idle
2058    fn tick_fault_recovery(&mut self, view: &mut impl AxisView, step: u8) {
2059        match step {
2060            1 => {
2061                // Assert fault reset (rising edge on bit 7)
2062                let mut cw = RawControlWord(view.control_word());
2063                cw.cmd_fault_reset();
2064                view.set_control_word(cw.raw());
2065                self.op = AxisOp::FaultRecovery(2);
2066            }
2067            2 => {
2068                // Wait for fault to clear
2069                let sw = RawStatusWord(view.status_word());
2070                let state = sw.state();
2071                if !matches!(state, Cia402State::Fault | Cia402State::FaultReactionActive) {
2072                    log::info!("Fault cleared (drive state: {})", state);
2073                    self.complete_op();
2074                } else if self.op_timed_out() {
2075                    self.set_op_error("Fault reset timeout: drive still faulted");
2076                }
2077            }
2078            _ => self.complete_op(),
2079        }
2080    }
2081}
2082
2083// ──────────────────────────────────────────────
2084// Tests
2085// ──────────────────────────────────────────────
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090
2091    /// Mock AxisView for testing.
2092    struct MockView {
2093        control_word: u16,
2094        status_word: u16,
2095        target_position: i32,
2096        profile_velocity: u32,
2097        profile_acceleration: u32,
2098        profile_deceleration: u32,
2099        modes_of_operation: i8,
2100        modes_of_operation_display: i8,
2101        position_actual: i32,
2102        velocity_actual: i32,
2103        error_code: u16,
2104        positive_limit: bool,
2105        negative_limit: bool,
2106        home_sensor: bool,
2107    }
2108
2109    impl MockView {
2110        fn new() -> Self {
2111            Self {
2112                control_word: 0,
2113                status_word: 0x0040, // SwitchOnDisabled
2114                target_position: 0,
2115                profile_velocity: 0,
2116                profile_acceleration: 0,
2117                profile_deceleration: 0,
2118                modes_of_operation: 0,
2119                modes_of_operation_display: 1, // PP
2120                position_actual: 0,
2121                velocity_actual: 0,
2122                error_code: 0,
2123                positive_limit: false,
2124                negative_limit: false,
2125                home_sensor: false,
2126            }
2127        }
2128
2129        fn set_state(&mut self, state: u16) {
2130            self.status_word = state;
2131        }
2132    }
2133
2134    impl AxisView for MockView {
2135        fn control_word(&self) -> u16 { self.control_word }
2136        fn set_control_word(&mut self, word: u16) { self.control_word = word; }
2137        fn set_target_position(&mut self, pos: i32) { self.target_position = pos; }
2138        fn set_profile_velocity(&mut self, vel: u32) { self.profile_velocity = vel; }
2139        fn set_profile_acceleration(&mut self, accel: u32) { self.profile_acceleration = accel; }
2140        fn set_profile_deceleration(&mut self, decel: u32) { self.profile_deceleration = decel; }
2141        fn set_modes_of_operation(&mut self, mode: i8) { self.modes_of_operation = mode; }
2142        fn modes_of_operation_display(&self) -> i8 { self.modes_of_operation_display }
2143        fn status_word(&self) -> u16 { self.status_word }
2144        fn position_actual(&self) -> i32 { self.position_actual }
2145        fn velocity_actual(&self) -> i32 { self.velocity_actual }
2146        fn error_code(&self) -> u16 { self.error_code }
2147        fn positive_limit_active(&self) -> bool { self.positive_limit }
2148        fn negative_limit_active(&self) -> bool { self.negative_limit }
2149        fn home_sensor_active(&self) -> bool { self.home_sensor }
2150    }
2151
2152    fn test_config() -> AxisConfig {
2153        AxisConfig::new(12_800).with_user_scale(360.0)
2154    }
2155
2156    /// Helper: create axis + mock client channels.
2157    fn test_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
2158        use tokio::sync::mpsc;
2159        let (write_tx, write_rx) = mpsc::unbounded_channel();
2160        let (response_tx, response_rx) = mpsc::unbounded_channel();
2161        let client = CommandClient::new(write_tx, response_rx);
2162        let axis = Axis::new(test_config(), "TestDrive");
2163        (axis, client, response_tx, write_rx)
2164    }
2165
2166    #[test]
2167    fn axis_config_conversion() {
2168        let cfg = test_config();
2169        // 45 degrees = 1600 counts
2170        assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
2171    }
2172
2173    #[test]
2174    fn enable_sequence_sets_pp_mode_and_shutdown() {
2175        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2176        let mut view = MockView::new();
2177
2178        axis.enable(&mut view);
2179
2180        // Should have set PP mode
2181        assert_eq!(view.modes_of_operation, ModesOfOperation::ProfilePosition.as_i8());
2182        // Should have issued shutdown command (bits 1,2 set; 0,3,7 clear)
2183        assert_eq!(view.control_word & 0x008F, 0x0006);
2184        // Should be in Enabling state
2185        assert_eq!(axis.op, AxisOp::Enabling(1));
2186
2187        // Simulate drive reaching ReadyToSwitchOn
2188        view.set_state(0x0021); // ReadyToSwitchOn
2189        axis.tick(&mut view, &mut client);
2190
2191        // Should have issued enable_operation (bits 0-3 set; 7 clear)
2192        assert_eq!(view.control_word & 0x008F, 0x000F);
2193        assert_eq!(axis.op, AxisOp::Enabling(2));
2194
2195        // Simulate drive reaching OperationEnabled
2196        view.set_state(0x0027); // OperationEnabled
2197        axis.tick(&mut view, &mut client);
2198
2199        // Should be idle now, motor_on = true
2200        assert_eq!(axis.op, AxisOp::Idle);
2201        assert!(axis.motor_on);
2202    }
2203
2204    #[test]
2205    fn move_absolute_sets_target() {
2206        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2207        let mut view = MockView::new();
2208        view.set_state(0x0027); // OperationEnabled
2209        axis.tick(&mut view, &mut client); // update outputs
2210
2211        // Move to 45 degrees at 90 deg/s, 180 deg/s² accel/decel
2212        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2213
2214        // Target should be ~1600 counts (45° at 12800 cpr / 360°)
2215        assert_eq!(view.target_position, 1600);
2216        // Velocity: 90 deg/s * (12800/360) ≈ 3200 counts/s
2217        assert_eq!(view.profile_velocity, 3200);
2218        // Accel: 180 deg/s² * (12800/360) ≈ 6400 counts/s²
2219        assert_eq!(view.profile_acceleration, 6400);
2220        assert_eq!(view.profile_deceleration, 6400);
2221        // Bit 4 (new set-point) should be set
2222        assert!(view.control_word & (1 << 4) != 0);
2223        // Bit 6 (relative) should be clear for absolute move
2224        assert!(view.control_word & (1 << 6) == 0);
2225        // Should be in Moving state
2226        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1, _, _)));
2227    }
2228
2229    #[test]
2230    fn move_relative_sets_relative_bit() {
2231        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2232        let mut view = MockView::new();
2233        view.set_state(0x0027);
2234        axis.tick(&mut view, &mut client);
2235
2236        axis.move_relative(&mut view, 10.0, 90.0, 180.0, 180.0);
2237
2238        // Bit 6 (relative) should be set
2239        assert!(view.control_word & (1 << 6) != 0);
2240        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Relative, 1, _, _)));
2241    }
2242
2243    #[test]
2244    fn move_completes_on_target_reached() {
2245        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2246        let mut view = MockView::new();
2247        view.set_state(0x0027); // OperationEnabled
2248        axis.tick(&mut view, &mut client);
2249
2250        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2251
2252        // Step 1: simulate set-point acknowledge (bit 12)
2253        view.status_word = 0x1027; // OperationEnabled + bit 12
2254        axis.tick(&mut view, &mut client);
2255        // Should have cleared bit 4
2256        assert!(view.control_word & (1 << 4) == 0);
2257
2258        // Step 2: simulate target reached (bit 10)
2259        view.status_word = 0x0427; // OperationEnabled + bit 10
2260        axis.tick(&mut view, &mut client);
2261        // Should be idle now
2262        assert_eq!(axis.op, AxisOp::Idle);
2263        assert!(!axis.in_motion);
2264    }
2265
2266    #[test]
2267    fn fault_detected_sets_error() {
2268        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2269        let mut view = MockView::new();
2270        view.set_state(0x0008); // Fault
2271        view.error_code = 0x1234;
2272
2273        axis.tick(&mut view, &mut client);
2274
2275        assert!(axis.is_error);
2276        assert_eq!(axis.error_code, 0x1234);
2277        assert!(axis.error_message.contains("fault"));
2278    }
2279
2280    #[test]
2281    fn fault_recovery_sequence() {
2282        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2283        let mut view = MockView::new();
2284        view.set_state(0x0008); // Fault
2285
2286        axis.reset_faults(&mut view);
2287        // Step 0: bit 7 should be cleared
2288        assert!(view.control_word & 0x0080 == 0);
2289
2290        // Step 1: tick should assert bit 7
2291        axis.tick(&mut view, &mut client);
2292        assert!(view.control_word & 0x0080 != 0);
2293
2294        // Step 2: simulate fault cleared → SwitchOnDisabled
2295        view.set_state(0x0040);
2296        axis.tick(&mut view, &mut client);
2297        assert_eq!(axis.op, AxisOp::Idle);
2298        assert!(!axis.is_error);
2299    }
2300
2301    #[test]
2302    fn reset_faults_software_only_is_synchronous() {
2303        // When the axis hits a software error (e.g. target outside the
2304        // software limits) the drive stays in OperationEnabled — there is
2305        // no CiA 402 fault to clear. reset_faults() must return the axis
2306        // to Idle on the same call without engaging the drive's bit-7
2307        // fault-reset handshake.
2308        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2309        let mut view = MockView::new();
2310        view.set_state(0x0027); // OperationEnabled
2311        axis.tick(&mut view, &mut client);
2312
2313        // Trigger a software-only error via target outside software limit.
2314        axis.set_software_min_limit(207.0);
2315        axis.move_absolute(&mut view, 200.0, 90.0, 180.0, 180.0);
2316        assert!(axis.is_error, "move past software limit must set is_error");
2317        assert!(!axis.is_busy);
2318
2319        // Capture cw before reset. reset_faults must not modify it for
2320        // a software-only error (drive is healthy, no handshake needed).
2321        let cw_before = view.control_word;
2322
2323        axis.reset_faults(&mut view);
2324
2325        // Synchronous: error cleared, axis already Idle, not busy.
2326        assert!(!axis.is_error);
2327        assert_eq!(axis.error_code, 0);
2328        assert!(axis.error_message.is_empty());
2329        assert!(!axis.is_busy);
2330        assert_eq!(axis.op, AxisOp::Idle);
2331        // No drive handshake — control word untouched.
2332        assert_eq!(view.control_word, cw_before);
2333    }
2334
2335    #[test]
2336    fn disable_sequence() {
2337        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2338        let mut view = MockView::new();
2339        view.set_state(0x0027); // OperationEnabled
2340
2341        axis.disable(&mut view);
2342        // Should have sent disable_operation command
2343        assert_eq!(view.control_word & 0x008F, 0x0007);
2344
2345        // Simulate drive leaving OperationEnabled
2346        view.set_state(0x0023); // SwitchedOn
2347        axis.tick(&mut view, &mut client);
2348        assert_eq!(axis.op, AxisOp::Idle);
2349    }
2350
2351    #[test]
2352    fn position_tracks_with_home_offset() {
2353        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2354        let mut view = MockView::new();
2355        view.set_state(0x0027);
2356        view.position_actual = 5000;
2357
2358        // Enable to capture home offset
2359        axis.enable(&mut view);
2360        view.set_state(0x0021);
2361        axis.tick(&mut view, &mut client);
2362        view.set_state(0x0027);
2363        axis.tick(&mut view, &mut client);
2364
2365        // Home offset should be 5000
2366        assert_eq!(axis.home_offset, 5000);
2367
2368        // Position should be 0 (at home)
2369        assert!((axis.position - 0.0).abs() < 0.01);
2370
2371        // Move actual position to 5000 + 1600 = 6600
2372        view.position_actual = 6600;
2373        axis.tick(&mut view, &mut client);
2374
2375        // Should read as 45 degrees
2376        assert!((axis.position - 45.0).abs() < 0.1);
2377    }
2378
2379    #[test]
2380    fn set_position_adjusts_home_offset() {
2381        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2382        let mut view = MockView::new();
2383        view.position_actual = 3200;
2384
2385        axis.set_position(&view, 90.0);
2386        axis.tick(&mut view, &mut client);
2387
2388        // home_offset = 3200 - to_counts(90.0) = 3200 - 3200 = 0
2389        assert_eq!(axis.home_offset, 0);
2390        assert!((axis.position - 90.0).abs() < 0.01);
2391    }
2392
2393    #[test]
2394    fn halt_runs_multi_stage_close_out() {
2395        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2396        let mut view = MockView::new();
2397        view.set_state(0x0027);
2398
2399        axis.halt(&mut view);
2400
2401        // halt() sets CW bit 8 (halt) and clears CW bit 4 (new setpoint).
2402        assert!(view.control_word & (1 << 8) != 0, "halt bit must be set");
2403        assert!(view.control_word & (1 << 4) == 0, "new_setpoint must be cleared");
2404
2405        // Halt is a multi-stage process. The first stage is WaitStopped.
2406        assert!(matches!(axis.op, AxisOp::Halting(_)),
2407                "halt should enter Halting state, not Idle");
2408        let AxisOp::Halting(step) = axis.op.clone() else { unreachable!() };
2409        assert_eq!(step, HaltState::WaitStopped as u8);
2410
2411        // Tick alone does NOT immediately return to Idle anymore —
2412        // WaitStopped polls position stability (5 consecutive ticks
2413        // with position stable or velocity near zero). With a static
2414        // MockView, position stays at 0 and velocity stays at 0, so
2415        // vel_stopped is true every tick → the counter accumulates
2416        // and the state progresses after HALT_STABLE_TICKS_REQUIRED.
2417        for _ in 0..HALT_STABLE_TICKS_REQUIRED {
2418            axis.tick(&mut view, &mut client);
2419        }
2420        // After enough stable ticks we advance to WaitCancelAck.
2421        assert!(matches!(axis.op, AxisOp::Halting(_)));
2422        let AxisOp::Halting(step) = axis.op.clone() else { unreachable!() };
2423        assert_eq!(step, HaltState::WaitCancelAck as u8,
2424                   "should advance past WaitStopped once position/velocity is stable");
2425
2426        // axis.is_busy must remain true through the whole halt sequence so
2427        // callers like MoveToLoad don't see "stopped" until the PP state
2428        // is fully cleaned up.
2429        assert!(axis.is_busy, "is_busy must stay true across Halting stages");
2430    }
2431
2432    #[test]
2433    fn is_busy_tracks_operations() {
2434        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2435        let mut view = MockView::new();
2436
2437        // Idle — not busy
2438        axis.tick(&mut view, &mut client);
2439        assert!(!axis.is_busy);
2440
2441        // Enable — busy
2442        axis.enable(&mut view);
2443        axis.tick(&mut view, &mut client);
2444        assert!(axis.is_busy);
2445
2446        // Complete enable
2447        view.set_state(0x0021);
2448        axis.tick(&mut view, &mut client);
2449        view.set_state(0x0027);
2450        axis.tick(&mut view, &mut client);
2451        assert!(!axis.is_busy);
2452
2453        // Move — busy
2454        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2455        axis.tick(&mut view, &mut client);
2456        assert!(axis.is_busy);
2457        assert!(axis.in_motion);
2458    }
2459
2460    #[test]
2461    fn fault_during_move_cancels_op() {
2462        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2463        let mut view = MockView::new();
2464        view.set_state(0x0027); // OperationEnabled
2465        axis.tick(&mut view, &mut client);
2466
2467        // Start a move
2468        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2469        axis.tick(&mut view, &mut client);
2470        assert!(axis.is_busy);
2471        assert!(!axis.is_error);
2472
2473        // Fault occurs mid-move
2474        view.set_state(0x0008); // Fault
2475        axis.tick(&mut view, &mut client);
2476
2477        // is_busy should be false, is_error should be true
2478        assert!(!axis.is_busy);
2479        assert!(axis.is_error);
2480        assert_eq!(axis.op, AxisOp::Idle);
2481    }
2482
2483    #[test]
2484    fn move_absolute_rejected_by_max_limit() {
2485        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2486        let mut view = MockView::new();
2487        view.set_state(0x0027);
2488        axis.tick(&mut view, &mut client);
2489
2490        axis.set_software_max_limit(90.0);
2491        axis.move_absolute(&mut view, 100.0, 90.0, 180.0, 180.0);
2492
2493        // Should not have started a move — error instead
2494        assert!(axis.is_error);
2495        assert_eq!(axis.op, AxisOp::Idle);
2496        assert!(axis.error_message.contains("max software limit"));
2497    }
2498
2499    #[test]
2500    fn move_absolute_rejected_by_min_limit() {
2501        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2502        let mut view = MockView::new();
2503        view.set_state(0x0027);
2504        axis.tick(&mut view, &mut client);
2505
2506        axis.set_software_min_limit(-10.0);
2507        axis.move_absolute(&mut view, -20.0, 90.0, 180.0, 180.0);
2508
2509        assert!(axis.is_error);
2510        assert_eq!(axis.op, AxisOp::Idle);
2511        assert!(axis.error_message.contains("min software limit"));
2512    }
2513
2514    #[test]
2515    fn move_relative_rejected_by_max_limit() {
2516        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2517        let mut view = MockView::new();
2518        view.set_state(0x0027);
2519        axis.tick(&mut view, &mut client);
2520
2521        // Position is 0, max limit 50 — relative move of +60 should be rejected
2522        axis.set_software_max_limit(50.0);
2523        axis.move_relative(&mut view, 60.0, 90.0, 180.0, 180.0);
2524
2525        assert!(axis.is_error);
2526        assert_eq!(axis.op, AxisOp::Idle);
2527        assert!(axis.error_message.contains("max software limit"));
2528    }
2529
2530    #[test]
2531    fn move_within_limits_allowed() {
2532        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2533        let mut view = MockView::new();
2534        view.set_state(0x0027);
2535        axis.tick(&mut view, &mut client);
2536
2537        axis.set_software_max_limit(90.0);
2538        axis.set_software_min_limit(-90.0);
2539        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2540
2541        // Should have started normally
2542        assert!(!axis.is_error);
2543        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1, _, _)));
2544    }
2545
2546    #[test]
2547    fn runtime_limit_halts_move_in_violated_direction() {
2548        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2549        let mut view = MockView::new();
2550        view.set_state(0x0027);
2551        axis.tick(&mut view, &mut client);
2552
2553        axis.set_software_max_limit(45.0);
2554        // Start a move to exactly the limit (allowed)
2555        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2556
2557        // Simulate the drive overshooting past 45° (position_actual in counts)
2558        // home_offset is 0, so 1650 counts = 46.4°
2559        view.position_actual = 1650;
2560        view.velocity_actual = 100; // moving positive
2561
2562        // Simulate set-point ack so we're in Moving step 2
2563        view.status_word = 0x1027;
2564        axis.tick(&mut view, &mut client);
2565        view.status_word = 0x0027;
2566        axis.tick(&mut view, &mut client);
2567
2568        // Should have halted cleanly without error. The axis enters the
2569        // Halting close-out (caller waits for !is_busy then checks
2570        // at_max_limit).
2571        assert!(!axis.is_error);
2572        assert!(axis.at_max_limit);
2573        assert!(axis.is_busy);
2574        assert!(matches!(axis.op, AxisOp::Halting(_)));
2575        // Halt bit (bit 8) should be set
2576        assert!(view.control_word & (1 << 8) != 0);
2577    }
2578
2579    #[test]
2580    fn runtime_limit_allows_move_in_opposite_direction() {
2581        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2582        let mut view = MockView::new();
2583        view.set_state(0x0027);
2584        // Start at 50° (past max limit)
2585        view.position_actual = 1778; // ~50°
2586        axis.set_software_max_limit(45.0);
2587        axis.tick(&mut view, &mut client);
2588        assert!(axis.at_max_limit);
2589
2590        // Move back toward 0 — should be allowed even though at max limit
2591        axis.move_absolute(&mut view, 0.0, 90.0, 180.0, 180.0);
2592        assert!(!axis.is_error);
2593        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1, _, _)));
2594
2595        // Simulate moving negative — limit check should not halt
2596        view.velocity_actual = -100;
2597        view.status_word = 0x1027; // ack
2598        axis.tick(&mut view, &mut client);
2599        // Still moving, no error from limit
2600        assert!(!axis.is_error);
2601    }
2602
2603    #[test]
2604    fn positive_limit_switch_halts_positive_move() {
2605        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2606        let mut view = MockView::new();
2607        view.set_state(0x0027);
2608        axis.tick(&mut view, &mut client);
2609
2610        // Start a move in the positive direction
2611        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2612        view.velocity_actual = 100; // moving positive
2613        // Simulate set-point ack so we're in Moving step 2
2614        view.status_word = 0x1027;
2615        axis.tick(&mut view, &mut client);
2616        view.status_word = 0x0027;
2617
2618        // Now the positive limit switch trips
2619        view.positive_limit = true;
2620        axis.tick(&mut view, &mut client);
2621
2622        // Limit hit during a regular move enters the Halting close-out:
2623        // no error, halt bit asserted, drive's queued setpoint will be
2624        // canceled and halt cleared before the axis returns to Idle.
2625        // Caller waits for !is_busy then inspects at_positive_limit_switch.
2626        assert!(!axis.is_error);
2627        assert!(axis.at_positive_limit_switch);
2628        assert!(axis.is_busy);
2629        assert!(matches!(axis.op, AxisOp::Halting(_)));
2630        // Halt bit should be set
2631        assert!(view.control_word & (1 << 8) != 0);
2632    }
2633
2634    #[test]
2635    fn negative_limit_switch_halts_negative_move() {
2636        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2637        let mut view = MockView::new();
2638        view.set_state(0x0027);
2639        axis.tick(&mut view, &mut client);
2640
2641        // Start a move in the negative direction
2642        axis.move_absolute(&mut view, -45.0, 90.0, 180.0, 180.0);
2643        view.velocity_actual = -100; // moving negative
2644        view.status_word = 0x1027;
2645        axis.tick(&mut view, &mut client);
2646        view.status_word = 0x0027;
2647
2648        // Negative limit switch trips
2649        view.negative_limit = true;
2650        axis.tick(&mut view, &mut client);
2651
2652        // Limit hit during a regular move enters the Halting close-out:
2653        // no error, halt bit asserted, drive's queued setpoint will be
2654        // canceled and halt cleared before the axis returns to Idle.
2655        // Caller waits for !is_busy then inspects at_negative_limit_switch.
2656        assert!(!axis.is_error);
2657        assert!(axis.at_negative_limit_switch);
2658        assert!(axis.is_busy);
2659        assert!(matches!(axis.op, AxisOp::Halting(_)));
2660        assert!(view.control_word & (1 << 8) != 0);
2661    }
2662
2663    #[test]
2664    fn limit_halt_cleanup_then_next_move_starts_cleanly() {
2665        // After a limit-switch halt, the axis must run the full Halting
2666        // close-out (motor stop → cancel queued setpoint → clear halt) so
2667        // the drive ends in a clean state. The next move then starts via
2668        // the normal one-tick start_move path.
2669        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2670        let mut view = MockView::new();
2671        view.set_state(0x0027); // OperationEnabled
2672        axis.tick(&mut view, &mut client);
2673
2674        // Drive an initial move into a positive limit.
2675        axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
2676        view.velocity_actual = 100;
2677        view.status_word = 0x1027;
2678        axis.tick(&mut view, &mut client);
2679        view.status_word = 0x0027;
2680        view.positive_limit = true;
2681        axis.tick(&mut view, &mut client);
2682
2683        // Limit halt: in Halting close-out, halt asserted, busy, no error.
2684        assert!(!axis.is_error);
2685        assert!(axis.is_busy);
2686        assert!(matches!(axis.op, AxisOp::Halting(_)));
2687        assert!(view.control_word & (1 << 8) != 0);
2688
2689        // Drive the close-out to completion. WaitStopped polls position
2690        // stability; with static MockView (vel=0 after we clear it), it
2691        // accumulates HALT_STABLE_TICKS_REQUIRED and advances to
2692        // WaitCancelAck.
2693        view.velocity_actual = 0;
2694        view.positive_limit = false;
2695        for _ in 0..HALT_STABLE_TICKS_REQUIRED {
2696            axis.tick(&mut view, &mut client);
2697        }
2698        assert!(matches!(axis.op, AxisOp::Halting(_)));
2699        let AxisOp::Halting(step) = axis.op.clone() else { unreachable!() };
2700        assert_eq!(step, HaltState::WaitCancelAck as u8);
2701
2702        // WaitCancelAck: drive acks the cancel. We mock by setting bit 12.
2703        view.status_word = 0x1027;
2704        axis.tick(&mut view, &mut client);
2705        let AxisOp::Halting(step) = axis.op.clone() else { unreachable!() };
2706        assert_eq!(step, HaltState::WaitCancelAckClear as u8);
2707
2708        // WaitCancelAckClear: drive drops bit 12. Halting completes,
2709        // halt bit cleared, op back to Idle.
2710        view.status_word = 0x0027;
2711        axis.tick(&mut view, &mut client);
2712        assert_eq!(axis.op, AxisOp::Idle);
2713        assert!(!axis.is_busy);
2714        assert!(view.control_word & (1 << 8) == 0, "halt should be cleared after close-out");
2715
2716        // Now a fresh move should start via the normal one-tick path —
2717        // bit 4 asserted immediately, op at step 1.
2718        axis.move_absolute(&mut view, 30.0, 90.0, 180.0, 180.0);
2719        assert!(view.control_word & (1 << 4) != 0, "new-setpoint asserted on start_move tick");
2720        assert!(view.control_word & (1 << 8) == 0, "halt stays cleared");
2721        assert!(matches!(axis.op, AxisOp::Moving(MoveKind::Absolute, 1, _, _)));
2722    }
2723
2724    #[test]
2725    fn limit_switch_allows_move_in_opposite_direction() {
2726        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2727        let mut view = MockView::new();
2728        view.set_state(0x0027);
2729        // Positive limit is active, but we're moving negative (retreating)
2730        view.positive_limit = true;
2731        view.velocity_actual = -100;
2732        axis.tick(&mut view, &mut client);
2733        assert!(axis.at_positive_limit_switch);
2734
2735        // Move in the negative direction should be allowed
2736        axis.move_absolute(&mut view, -10.0, 90.0, 180.0, 180.0);
2737        view.status_word = 0x1027;
2738        axis.tick(&mut view, &mut client);
2739
2740        // Should still be moving, no error
2741        assert!(!axis.is_error);
2742        assert!(matches!(axis.op, AxisOp::Moving(_, _, _, _)));
2743    }
2744
2745    #[test]
2746    fn limit_switch_ignored_when_not_moving() {
2747        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2748        let mut view = MockView::new();
2749        view.set_state(0x0027);
2750        view.positive_limit = true;
2751
2752        axis.tick(&mut view, &mut client);
2753
2754        // Output flag is set, but no error since we're not moving
2755        assert!(axis.at_positive_limit_switch);
2756        assert!(!axis.is_error);
2757    }
2758
2759    #[test]
2760    fn home_sensor_output_tracks_view() {
2761        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2762        let mut view = MockView::new();
2763        view.set_state(0x0027);
2764
2765        axis.tick(&mut view, &mut client);
2766        assert!(!axis.home_sensor);
2767
2768        view.home_sensor = true;
2769        axis.tick(&mut view, &mut client);
2770        assert!(axis.home_sensor);
2771
2772        view.home_sensor = false;
2773        axis.tick(&mut view, &mut client);
2774        assert!(!axis.home_sensor);
2775    }
2776
2777    #[test]
2778    fn velocity_output_converted() {
2779        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
2780        let mut view = MockView::new();
2781        view.set_state(0x0027);
2782        // 3200 counts/s = 90 deg/s
2783        view.velocity_actual = 3200;
2784
2785        axis.tick(&mut view, &mut client);
2786
2787        assert!((axis.speed - 90.0).abs() < 0.1);
2788        assert!(axis.moving_positive);
2789        assert!(!axis.moving_negative);
2790    }
2791
2792    // ── Software homing tests ──
2793
2794    fn soft_homing_config() -> AxisConfig {
2795        let mut cfg = AxisConfig::new(12_800).with_user_scale(360.0);
2796        cfg.homing_speed = 10.0;
2797        cfg.homing_accel = 20.0;
2798        cfg.homing_decel = 20.0;
2799        cfg
2800    }
2801
2802    fn soft_homing_axis() -> (Axis, CommandClient, tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>, tokio::sync::mpsc::UnboundedReceiver<String>) {
2803        use tokio::sync::mpsc;
2804        let (write_tx, write_rx) = mpsc::unbounded_channel();
2805        let (response_tx, response_rx) = mpsc::unbounded_channel();
2806        let client = CommandClient::new(write_tx, response_rx);
2807        let axis = Axis::new(soft_homing_config(), "TestDrive");
2808        (axis, client, response_tx, write_rx)
2809    }
2810
2811    /// Helper: enable the axis and put it in OperationEnabled state.
2812    fn enable_axis(axis: &mut Axis, view: &mut MockView, client: &mut CommandClient) {
2813        view.set_state(0x0027); // OperationEnabled
2814        axis.tick(view, client);
2815    }
2816
2817    /// Helper: drive the soft homing state machine through phases 2-4
2818    /// (halt, back-off, set home). Call after sensor triggers (step 4).
2819    /// `trigger_pos`: position where sensor triggered
2820    /// `clear_sensor`: closure to deactivate the sensor on the view
2821    fn complete_soft_homing(
2822        axis: &mut Axis,
2823        view: &mut MockView,
2824        client: &mut CommandClient,
2825        resp_tx: &tokio::sync::mpsc::UnboundedSender<mechutil::ipc::CommandMessage>,
2826        trigger_pos: i32,
2827        clear_sensor: impl FnOnce(&mut MockView),
2828    ) {
2829        use mechutil::ipc::CommandMessage as IpcMsg;
2830
2831        // Phase 2: HALT (steps 4-6)
2832        // Step 4: halt
2833        axis.tick(view, client);
2834        assert!(matches!(axis.op, AxisOp::SoftHoming(5)));
2835
2836        // Step 5: motor decelerating then stopped
2837        view.position_actual = trigger_pos + 100;
2838        axis.tick(view, client);
2839        view.position_actual = trigger_pos + 120;
2840        axis.tick(view, client);
2841        // 10 stable ticks
2842        for _ in 0..10 { axis.tick(view, client); }
2843        assert!(matches!(axis.op, AxisOp::SoftHoming(6)));
2844
2845        // Step 6: cancel ack → delay step 60
2846        view.status_word = 0x1027;
2847        axis.tick(view, client);
2848        assert!(matches!(axis.op, AxisOp::SoftHoming(60)));
2849        view.status_word = 0x0027;
2850
2851        // Step 60: delay (~1 second = 100 ticks)
2852        for _ in 0..100 { axis.tick(view, client); }
2853        assert!(matches!(axis.op, AxisOp::SoftHoming(7)));
2854
2855        // Phase 3: BACK-OFF (steps 7-11)
2856        // Step 7: start back-off move
2857        axis.tick(view, client);
2858        assert!(matches!(axis.op, AxisOp::SoftHoming(8)));
2859
2860        // Step 8: ack
2861        view.status_word = 0x1027;
2862        axis.tick(view, client);
2863        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
2864        view.status_word = 0x0027;
2865
2866        // Step 9: sensor still active, then clears
2867        axis.tick(view, client);
2868        assert!(matches!(axis.op, AxisOp::SoftHoming(9)));
2869        clear_sensor(view);
2870        view.position_actual = trigger_pos - 200;
2871        axis.tick(view, client);
2872        assert!(matches!(axis.op, AxisOp::SoftHoming(10)));
2873
2874        // Step 10-11: halt after back-off, wait stable
2875        axis.tick(view, client);
2876        assert!(matches!(axis.op, AxisOp::SoftHoming(11)));
2877        for _ in 0..10 { axis.tick(view, client); }
2878        assert!(matches!(axis.op, AxisOp::SoftHoming(12)));
2879
2880        // Phase 4: SET HOME (steps 12-19)
2881        // Step 12: cancel ack + SDO write home offset
2882        view.status_word = 0x1027;
2883        axis.tick(view, client);
2884        view.status_word = 0x0027;
2885        assert!(matches!(axis.op, AxisOp::SoftHoming(13)));
2886
2887        // Step 13: SDO ack for home offset
2888        let tid = axis.homing_sdo_tid;
2889        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
2890        client.poll();
2891        axis.tick(view, client);
2892        assert!(matches!(axis.op, AxisOp::SoftHoming(14)));
2893
2894        // Step 14→15: SDO write homing method, ack
2895        axis.tick(view, client);
2896        let tid = axis.homing_sdo_tid;
2897        resp_tx.send(IpcMsg::response(tid, json!(null))).unwrap();
2898        client.poll();
2899        axis.tick(view, client);
2900        assert!(matches!(axis.op, AxisOp::SoftHoming(16)));
2901
2902        // Step 16: switch to homing mode + trigger
2903        view.modes_of_operation_display = ModesOfOperation::Homing.as_i8();
2904        axis.tick(view, client);
2905        assert!(matches!(axis.op, AxisOp::SoftHoming(17)));
2906
2907        // Step 17: homing complete (attained + reached)
2908        view.status_word = 0x1427; // bit 12 + bit 10
2909        axis.tick(view, client);
2910        assert!(matches!(axis.op, AxisOp::SoftHoming(18)));
2911        view.modes_of_operation_display = ModesOfOperation::ProfilePosition.as_i8();
2912        view.status_word = 0x0027;
2913
2914        // Step 18: hold position
2915        axis.tick(view, client);
2916        assert!(matches!(axis.op, AxisOp::SoftHoming(19)));
2917
2918        // Step 19: hold ack
2919        view.status_word = 0x1027;
2920        axis.tick(view, client);
2921        view.status_word = 0x0027;
2922
2923        assert_eq!(axis.op, AxisOp::Idle);
2924        assert!(!axis.is_busy);
2925        assert!(!axis.is_error);
2926        assert_eq!(axis.home_offset, 0); // drive handles it now
2927    }
2928
2929    #[test]
2930    fn soft_homing_pnp_home_sensor_full_sequence() {
2931        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
2932        let mut view = MockView::new();
2933        enable_axis(&mut axis, &mut view, &mut client);
2934
2935        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
2936
2937        // Phase 1: search
2938        axis.tick(&mut view, &mut client); // step 0→1
2939        view.status_word = 0x1027;
2940        axis.tick(&mut view, &mut client); // step 1→2 (ack)
2941        view.status_word = 0x0027;
2942        axis.tick(&mut view, &mut client); // step 2→3
2943
2944        // Sensor triggers
2945        view.home_sensor = true;
2946        view.position_actual = 5000;
2947        axis.tick(&mut view, &mut client);
2948        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2949
2950        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
2951            |v| { v.home_sensor = false; });
2952    }
2953
2954    #[test]
2955    fn soft_homing_npn_home_sensor_full_sequence() {
2956        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
2957        let mut view = MockView::new();
2958        // NPN: sensor reads true normally, false when detected
2959        view.home_sensor = true;
2960        enable_axis(&mut axis, &mut view, &mut client);
2961
2962        axis.home(&mut view, HomingMethod::HomeSensorPosNpn);
2963
2964        // Phase 1: search
2965        axis.tick(&mut view, &mut client);
2966        view.status_word = 0x1027;
2967        axis.tick(&mut view, &mut client);
2968        view.status_word = 0x0027;
2969        axis.tick(&mut view, &mut client);
2970
2971        // NPN: sensor goes false = detected
2972        view.home_sensor = false;
2973        view.position_actual = 3000;
2974        axis.tick(&mut view, &mut client);
2975        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
2976
2977        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 3000,
2978            |v| { v.home_sensor = true; }); // NPN: back to true = cleared
2979    }
2980
2981    #[test]
2982    fn soft_homing_limit_switch_suppresses_halt() {
2983        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
2984        let mut view = MockView::new();
2985        enable_axis(&mut axis, &mut view, &mut client);
2986
2987        // Software homing on positive limit switch (rising edge)
2988        axis.home(&mut view, HomingMethod::LimitSwitchPosPnp);
2989
2990        // Progress through initial steps
2991        axis.tick(&mut view, &mut client); // step 0 → 1
2992        view.status_word = 0x1027; // ack
2993        axis.tick(&mut view, &mut client); // step 1 → 2
2994        view.status_word = 0x0027;
2995        axis.tick(&mut view, &mut client); // step 2 → 3
2996
2997        // Positive limit switch trips — should NOT halt (suppressed)
2998        view.positive_limit = true;
2999        view.velocity_actual = 100; // moving positive
3000        view.position_actual = 8000;
3001        axis.tick(&mut view, &mut client);
3002
3003        // Should have detected rising edge → step 4, NOT an error halt
3004        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3005        assert!(!axis.is_error);
3006    }
3007
3008    #[test]
3009    fn soft_homing_opposite_limit_still_protects() {
3010        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3011        let mut view = MockView::new();
3012        enable_axis(&mut axis, &mut view, &mut client);
3013
3014        // Software homing on home sensor (positive direction)
3015        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3016
3017        // Progress through initial steps
3018        axis.tick(&mut view, &mut client); // step 0 → 1
3019        view.status_word = 0x1027; // ack
3020        axis.tick(&mut view, &mut client); // step 1 → 2
3021        view.status_word = 0x0027;
3022        axis.tick(&mut view, &mut client); // step 2 → 3
3023
3024        // Negative limit switch trips while searching positive (shouldn't happen
3025        // in practice, but tests protection)
3026        view.negative_limit = true;
3027        view.velocity_actual = -100; // moving negative
3028        axis.tick(&mut view, &mut client);
3029
3030        // Should have halted with error (negative limit protects)
3031        assert!(axis.is_error);
3032        assert!(axis.error_message.contains("Negative limit switch"));
3033    }
3034
3035    #[test]
3036    // fn soft_homing_sensor_already_active_rejects() {
3037    //     let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3038    //     let mut view = MockView::new();
3039    //     enable_axis(&mut axis, &mut view, &mut client);
3040
3041    //     // Home sensor is already active (rising edge would never happen)
3042    //     view.home_sensor = true;
3043    //     axis.tick(&mut view, &mut client); // update prev state
3044
3045    //     axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3046
3047    //     // Should have been rejected immediately
3048    //     assert!(axis.is_error);
3049    //     assert!(axis.error_message.contains("already in trigger state"));
3050    //     assert_eq!(axis.op, AxisOp::Idle);
3051    // }
3052
3053    #[test]
3054    fn soft_homing_negative_direction_sets_negative_target() {
3055        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3056        let mut view = MockView::new();
3057        enable_axis(&mut axis, &mut view, &mut client);
3058
3059        axis.home(&mut view, HomingMethod::HomeSensorNegPnp);
3060        axis.tick(&mut view, &mut client); // step 0
3061
3062        // Target should be negative (large negative value in counts)
3063        assert!(view.target_position < 0);
3064    }
3065
3066    #[test]
3067    fn home_integrated_method_starts_hardware_homing() {
3068        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3069        let mut view = MockView::new();
3070        enable_axis(&mut axis, &mut view, &mut client);
3071
3072        axis.home(&mut view, HomingMethod::CurrentPosition);
3073        assert!(matches!(axis.op, AxisOp::Homing(0)));
3074        assert_eq!(axis.homing_method, 37);
3075    }
3076
3077    #[test]
3078    fn home_integrated_arbitrary_code() {
3079        let (mut axis, mut client, _resp_tx, _write_rx) = test_axis();
3080        let mut view = MockView::new();
3081        enable_axis(&mut axis, &mut view, &mut client);
3082
3083        axis.home(&mut view, HomingMethod::Integrated(35));
3084        assert!(matches!(axis.op, AxisOp::Homing(0)));
3085        assert_eq!(axis.homing_method, 35);
3086    }
3087
3088    #[test]
3089    fn hardware_homing_skips_speed_sdos_when_zero() {
3090        use mechutil::ipc::CommandMessage;
3091
3092        let (mut axis, mut client, resp_tx, mut write_rx) = test_axis();
3093        let mut view = MockView::new();
3094        enable_axis(&mut axis, &mut view, &mut client);
3095
3096        // Config has homing_speed = 0 and homing_accel = 0 (defaults)
3097        axis.home(&mut view, HomingMethod::Integrated(37));
3098
3099        // Step 0: writes homing method SDO
3100        axis.tick(&mut view, &mut client);
3101        assert!(matches!(axis.op, AxisOp::Homing(1)));
3102
3103        // Drain the SDO write message
3104        let _ = write_rx.try_recv();
3105
3106        // Simulate SDO ack — need to use the correct tid from the sdo write
3107        let tid = axis.homing_sdo_tid;
3108        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
3109        client.poll();
3110        axis.tick(&mut view, &mut client);
3111
3112        // Should have skipped to step 8 (set homing mode)
3113        assert!(matches!(axis.op, AxisOp::Homing(8)));
3114    }
3115
3116    #[test]
3117    fn hardware_homing_writes_speed_sdos_when_nonzero() {
3118        use mechutil::ipc::CommandMessage;
3119
3120        let (mut axis, mut client, resp_tx, mut write_rx) = soft_homing_axis();
3121        let mut view = MockView::new();
3122        enable_axis(&mut axis, &mut view, &mut client);
3123
3124        // Config has homing_speed = 10.0, homing_accel = 20.0
3125        axis.home(&mut view, HomingMethod::Integrated(37));
3126
3127        // Step 0: writes homing method SDO
3128        axis.tick(&mut view, &mut client);
3129        assert!(matches!(axis.op, AxisOp::Homing(1)));
3130        let _ = write_rx.try_recv();
3131
3132        // SDO ack for homing method
3133        let tid = axis.homing_sdo_tid;
3134        resp_tx.send(CommandMessage::response(tid, serde_json::json!(null))).unwrap();
3135        client.poll();
3136        axis.tick(&mut view, &mut client);
3137        // Should go to step 2 (write speed SDO), not skip to 8
3138        assert!(matches!(axis.op, AxisOp::Homing(2)));
3139    }
3140
3141    #[test]
3142    fn soft_homing_edge_during_ack_step() {
3143        let (mut axis, mut client, _resp_tx, _write_rx) = soft_homing_axis();
3144        let mut view = MockView::new();
3145        enable_axis(&mut axis, &mut view, &mut client);
3146
3147        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3148        axis.tick(&mut view, &mut client); // step 0 → 1
3149
3150        // Sensor rises during step 1 (before ack)
3151        view.home_sensor = true;
3152        view.position_actual = 2000;
3153        axis.tick(&mut view, &mut client);
3154
3155        // Should jump straight to step 4 (edge detected)
3156        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3157    }
3158
3159    #[test]
3160    fn soft_homing_applies_home_position() {
3161        let mut cfg = soft_homing_config();
3162        cfg.home_position = 90.0;
3163
3164        use tokio::sync::mpsc;
3165        let (write_tx, _write_rx) = mpsc::unbounded_channel();
3166        let (resp_tx, response_rx) = mpsc::unbounded_channel();
3167        let mut client = CommandClient::new(write_tx, response_rx);
3168        let mut axis = Axis::new(cfg, "TestDrive");
3169
3170        let mut view = MockView::new();
3171        enable_axis(&mut axis, &mut view, &mut client);
3172
3173        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3174
3175        // Search phase
3176        axis.tick(&mut view, &mut client);
3177        view.status_word = 0x1027;
3178        axis.tick(&mut view, &mut client);
3179        view.status_word = 0x0027;
3180        axis.tick(&mut view, &mut client);
3181
3182        // Sensor triggers
3183        view.home_sensor = true;
3184        view.position_actual = 5000;
3185        axis.tick(&mut view, &mut client);
3186        assert!(matches!(axis.op, AxisOp::SoftHoming(4)));
3187
3188        // Complete full sequence (halt, back-off, set home via drive)
3189        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
3190            |v| { v.home_sensor = false; });
3191
3192        // After completion, home_offset = 0 (drive handles it)
3193        assert_eq!(axis.home_offset, 0);
3194    }
3195
3196    #[test]
3197    fn soft_homing_default_home_position_zero() {
3198        let (mut axis, mut client, resp_tx, _write_rx) = soft_homing_axis();
3199        let mut view = MockView::new();
3200        enable_axis(&mut axis, &mut view, &mut client);
3201
3202        axis.home(&mut view, HomingMethod::HomeSensorPosPnp);
3203
3204        // Search phase
3205        axis.tick(&mut view, &mut client);
3206        view.status_word = 0x1027;
3207        axis.tick(&mut view, &mut client);
3208        view.status_word = 0x0027;
3209        axis.tick(&mut view, &mut client);
3210
3211        // Sensor triggers
3212        view.home_sensor = true;
3213        view.position_actual = 5000;
3214        axis.tick(&mut view, &mut client);
3215
3216        complete_soft_homing(&mut axis, &mut view, &mut client, &resp_tx, 5000,
3217            |v| { v.home_sensor = false; });
3218
3219        assert_eq!(axis.home_offset, 0);
3220    }
3221}