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