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