Skip to main content

autocore_std/motion/
axis.rs

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