Browse Source

控制卡 轴添加主从模式;界面添加IO调试和安全配置

刘彬 2 hours ago
parent
commit
5fadb51737
40 changed files with 3841 additions and 134 deletions
  1. 172 1
      ExE/win-x64/Config/MotionDevices.json
  2. BIN
      ExE/win-x64/Products/产品1/流程1.aas
  3. BIN
      ExE/win-x64/Runtime/Plugins/Plugins.Motion.dll
  4. BIN
      ExE/win-x64/Runtime/TeamAAS.Camera.dll
  5. BIN
      ExE/win-x64/Runtime/TeamAAS.Communication.dll
  6. BIN
      ExE/win-x64/Runtime/TeamAAS.Core.dll
  7. BIN
      ExE/win-x64/Runtime/TeamAAS.Database.dll
  8. BIN
      ExE/win-x64/Runtime/TeamAAS.Feeder.dll
  9. BIN
      ExE/win-x64/Runtime/TeamAAS.FlowEngine.dll
  10. BIN
      ExE/win-x64/Runtime/TeamAAS.Global.dll
  11. BIN
      ExE/win-x64/Runtime/TeamAAS.Motion.dll
  12. BIN
      ExE/win-x64/Runtime/TeamAAS.Robot.dll
  13. BIN
      ExE/win-x64/Runtime/TeamAAS.Vision.dll
  14. BIN
      ExE/win-x64/TeamAAS.exe
  15. 42 1
      TeamAAS.Base/TeamAAS.Global/Devices/MotionContracts.cs
  16. 93 0
      TeamAAS.Base/TeamAAS.Global/Dialogs/PropertyGridDialog.xaml.cs
  17. 190 0
      TeamAAS.Core/TeamAAS.Motion/Models/MotionAxisConfig.cs
  18. 48 7
      TeamAAS.Core/TeamAAS.Motion/Models/MotionDeviceConfig.cs
  19. 289 0
      TeamAAS.Core/TeamAAS.Motion/Motions/CombinedMotionAxis.cs
  20. 1176 29
      TeamAAS.Core/TeamAAS.Motion/Motions/InovanceMotionCard.cs
  21. 91 0
      TeamAAS.Core/TeamAAS.Motion/Motions/MasterFollowSession.cs
  22. 136 0
      TeamAAS.Core/TeamAAS.Motion/Motions/MotionSafetyGuard.cs
  23. 435 79
      TeamAAS.Core/TeamAAS.Motion/Motions/SimulatedMotionCard.cs
  24. 71 4
      TeamAAS.Core/TeamAAS.Motion/UI/MotionDebugView.xaml
  25. 259 7
      TeamAAS.Core/TeamAAS.Motion/UI/MotionDebugView.xaml.cs
  26. 150 0
      TeamAAS.Plugins/Plugins.Motion/Core/MotionAxisNoConverter.cs
  27. 17 4
      TeamAAS.Plugins/Plugins.Motion/Models/MotionPluginModels.cs
  28. 2 0
      TeamAAS.Plugins/Plugins.Motion/MotionEnablePlugins.cs
  29. 6 0
      TeamAAS.Plugins/Plugins.Motion/MotionMovePlugins.cs
  30. 20 0
      TeamAAS.Plugins/Plugins.Motion/MotionPluginHelper.cs
  31. 6 1
      TeamAAS/TeamAAS.csproj
  32. 64 0
      TeamAAS/ViewModels/SettingViewModel.MotionCard.cs
  33. 4 0
      TeamAAS/ViewModels/SettingViewModel.cs
  34. 1 0
      TeamAAS/Views/AddMotionCardDialog.xaml.cs
  35. 68 0
      TeamAAS/Views/EditMotionAxesDialog.xaml
  36. 232 0
      TeamAAS/Views/EditMotionAxesDialog.xaml.cs
  37. 117 0
      TeamAAS/Views/EditMotionSafetyDialog.xaml
  38. 133 0
      TeamAAS/Views/EditMotionSafetyDialog.xaml.cs
  39. 13 1
      TeamAAS/Views/SettingView.xaml
  40. 6 0
      TeamAAS/Views/SettingView.xaml.cs

+ 172 - 1
ExE/win-x64/Config/MotionDevices.json

@@ -3,9 +3,180 @@
     "Name": "控制卡1",
     "AdapterKey": "Simulated",
     "BoardIndex": 0,
+    "AxisCount": 6,
+    "Axes": [
+      {
+        "AxisNo": 0,
+        "Name": "X",
+        "Kind": 0,
+        "OutputChannel": 0,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 1,
+        "Name": "Y",
+        "Kind": 0,
+        "OutputChannel": 1,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 2,
+        "Name": "Z",
+        "Kind": 1,
+        "OutputChannel": 2,
+        "CombinedMembers": "4",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 3,
+        "Name": "R",
+        "Kind": 1,
+        "OutputChannel": 3,
+        "CombinedMembers": "4,5",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 4,
+        "Name": "W1",
+        "Kind": 0,
+        "OutputChannel": 4,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 5,
+        "Name": "W2",
+        "Kind": 0,
+        "OutputChannel": 5,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      }
+    ],
+    "InputCount": 16,
+    "OutputCount": 16,
+    "ConnectionString": "",
+    "EstopDiIndex": -1,
+    "EstopActiveHigh": true,
+    "StopDiIndex": -1,
+    "StopActiveHigh": true
+  },
+  {
+    "Name": "控制卡2",
+    "AdapterKey": "Inovance",
+    "BoardIndex": 0,
     "AxisCount": 4,
+    "Axes": [
+      {
+        "AxisNo": 0,
+        "Name": "X",
+        "Kind": 0,
+        "OutputChannel": 0,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 1,
+        "Name": "Y",
+        "Kind": 0,
+        "OutputChannel": 1,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 2,
+        "Name": "Z",
+        "Kind": 0,
+        "OutputChannel": 2,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      },
+      {
+        "AxisNo": 3,
+        "Name": "R",
+        "Kind": 0,
+        "OutputChannel": 3,
+        "CombinedMembers": "",
+        "Velocity": 100.0,
+        "Acceleration": 500.0,
+        "Deceleration": 500.0,
+        "Jerk": 1000.0,
+        "SoftLimitEnabled": false,
+        "SoftLimitNeg": 0.0,
+        "SoftLimitPos": 0.0,
+        "MaxVelocity": 0.0
+      }
+    ],
     "InputCount": 16,
     "OutputCount": 16,
-    "ConnectionString": ""
+    "ConnectionString": "",
+    "EstopDiIndex": -1,
+    "EstopActiveHigh": true,
+    "StopDiIndex": -1,
+    "StopActiveHigh": true
   }
 ]

BIN
ExE/win-x64/Products/产品1/流程1.aas


BIN
ExE/win-x64/Runtime/Plugins/Plugins.Motion.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Camera.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Communication.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Core.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Database.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Feeder.dll


BIN
ExE/win-x64/Runtime/TeamAAS.FlowEngine.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Global.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Motion.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Robot.dll


BIN
ExE/win-x64/Runtime/TeamAAS.Vision.dll


BIN
ExE/win-x64/TeamAAS.exe


+ 42 - 1
TeamAAS.Base/TeamAAS.Global/Devices/MotionContracts.cs

@@ -49,7 +49,7 @@ namespace TeamAAS.Global.Devices
         /// <summary>急停全部轴。</summary>
         bool EmergencyStop();
 
-        // --- 板载 IO ---
+        // --- 板载 IO(无 bank 时按本地端子)---
         bool ReadDI(int portIndex);
         bool WriteDO(int portIndex, bool value);
 
@@ -57,6 +57,47 @@ namespace TeamAAS.Global.Devices
         event EventHandler<MotionErrorEventArgs> ErrorOccurred;
     }
 
+    /// <summary>运动卡 IO 分区:本地端子板与 EtherCAT 扩展点号相互独立。</summary>
+    public enum MotionIoBank
+    {
+        Local = 0,
+        EtherCat = 1
+    }
+
+    /// <summary>运动卡本地 / EtherCAT 数字量 IO。</summary>
+    public interface IMotionIoHost
+    {
+        bool CanSimulateDi { get; }
+        int GetDiCount(MotionIoBank bank);
+        int GetDoCount(MotionIoBank bank);
+        bool ReadDI(MotionIoBank bank, int index);
+        bool ReadDO(MotionIoBank bank, int index);
+        bool WriteDO(MotionIoBank bank, int index, bool value);
+        bool WriteDI(MotionIoBank bank, int index, bool value);
+    }
+
+    /// <summary>
+    /// 卡级安全互锁:急停下使能并禁止使能/运动;停止信号禁止运动。
+    /// </summary>
+    public interface IMotionSafetyHost
+    {
+        bool IsEstopActive { get; }
+        bool IsStopActive { get; }
+        bool AllowEnable(out string reason);
+        bool AllowMotion(out string reason);
+        event EventHandler SafetyChanged;
+    }
+
+    /// <summary>
+    /// 仿真卡时钟。点到点应一次拨完剩余行程;超时只用于真实等待(点动/硬件),不要和仿真行程绑在同一循环。
+    /// </summary>
+    public interface IMotionSimulation
+    {
+        void Simulate(double deltaSeconds);
+        /// <summary>把所有点到点运动按剩余路程/速度一次仿真到结束。点动保持运动中。</summary>
+        void FinishDiscreteMoves();
+    }
+
     /// <summary>
     /// IO 卡(如 GR20T-ECT-1616ETN)统一契约。
     /// </summary>

+ 93 - 0
TeamAAS.Base/TeamAAS.Global/Dialogs/PropertyGridDialog.xaml.cs

@@ -1,12 +1,16 @@
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Windows;
 using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
 using System.Windows.Input;
 using System.Windows.Media;
 using HandyControl.Controls;
+using PgItem = PropertyGridLib.Controls.PropertyItem;
 
 namespace TeamAAS.Dialogs.Dialogs
 {
@@ -25,6 +29,7 @@ namespace TeamAAS.Dialogs.Dialogs
 
         private CancellationTokenSource _cts;
         private bool _isExecuting;
+        private bool _refreshingProperties;
 
         /// <summary>最近一次持久提示。悬停提示结束后回落到这里。</summary>
         private string _lastTip = string.Empty;
@@ -52,6 +57,8 @@ namespace TeamAAS.Dialogs.Dialogs
             InitializeComponent();
             UiScaler.Attach(this); // 分辨率/DPI 自适应
             Closing += (s, e) => { if (_isExecuting) e.Cancel = true; };
+            PropertyGrid.AddHandler(Selector.SelectionChangedEvent, new SelectionChangedEventHandler(OnComboSelectionChanged), true);
+            PropertyGrid.AddHandler(UIElement.PreviewMouseLeftButtonDownEvent, new MouseButtonEventHandler(OnComboPreviewMouseDown), true);
             // 回车 = 确认(执行中除外)
             PreviewKeyDown += (s, e) =>
             {
@@ -221,5 +228,91 @@ namespace TeamAAS.Dialogs.Dialogs
             Result = false;
             Close();
         }
+
+        private void OnComboSelectionChanged(object sender, SelectionChangedEventArgs e)
+        {
+            if (_refreshingProperties || e.AddedItems == null || e.AddedItems.Count == 0) return;
+            var combo = e.OriginalSource as System.Windows.Controls.ComboBox
+                        ?? FindComboBox(e.OriginalSource as DependencyObject);
+            if (combo?.DataContext is not PgItem item) return;
+            var owner = PropertyGrid.SelectedObject;
+            if (owner == null || string.IsNullOrEmpty(item.Name)) return;
+
+            var pd = TypeDescriptor.GetProperties(owner)[item.Name];
+            if (pd == null || pd.IsReadOnly) return;
+
+            object val = combo.SelectedItem ?? combo.Text;
+            object current = pd.GetValue(owner);
+            if (val != null && !Equals(current, val))
+            {
+                try { pd.SetValue(owner, val); }
+                catch { /* 类型转换失败时仍走 LostFocus 写回 */ }
+            }
+
+            var refresh = pd.Attributes[typeof(RefreshPropertiesAttribute)] as RefreshPropertiesAttribute;
+            if (refresh == null || refresh.RefreshProperties == RefreshProperties.None) return;
+            if (Equals(current, val)) return;
+
+            _refreshingProperties = true;
+            try
+            {
+                PropertyGrid.RefreshProperties();
+            }
+            finally
+            {
+                _refreshingProperties = false;
+            }
+        }
+
+        /// <summary>
+        /// PropertyGridLib 只在创建属性项时抓一次 TypeConverter 下拉项。展开前按当前对象重取,
+        /// 这样「先选卡再选轴」能看到该卡的轴列表。
+        /// </summary>
+        private void OnComboPreviewMouseDown(object sender, MouseButtonEventArgs e)
+        {
+            var combo = FindComboBox(e.OriginalSource as DependencyObject);
+            if (combo == null || combo.DataContext is not PgItem item) return;
+            RefreshComboStandardValues(combo, item);
+        }
+
+        private void RefreshComboStandardValues(System.Windows.Controls.ComboBox combo, PgItem item)
+        {
+            var owner = PropertyGrid.SelectedObject;
+            if (owner == null || string.IsNullOrEmpty(item.Name)) return;
+            var pd = TypeDescriptor.GetProperties(owner)[item.Name];
+            var conv = pd?.Converter;
+            if (conv == null || !conv.GetStandardValuesSupported()) return;
+
+            var values = conv.GetStandardValues(new EditorTypeDescriptorContext(owner, pd));
+            if (values == null || values.Count == 0) return;
+
+            var list = values.Cast<object>().ToList();
+            combo.ItemsSource = list;
+        }
+
+        private static System.Windows.Controls.ComboBox FindComboBox(DependencyObject start)
+        {
+            for (var d = start; d != null; d = VisualTreeHelper.GetParent(d))
+            {
+                if (d is System.Windows.Controls.ComboBox combo) return combo;
+            }
+            return null;
+        }
+
+        private sealed class EditorTypeDescriptorContext : ITypeDescriptorContext
+        {
+            public EditorTypeDescriptorContext(object instance, PropertyDescriptor property)
+            {
+                Instance = instance;
+                PropertyDescriptor = property;
+            }
+
+            public IContainer Container => null;
+            public object Instance { get; }
+            public PropertyDescriptor PropertyDescriptor { get; }
+            public object GetService(Type serviceType) => null;
+            public void OnComponentChanged() { }
+            public bool OnComponentChanging() => true;
+        }
     }
 }

+ 190 - 0
TeamAAS.Core/TeamAAS.Motion/Models/MotionAxisConfig.cs

@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+
+namespace TeamAAS.Motion.Models
+{
+    /// <summary>逻辑轴类型。</summary>
+    public enum MotionAxisKind
+    {
+        /// <summary>EtherCAT/脉冲实轴,绑定物理通道。</summary>
+        [Description("实轴")]
+        Physical = 0,
+        /// <summary>虚轴:仅规划,不绑定输出。</summary>
+        [Description("虚轴")]
+        Virtual = 1,
+        /// <summary>组合轴:一条逻辑轴同步驱动一个或多个从轴(主从跟随 / 双驱龙门)。</summary>
+        [Description("组合轴")]
+        Combined = 2
+    }
+
+    /// <summary>单条逻辑轴配置(持久化在 MotionDevices.json)。</summary>
+    [Serializable]
+    public class MotionAxisConfig
+    {
+        /// <summary>逻辑轴号,流程/调试用,范围 [0,63]。</summary>
+        public int AxisNo { get; set; }
+
+        public string Name { get; set; } = "Axis";
+
+        public MotionAxisKind Kind { get; set; } = MotionAxisKind.Physical;
+
+        /// <summary>实轴的 EtherCAT 输出通道;虚轴/组合轴忽略。</summary>
+        public int OutputChannel { get; set; }
+
+        /// <summary>
+        /// 从轴列表。虚轴作为主轴时填写从轴号;正数为同向,负数为反向(例如 "1" 或 "1,-2")。
+        /// </summary>
+        public string CombinedMembers { get; set; } = "";
+
+        public double Velocity { get; set; } = 100;
+        public double Acceleration { get; set; } = 500;
+        public double Deceleration { get; set; } = 500;
+        public double Jerk { get; set; } = 1000;
+
+        /// <summary>是否启用软限位。关闭时不钳制位置、不下发卡限位检查。</summary>
+        public bool SoftLimitEnabled { get; set; }
+
+        /// <summary>负向软限位(用户单位)。</summary>
+        public double SoftLimitNeg { get; set; }
+
+        /// <summary>正向软限位(用户单位)。</summary>
+        public double SoftLimitPos { get; set; }
+
+        /// <summary>最高速度。≤0 表示不限制。</summary>
+        public double MaxVelocity { get; set; }
+
+        public MotionAxisConfig Clone()
+        {
+            return new MotionAxisConfig
+            {
+                AxisNo = AxisNo,
+                Name = Name,
+                Kind = Kind,
+                OutputChannel = OutputChannel,
+                CombinedMembers = CombinedMembers,
+                Velocity = Velocity,
+                Acceleration = Acceleration,
+                Deceleration = Deceleration,
+                Jerk = Jerk,
+                SoftLimitEnabled = SoftLimitEnabled,
+                SoftLimitNeg = SoftLimitNeg,
+                SoftLimitPos = SoftLimitPos,
+                MaxVelocity = MaxVelocity
+            };
+        }
+
+        public double ClampPosition(double position)
+        {
+            if (!SoftLimitEnabled) return position;
+            double lo = Math.Min(SoftLimitNeg, SoftLimitPos);
+            double hi = Math.Max(SoftLimitNeg, SoftLimitPos);
+            if (position < lo) return lo;
+            if (position > hi) return hi;
+            return position;
+        }
+
+        public double ClampVelocity(double velocity)
+        {
+            if (MaxVelocity <= 0) return velocity;
+            if (velocity > MaxVelocity) return MaxVelocity;
+            if (velocity < -MaxVelocity) return -MaxVelocity;
+            return velocity;
+        }
+
+        public double ClampJog(double velocity, double currentPosition)
+        {
+            double v = ClampVelocity(velocity);
+            if (!SoftLimitEnabled || Math.Abs(v) < 1e-9) return v;
+            double lo = Math.Min(SoftLimitNeg, SoftLimitPos);
+            double hi = Math.Max(SoftLimitNeg, SoftLimitPos);
+            if (v > 0 && currentPosition >= hi) return 0;
+            if (v < 0 && currentPosition <= lo) return 0;
+            return v;
+        }
+
+        public List<(int AxisNo, double Scale)> ParseMembers()
+        {
+            var list = new List<(int, double)>();
+            if (string.IsNullOrWhiteSpace(CombinedMembers)) return list;
+            foreach (var raw in CombinedMembers.Split(new[] { ',', ';', ' ', ',' }, StringSplitOptions.RemoveEmptyEntries))
+            {
+                var token = raw.Trim();
+                if (token.Length == 0 || token == "-" || token == "+") continue;
+                if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var signed))
+                    continue;
+                int no = Math.Abs(signed);
+                double scale = token.StartsWith("-", StringComparison.Ordinal) ? -1 : 1;
+                list.Add((no, scale));
+            }
+            return list;
+        }
+
+        /// <summary>组合轴,或虚轴填写了至少 1 个从轴时,按主从跟随运行。</summary>
+        [Newtonsoft.Json.JsonIgnore]
+        public bool IsGrouped
+        {
+            get
+            {
+                if (ParseMembers().Count < 1) return false;
+                return Kind == MotionAxisKind.Combined || Kind == MotionAxisKind.Virtual;
+            }
+        }
+
+        [Newtonsoft.Json.JsonIgnore]
+        public string KindText
+        {
+            get
+            {
+                if (Kind == MotionAxisKind.Virtual && ParseMembers().Count >= 1)
+                    return "虚轴(主从)";
+                switch (Kind)
+                {
+                    case MotionAxisKind.Virtual: return "虚轴";
+                    case MotionAxisKind.Combined: return "组合轴";
+                    default: return "实轴";
+                }
+            }
+        }
+    }
+
+    public static class MotionAxisCatalog
+    {
+        public static readonly string[] DefaultNames = { "X", "Y", "Z", "R" };
+
+        public static List<MotionAxisConfig> CreateDefaults(int count)
+        {
+            count = Math.Max(1, count);
+            var list = new List<MotionAxisConfig>(count);
+            for (int i = 0; i < count; i++)
+            {
+                list.Add(new MotionAxisConfig
+                {
+                    AxisNo = i,
+                    Name = i < DefaultNames.Length ? DefaultNames[i] : $"Axis-{i}",
+                    Kind = MotionAxisKind.Physical,
+                    OutputChannel = i
+                });
+            }
+            return list;
+        }
+
+        public static List<MotionAxisConfig> GetEffective(MotionDeviceConfig cfg)
+        {
+            if (cfg?.Axes != null && cfg.Axes.Count > 0)
+            {
+                var byNo = new Dictionary<int, MotionAxisConfig>();
+                foreach (var a in cfg.Axes)
+                {
+                    if (a == null) continue;
+                    byNo[a.AxisNo] = a;
+                }
+                if (byNo.Count > 0)
+                    return byNo.Values.OrderBy(x => x.AxisNo).ToList();
+            }
+            return CreateDefaults(cfg?.AxisCount ?? 4);
+        }
+    }
+}

+ 48 - 7
TeamAAS.Core/TeamAAS.Motion/Models/MotionDeviceConfig.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel;
 using TeamAAS.Global.Devices;
+using Newtonsoft.Json;
 
 namespace TeamAAS.Motion.Models
 {
@@ -26,21 +27,61 @@ namespace TeamAAS.Motion.Models
         [Description("品牌适配器的板卡/设备索引")]
         public int BoardIndex { get; set; } = 0;
 
-        /// <summary>轴数量(IO 卡忽略)。</summary>
+        /// <summary>轴数量(有 Axes 时等于配置条数)。不要在 setter 里生成默认轴,反序列化会先写 AxisCount 再追加 Axes,造成列表重复。</summary>
         [DisplayName("轴数量")]
-        public int AxisCount { get; set; } = 4;
+        public int AxisCount
+        {
+            get => Axes != null && Axes.Count > 0 ? Axes.Count : _axisCount;
+            set => _axisCount = Math.Max(1, value);
+        }
+        private int _axisCount = 4;
 
-        /// <summary>输入点数(IO 卡用)。</summary>
-        [DisplayName("输入点数")]
+        /// <summary>逻辑轴列表(实轴 / 虚轴 / 组合轴)。为空时按 AxisCount 生成默认 X/Y/Z/R。</summary>
+        [Browsable(false)]
+        [JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
+        public List<MotionAxisConfig> Axes { get; set; } = new List<MotionAxisConfig>();
+
+        /// <summary>本地端子板 DI 点数。</summary>
+        [DisplayName("本地 DI 点数")]
+        public int LocalInputCount { get; set; } = 16;
+
+        /// <summary>本地端子板 DO 点数。</summary>
+        [DisplayName("本地 DO 点数")]
+        public int LocalOutputCount { get; set; } = 16;
+
+        /// <summary>EtherCAT 扩展 DI 点数(无硬件信息时用)。</summary>
+        [DisplayName("EtherCAT DI 点数")]
         public int InputCount { get; set; } = 16;
 
-        /// <summary>输出点数(IO 卡用)。</summary>
-        [DisplayName("输出点数")]
+        /// <summary>EtherCAT 扩展 DO 点数(无硬件信息时用)。</summary>
+        [DisplayName("EtherCAT DO 点数")]
         public int OutputCount { get; set; } = 16;
 
-        /// <summary>连接参数(IP/串口等,品牌适配器解释;EtherCAT 模块填 ENI/IP)。</summary>
+        /// <summary>连接参数。汇川卡填设备配置 XML 路径,可用 | 或 ; 分隔系统配置,例如:ENI.xml|System.xml</summary>
         [DisplayName("连接参数")]
         [Description("IP / 串口 / EtherCAT 配置等,由品牌适配器解释")]
         public string ConnectionString { get; set; } = "";
+
+        /// <summary>总急停输入点,-1 表示不启用。</summary>
+        [Browsable(false)]
+        public int EstopDiIndex { get; set; } = -1;
+
+        [Browsable(false)]
+        public MotionIoBank EstopDiBank { get; set; } = MotionIoBank.Local;
+
+        /// <summary>true=DI 为 1 时急停有效(常开);false=DI 为 0 时有效(常闭)。</summary>
+        [Browsable(false)]
+        public bool EstopActiveHigh { get; set; } = true;
+
+        /// <summary>总停止输入点,-1 表示不启用。</summary>
+        [Browsable(false)]
+        public int StopDiIndex { get; set; } = -1;
+
+        [Browsable(false)]
+        public MotionIoBank StopDiBank { get; set; } = MotionIoBank.Local;
+
+        /// <summary>true=DI 为 1 时停止有效。</summary>
+        [Browsable(false)]
+        public bool StopActiveHigh { get; set; } = true;
     }
 }

+ 289 - 0
TeamAAS.Core/TeamAAS.Motion/Motions/CombinedMotionAxis.cs

@@ -0,0 +1,289 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using TeamAAS.Global.Devices;
+using TeamAAS.Motion.Models;
+
+namespace TeamAAS.Motion.Motions
+{
+    /// <summary>
+    /// 主从跟随:主轴(虚轴)有独立规划位置;从轴只跟随主轴的相对位移。
+    /// 主轴从 0→10,从轴当前 1 则走到 11。从轴可被多条主轴共用,互不影响各自主轴读数。
+    /// </summary>
+    public class CombinedMotionAxis : IAxis
+    {
+        private readonly IMotionCard _card;
+        private readonly MotionAxisConfig _cfg;
+        private readonly IAxis _master;
+        private readonly Func<int, IAxis> _resolve;
+
+        public CombinedMotionAxis(IMotionCard card, MotionAxisConfig cfg, IAxis master, Func<int, IAxis> resolve)
+        {
+            _card = card;
+            _cfg = cfg ?? new MotionAxisConfig();
+            _master = master;
+            _resolve = resolve;
+            AxisNo = _cfg.AxisNo;
+            Name = string.IsNullOrWhiteSpace(_cfg.Name) ? $"Master-{AxisNo}" : _cfg.Name;
+            Velocity = _cfg.Velocity;
+            Acceleration = _cfg.Acceleration;
+            Deceleration = _cfg.Deceleration;
+            Jerk = _cfg.Jerk;
+            if (_master != null)
+            {
+                _master.Name = Name;
+                _master.Velocity = Velocity;
+                _master.Acceleration = Acceleration;
+                _master.Deceleration = Deceleration;
+                _master.Jerk = Jerk;
+            }
+        }
+
+        public int AxisNo { get; }
+        public string Name { get; set; }
+        public double Velocity { get; set; }
+        public double Acceleration { get; set; }
+        public double Deceleration { get; set; }
+        public double Jerk { get; set; }
+
+        public event EventHandler<AxisMotionEventArgs> MotionStarted;
+        public event EventHandler<AxisMotionEventArgs> MotionCompleted;
+        public event EventHandler<AxisStateChangedEventArgs> StateChanged;
+        public event EventHandler<AxisErrorEventArgs> ErrorOccurred;
+
+        public AxisState State => _master != null ? _master.State : AxisState.Off;
+        public bool IsEnabled => _master != null && _master.IsEnabled;
+        public bool IsMoving => _master != null && _master.IsMoving;
+        public bool IsHomed => _master != null && _master.IsHomed;
+        public double CommandPosition => _master != null ? _master.CommandPosition : 0;
+        public double ActualPosition => _master != null ? _master.CommandPosition : 0;
+        public double ActualVelocity => _master != null ? _master.ActualVelocity : 0;
+
+        private IEnumerable<(IAxis Axis, double Scale)> Slaves()
+        {
+            foreach (var m in _cfg.ParseMembers())
+            {
+                if (m.AxisNo == AxisNo) continue;
+                var a = _resolve(m.AxisNo);
+                if (a != null && !ReferenceEquals(a, _master) && !(a is CombinedMotionAxis))
+                    yield return (a, m.Scale);
+            }
+        }
+
+        private void ApplyParams(IAxis axis)
+        {
+            if (axis == null) return;
+            axis.Velocity = Velocity;
+            axis.Acceleration = Acceleration;
+            axis.Deceleration = Deceleration;
+            axis.Jerk = Jerk;
+        }
+
+        private IMasterFollowHost FollowHost => _card as IMasterFollowHost;
+
+        private IDisposable SoloFollow()
+        {
+            var host = FollowHost;
+            var slaves = Slaves().Select(s => s.Axis).ToList();
+            var scope = host?.SuspendFollowRelease();
+            host?.BeginSoloMasterFollow(AxisNo, slaves);
+            return scope ?? EmptyScope.Instance;
+        }
+
+        public void Enable()
+        {
+            if (_card is IMotionSafetyHost safety && !safety.AllowEnable(out var reason))
+            {
+                OnError(reason);
+                return;
+            }
+            DriveMasterEnable(true);
+        }
+
+        public void Disable()
+        {
+            FollowHost?.ReleaseMasterFollow();
+            DriveMasterEnable(false);
+        }
+
+        private MotionAxisConfig CfgOf(int axisNo)
+        {
+            if (_card is InovanceMotionCard imc) return imc.GetAxisConfig(axisNo);
+            if (_card is SimulatedMotionCard sim) return sim.GetAxisConfig(axisNo);
+            return axisNo == AxisNo ? _cfg : new MotionAxisConfig { AxisNo = axisNo };
+        }
+
+        public void MoveTo(double position)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (_card is IMotionSafetyHost safety && !safety.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (_master == null) { OnError("主轴无效"); return; }
+            var masterCfg = CfgOf(AxisNo);
+            position = masterCfg.ClampPosition(position);
+            Velocity = Math.Abs(masterCfg.ClampVelocity(Velocity));
+            double delta = position - _master.CommandPosition;
+            using (SoloFollow())
+            {
+                ApplyParams(_master);
+                if (!DriveMasterMoveAbs(position)) { OnError("主轴定位失败"); return; }
+                FollowRelative(delta);
+            }
+            MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, position, ActualPosition));
+        }
+
+        public void MoveBy(double distance)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (_card is IMotionSafetyHost safety && !safety.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (_master == null) { OnError("主轴无效"); return; }
+            var masterCfg = CfgOf(AxisNo);
+            double target = masterCfg.ClampPosition(_master.CommandPosition + distance);
+            Velocity = Math.Abs(masterCfg.ClampVelocity(Velocity));
+            double delta = target - _master.CommandPosition;
+            using (SoloFollow())
+            {
+                ApplyParams(_master);
+                if (!DriveMasterMoveRel(delta)) { OnError("主轴相对定位失败"); return; }
+                FollowRelative(delta);
+            }
+            MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, CommandPosition, ActualPosition));
+        }
+
+        public void Jog(double velocity)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (_card is IMotionSafetyHost safety && !safety.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (_master == null) { OnError("主轴无效"); return; }
+            velocity = CfgOf(AxisNo).ClampJog(velocity, _master.CommandPosition);
+            using (SoloFollow())
+            {
+                ApplyParams(_master);
+                if (Math.Abs(velocity) < 1e-9)
+                {
+                    DriveMasterStop();
+                    return;
+                }
+                if (!DriveMasterJog(velocity)) { OnError("主轴点动失败"); return; }
+                foreach (var s in Slaves())
+                {
+                    if (!s.Axis.IsEnabled) { OnError($"从轴 {s.Axis.Name} 未使能"); return; }
+                    ApplyParams(s.Axis);
+                    double sv = CfgOf(s.Axis.AxisNo).ClampJog(velocity * s.Scale, s.Axis.CommandPosition);
+                    s.Axis.Jog(sv);
+                }
+            }
+            MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, double.NaN, ActualPosition));
+        }
+
+        public void Stop()
+        {
+            DriveMasterStop();
+            FollowHost?.ReleaseMasterFollow();
+            MotionCompleted?.Invoke(this, new AxisMotionEventArgs(AxisNo, CommandPosition, ActualPosition));
+        }
+
+        public void Home(HomeMode mode = HomeMode.Default)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (_card is IMotionSafetyHost safety && !safety.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (_master == null) { OnError("主轴无效"); return; }
+            double delta = -_master.CommandPosition;
+            using (SoloFollow())
+            {
+                ApplyParams(_master);
+                DriveMasterHome(mode);
+                FollowRelative(delta);
+            }
+            MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, 0, ActualPosition));
+        }
+
+        public void SetPosition(double position)
+        {
+            if (_master == null) return;
+            if (_card is InovanceMotionCard imc)
+                imc.SetCurrentPosition(AxisNo, position);
+            else
+                _master.SetPosition(position);
+        }
+
+        public void SetMotionParams(MotionParams parameters)
+        {
+            if (parameters == null) return;
+            Velocity = parameters.Velocity;
+            Acceleration = parameters.Acceleration;
+            Deceleration = parameters.Deceleration;
+            Jerk = parameters.Jerk;
+            ApplyParams(_master);
+        }
+
+        private void FollowRelative(double masterDelta)
+        {
+            foreach (var s in Slaves())
+            {
+                if (!s.Axis.IsEnabled) { OnError($"从轴 {s.Axis.Name} 未使能"); return; }
+                ApplyParams(s.Axis);
+                double slaveDelta = masterDelta * s.Scale;
+                double target = CfgOf(s.Axis.AxisNo).ClampPosition(s.Axis.CommandPosition + slaveDelta);
+                s.Axis.MoveTo(target);
+            }
+        }
+
+        private bool DriveMasterEnable(bool on)
+        {
+            if (_master == null) return false;
+            if (_card is InovanceMotionCard imc)
+                return imc.HardwareSetEnable(AxisNo, on);
+            if (on) _master.Enable(); else _master.Disable();
+            return true;
+        }
+
+        private bool DriveMasterMoveAbs(double position)
+        {
+            if (_card is InovanceMotionCard imc)
+                return imc.HardwareMoveAbsolute(AxisNo, position, Velocity);
+            _master.MoveTo(position);
+            return true;
+        }
+
+        private bool DriveMasterMoveRel(double distance)
+        {
+            if (_card is InovanceMotionCard imc)
+                return imc.HardwareMoveRelative(AxisNo, distance, Velocity);
+            _master.MoveBy(distance);
+            return true;
+        }
+
+        private bool DriveMasterJog(double velocity)
+        {
+            if (_card is InovanceMotionCard imc)
+                return imc.HardwareJog(AxisNo, velocity);
+            _master.Jog(velocity);
+            return true;
+        }
+
+        private void DriveMasterStop()
+        {
+            if (_card is InovanceMotionCard imc)
+                imc.HardwareStop(AxisNo);
+            else
+                _master?.Stop();
+        }
+
+        private void DriveMasterHome(HomeMode mode)
+        {
+            if (_card is InovanceMotionCard imc)
+                imc.HardwareHome(AxisNo, (int)mode);
+            else
+                _master?.Home(mode);
+        }
+
+        private void OnError(string message)
+            => ErrorOccurred?.Invoke(this, new AxisErrorEventArgs(AxisNo, message));
+
+        private sealed class EmptyScope : IDisposable
+        {
+            public static readonly EmptyScope Instance = new EmptyScope();
+            public void Dispose() { }
+        }
+    }
+}

+ 1176 - 29
TeamAAS.Core/TeamAAS.Motion/Motions/InovanceMotionCard.cs

@@ -1,8 +1,8 @@
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using TeamAAS;
 using TeamAAS.Global.Devices;
 using TeamAAS.Motion.Models;
 using System.Runtime.InteropServices;
@@ -10,58 +10,1205 @@ using System.Runtime.InteropServices;
 namespace TeamAAS.Motion.Motions
 {
     /// <summary>
-    /// 汇川IMC60运动控制卡
+    /// 汇川 IMC60G 运动控制卡(对接 IMC_Library_x64.dll)。
+    /// 流程层用索引 API;调试层用 <see cref="InovanceAxis"/>。
     /// </summary>
-    public class InovanceMotionCard : IMotionCard
+    public class InovanceMotionCard : IMotionCard, IMasterFollowHost, IMotionSafetyHost, IMotionIoHost
     {
+        private const short StopSmooth = 0;
+        private const short StopAbrupt = 1;
+        private const uint EcatMasterOp = 6;
         private readonly MotionDeviceConfig _cfg;
+        private readonly object _sync = new object();
+        private readonly MasterFollowSession _follow = new MasterFollowSession();
+        private readonly MotionSafetyGuard _guard;
+        private short _card;
+        private int _axisCount;
+        private IAxis[] _axes = Array.Empty<IAxis>();
+        private Dictionary<int, IAxis> _axisMap = new Dictionary<int, IAxis>();
+        private Dictionary<int, InovanceAxis> _hwAxes = new Dictionary<int, InovanceAxis>();
+        private Dictionary<int, MotionAxisConfig> _axisCfgs = new Dictionary<int, MotionAxisConfig>();
+        private bool _crdReady;
+        private int _crdDim;
+        private int _ecatDiCnt;
+        private int _ecatDoCnt;
 
         public string Name => _cfg.Name;
         public bool IsConnected { get; private set; }
-        public int AxisCount => _cfg.AxisCount;
+        public int AxisCount => _axisCount;
+        public bool IsEstopActive => _guard.EstopActive;
+        public bool IsStopActive => _guard.StopActive;
+        internal MotionDeviceConfig DeviceConfig => _cfg;
 
         public event EventHandler<MotionConnectionStateChangedEventArgs> ConnectionStateChanged;
         public event EventHandler<MotionErrorEventArgs> ErrorOccurred;
+        public event EventHandler SafetyChanged;
 
         public InovanceMotionCard(MotionDeviceConfig cfg)
         {
             _cfg = cfg ?? new MotionDeviceConfig();
+            _card = (short)_cfg.BoardIndex;
+            _axisCount = Math.Max(1, _cfg.AxisCount);
+            RebuildAxes();
+            _guard = new MotionSafetyGuard(
+                () => _cfg,
+                ReadDI,
+                OnSafetyEstopRise,
+                OnSafetyEstopHold,
+                OnSafetyStopRise);
+            _guard.Changed += (_, __) =>
+            {
+                try { SafetyChanged?.Invoke(this, EventArgs.Empty); }
+                catch { }
+            };
+        }
+
+        public bool AllowEnable(out string reason) => _guard.AllowEnable(out reason);
+        public bool AllowMotion(out string reason) => _guard.AllowMotion(out reason);
+
+        private bool RejectEnable()
+        {
+            if (_guard.AllowEnable(out var reason)) return false;
+            RaiseError(reason);
+            return true;
+        }
+
+        private bool RejectMotion()
+        {
+            if (_guard.AllowMotion(out var reason)) return false;
+            RaiseError(reason);
+            return true;
+        }
+
+        private void OnSafetyEstopRise()
+        {
+            if (!IsConnected) return;
+            EmergencyStop();
+        }
+
+        private void OnSafetyEstopHold()
+        {
+            if (!IsConnected) return;
+            lock (_sync)
+            {
+                if (!IsConnected) return;
+                foreach (var hw in _hwAxes.Values)
+                {
+                    try { Imc60.IMC_ServoOff(_card, (short)hw.AxisNo, 1); }
+                    catch { }
+                }
+            }
+        }
+
+        private void OnSafetyStopRise()
+        {
+            if (!IsConnected) return;
+            Stop(-1);
         }
 
         public bool Open()
         {
-            uint ret = Imc60.IMC_OpenCard((short)_cfg.BoardIndex);
-            IsConnected = ret == Imc60.EXE_SUCCESS;
-            if (IsConnected)
-                ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(true));
-            return IsConnected;
+            lock (_sync)
+            {
+                if (IsConnected) return true;
+                try
+                {
+                    uint ret = Imc60.IMC_OpenCard(_card);
+                    if (!Ok(ret, "开卡")) return false;
+
+                    ParseConfigPaths(out var deviceCfg, out var systemCfg);
+                    if (!string.IsNullOrEmpty(deviceCfg))
+                    {
+                        if (!File.Exists(deviceCfg))
+                        {
+                            RaiseError("设备配置不存在: " + deviceCfg);
+                            return FailOpen();
+                        }
+                        if (!Ok(Imc60.IMC_DownLoadDeviceConfig(_card, deviceCfg), "下载设备配置"))
+                            return FailOpen();
+                    }
+
+                    short masterCfg = 0;
+                    Imc60.IMC_GetMasterCfgXml(_card, ref masterCfg);
+                    if ((masterCfg & 0x1) != 0)
+                    {
+                        uint sts = 0;
+                        Imc60.IMC_GetEcatMasterSts(_card, ref sts);
+                        if (sts != EcatMasterOp)
+                        {
+                            if (!Ok(Imc60.IMC_ScanCardEcat(_card), "EtherCAT0 扫描"))
+                                return FailOpen();
+                        }
+                        if (!string.IsNullOrEmpty(systemCfg))
+                        {
+                            if (!File.Exists(systemCfg))
+                            {
+                                RaiseError("系统配置不存在: " + systemCfg);
+                                return FailOpen();
+                            }
+                            if (!Ok(Imc60.IMC_DownLoadSystemConfig(_card, systemCfg), "下载系统配置"))
+                                return FailOpen();
+                        }
+                    }
+                    if ((masterCfg & 0x2) != 0)
+                    {
+                        uint sts = 0;
+                        Imc60.IMC_H_GetEcatMasterSts(_card, ref sts);
+                        if (sts != EcatMasterOp)
+                        {
+                            if (!Ok(Imc60.IMC_H_ScanCardEcat(_card), "EtherCAT1 扫描"))
+                                return FailOpen();
+                        }
+                    }
+
+                    bool userAxes = _cfg.Axes != null && _cfg.Axes.Count > 0;
+                    var info = new Imc60.TMasterInfo();
+                    bool haveInfo = Imc60.IMC_GetEcatMasterInfo(_card, ref info) == Imc60.EXE_SUCCESS;
+                    if (haveInfo)
+                    {
+                        _ecatDiCnt = Math.Max(0, (int)info.diCnt);
+                        _ecatDoCnt = Math.Max(0, (int)info.doCnt);
+                    }
+                    if (!userAxes)
+                    {
+                        if (haveInfo && info.axisCnt > 0)
+                            _axisCount = info.axisCnt;
+                        else
+                            _axisCount = Math.Max(1, _cfg.AxisCount);
+                    }
+
+                    RebuildAxes();
+                    ApplyAxisBonds();
+                    ApplySafetyLimits();
+                    foreach (var hw in _hwAxes.Values)
+                    {
+                        Imc60.IMC_SetAxMvPara(_card, (short)hw.AxisNo, hw.Velocity, hw.Acceleration, hw.Deceleration);
+                        Imc60.IMC_SetAxStopDec(_card, (short)hw.AxisNo, hw.Deceleration, hw.Deceleration * 4);
+                    }
+
+                    IsConnected = true;
+                }
+                catch (DllNotFoundException)
+                {
+                    RaiseError("未找到 IMC_Library_x64.dll,请放到程序运行目录");
+                    return false;
+                }
+                catch (Exception ex)
+                {
+                    RaiseError("开卡异常: " + ex.Message);
+                    return FailOpen();
+                }
+            }
+            _guard.Start();
+            ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(true));
+            return true;
         }
 
         public void Close()
+        {
+            _guard.Stop();
+            lock (_sync)
+            {
+                if (!IsConnected) return;
+                try
+                {
+                    foreach (var hw in _hwAxes.Values)
+                        Imc60.IMC_ServoOff(_card, (short)hw.AxisNo, 1);
+                    if (_crdReady)
+                    {
+                        Imc60.IMC_CrdStop(_card, 0, StopAbrupt);
+                        Imc60.IMC_CrdDeleteMtSys(_card, 0);
+                        _crdReady = false;
+                    }
+                    Imc60.IMC_DelEcatComm(_card);
+                    Imc60.IMC_CloseCard(_card);
+                }
+                catch { }
+                _follow.Clear();
+                IsConnected = false;
+            }
+            ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(false));
+        }
+
+        void IMasterFollowHost.BeginSoloMasterFollow(int masterAxisNo, IEnumerable<IAxis> slaves)
+            => _follow.Begin(masterAxisNo, slaves);
+
+        void IMasterFollowHost.ReleaseMasterFollow() => _follow.Release();
+
+        IDisposable IMasterFollowHost.SuspendFollowRelease() => _follow.Suspend();
+
+        private void ReleaseFollowIfIndependent(int axis)
+        {
+            if (_follow.Suspended || _follow.IsReleasing) return;
+            if (axis >= 0 && TryCombined(axis, out _)) return;
+            _follow.Release();
+        }
+
+        public bool SetEnable(int axis, bool enable)
+        {
+            if (enable && RejectEnable()) return false;
+            if (!IsConnected) return false;
+            lock (_sync)
+            {
+                if (axis < 0)
+                {
+                    bool ok = true;
+                    foreach (var hw in _hwAxes.Values)
+                    {
+                        if (enable)
+                            Imc60.IMC_ClrAxSts(_card, (short)hw.AxisNo, 1);
+                        uint r = enable
+                            ? Imc60.IMC_ServoOn(_card, (short)hw.AxisNo, 1)
+                            : Imc60.IMC_ServoOff(_card, (short)hw.AxisNo, 1);
+                        if (!Ok(r, enable ? $"轴{hw.AxisNo}使能" : $"轴{hw.AxisNo}下使能"))
+                            ok = false;
+                    }
+                    return ok;
+                }
+                if (TryCombined(axis, out var combined))
+                {
+                    if (enable) combined.Enable(); else combined.Disable();
+                    return true;
+                }
+                if (!IsHardware(axis)) return false;
+                if (enable)
+                    Imc60.IMC_ClrAxSts(_card, (short)axis, 1);
+                uint ret = enable
+                    ? Imc60.IMC_ServoOn(_card, (short)axis, 1)
+                    : Imc60.IMC_ServoOff(_card, (short)axis, 1);
+                return Ok(ret, enable ? $"轴{axis}使能" : $"轴{axis}下使能");
+            }
+        }
+
+        public bool Home(int axis, int mode = 0)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !IsValidAxis(axis)) return false;
+            lock (_sync)
+            {
+                if (TryCombined(axis, out var combined))
+                {
+                    combined.Home((HomeMode)mode);
+                    return true;
+                }
+                ReleaseFollowIfIndependent(axis);
+                if (!_hwAxes.TryGetValue(axis, out var a)) return false;
+                var para = new Imc60.THomingPara
+                {
+                    homeMethod = MapHomeMethod(mode),
+                    highVel = ToUInt(Math.Abs(a.Velocity)),
+                    lowVel = ToUInt(Math.Abs(a.Velocity) * 0.2),
+                    acc = ToUInt(Math.Max(1, a.Acceleration)),
+                    offset = 0
+                };
+                if (!Ok(Imc60.IMC_StartHoming(_card, (short)axis, ref para), $"轴{axis}回零"))
+                    return false;
+                a.MarkHoming();
+                return true;
+            }
+        }
+
+        public bool IsHomed(int axis)
+        {
+            if (!IsValidAxis(axis)) return false;
+            RefreshHomeFlag(axis);
+            return _axisMap[axis].IsHomed;
+        }
+
+        public bool MoveAbsolute(int axis, double position, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !IsValidAxis(axis)) return false;
+            lock (_sync)
+            {
+                if (TryCombined(axis, out var combined))
+                {
+                    combined.Velocity = velocity;
+                    combined.MoveTo(position);
+                    return true;
+                }
+                ReleaseFollowIfIndependent(axis);
+                position = ClampAxisPosition(axis, position);
+                velocity = Math.Abs(ClampAxisVelocity(axis, velocity));
+                if (!ApplyMovePara(axis, velocity)) return false;
+                return Ok(Imc60.IMC_StartPtpMove(_card, (short)axis, position, 0), $"轴{axis}绝对定位");
+            }
+        }
+
+        public bool MoveRelative(int axis, double distance, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !IsValidAxis(axis)) return false;
+            lock (_sync)
+            {
+                if (TryCombined(axis, out var combined))
+                {
+                    combined.Velocity = velocity;
+                    combined.MoveBy(distance);
+                    return true;
+                }
+                ReleaseFollowIfIndependent(axis);
+                double current = ReadPrfPos(axis);
+                double target = ClampAxisPosition(axis, current + distance);
+                velocity = Math.Abs(ClampAxisVelocity(axis, velocity));
+                if (!ApplyMovePara(axis, velocity)) return false;
+                return Ok(Imc60.IMC_StartPtpMove(_card, (short)axis, target, 0), $"轴{axis}相对定位");
+            }
+        }
+
+        public bool JogStart(int axis, int direction, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !IsValidAxis(axis)) return false;
+            lock (_sync)
+            {
+                double vel = (direction >= 0 ? 1 : -1) * Math.Abs(velocity);
+                if (TryCombined(axis, out var combined))
+                {
+                    combined.Jog(vel);
+                    return true;
+                }
+                ReleaseFollowIfIndependent(axis);
+                vel = ClampAxisJog(axis, vel, ReadPrfPos(axis));
+                if (Math.Abs(vel) < 1e-9)
+                    return Stop(axis);
+                if (!ApplyMovePara(axis, Math.Abs(vel))) return false;
+                if (_hwAxes.TryGetValue(axis, out var hw))
+                    hw.MarkJogging(true);
+                return Ok(Imc60.IMC_StartJogMove(_card, (short)axis, vel), $"轴{axis}点动");
+            }
+        }
+
+        public bool JogStop(int axis) => Stop(axis);
+
+        public bool Stop(int axis = -1)
+        {
+            if (!IsConnected) return false;
+            lock (_sync)
+            {
+                if (axis < 0)
+                {
+                    _follow.Release();
+                    bool ok = true;
+                    foreach (var hw in _hwAxes.Values)
+                    {
+                        hw.AbortHome();
+                        if (!Ok(Imc60.IMC_StopMove(_card, (short)hw.AxisNo, StopSmooth), $"轴{hw.AxisNo}停止"))
+                            ok = false;
+                        hw.MarkJogging(false);
+                    }
+                    return ok;
+                }
+                if (TryCombined(axis, out var combined))
+                {
+                    combined.Stop();
+                    return true;
+                }
+                ReleaseFollowIfIndependent(axis);
+                if (!IsHardware(axis)) return false;
+                if (_hwAxes.TryGetValue(axis, out var a))
+                {
+                    a.AbortHome();
+                    a.MarkJogging(false);
+                }
+                return Ok(Imc60.IMC_StopMove(_card, (short)axis, StopSmooth), $"轴{axis}停止");
+            }
+        }
+
+        public AxisStatus GetAxisStatus(int axis)
+        {
+            if (!IsValidAxis(axis)) return null;
+            var logical = _axisMap[axis];
+            if (logical is CombinedMotionAxis)
+            {
+                return new AxisStatus
+                {
+                    Index = axis,
+                    Name = logical.Name,
+                    Position = logical.ActualPosition,
+                    Velocity = logical.ActualVelocity,
+                    IsMoving = logical.IsMoving,
+                    IsHomed = logical.IsHomed,
+                    IsEnabled = logical.IsEnabled
+                };
+            }
+
+            RefreshHomeFlag(axis);
+            if (logical is InovanceAxis inv)
+                inv.SyncState();
+            int sts = ReadSts(axis);
+            return new AxisStatus
+            {
+                Index = axis,
+                Name = logical.Name,
+                Position = ReadEncPos(axis),
+                Velocity = ReadEncVel(axis),
+                IsMoving = logical.IsMoving,
+                IsHomed = logical.IsHomed,
+                IsEnabled = logical.IsEnabled,
+                PositiveLimit = Has(sts, Imc60.AX_POSLMT_BIT) || Has(sts, Imc60.AX_SOFT_POSLMT_BIT),
+                NegativeLimit = Has(sts, Imc60.AX_NEGLMT_BIT) || Has(sts, Imc60.AX_SOFT_NEGLMT_BIT),
+                Alarm = Has(sts, Imc60.AX_ALARM_BIT) || Has(sts, Imc60.AX_UNLINK_BIT) || Has(sts, Imc60.AX_ERRPOS_BIT)
+            };
+        }
+
+        public List<AxisStatus> GetAllAxisStatus()
+        {
+            var list = new List<AxisStatus>(_axes.Length);
+            foreach (var a in _axes)
+                list.Add(GetAxisStatus(a.AxisNo));
+            return list;
+        }
+
+        public IAxis GetAxis(int axisNo)
+        {
+            if (!IsValidAxis(axisNo))
+                throw new ArgumentOutOfRangeException(nameof(axisNo), "轴号超出范围");
+            return _axisMap[axisNo];
+        }
+
+        public IList<IAxis> GetAxes() => _axes.ToList();
+
+        public bool MoveLinear(double[] targetPositions, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || targetPositions == null || targetPositions.Length < 2) return false;
+            lock (_sync)
+            {
+                _follow.Release();
+                int dim = targetPositions.Length >= 3 ? 3 : 2;
+                velocity = ClampInterpVelocity(velocity, dim);
+                if (!EnsureCrd(dim)) return false;
+                if (!PrepareCrd(velocity)) return false;
+
+                if (dim >= 3)
+                {
+                    var ends = new[]
+                    {
+                        ClampAxisPosition(0, targetPositions[0]),
+                        ClampAxisPosition(1, targetPositions[1]),
+                        ClampAxisPosition(2, targetPositions[2])
+                    };
+                    if (!Ok(Imc60.IMC_CrdLineXYZ(_card, 0, ends), "插补直线XYZ")) return false;
+                }
+                else
+                {
+                    var ends = new[]
+                    {
+                        ClampAxisPosition(0, targetPositions[0]),
+                        ClampAxisPosition(1, targetPositions[1])
+                    };
+                    if (!Ok(Imc60.IMC_CrdLineXY(_card, 0, ends), "插补直线XY")) return false;
+                }
+                return Ok(Imc60.IMC_CrdStart(_card, 0), "启动插补");
+            }
+        }
+
+        public bool MoveArc(double[] centerPoint, double[] targetPositions, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || centerPoint == null || targetPositions == null) return false;
+            if (centerPoint.Length < 2 || targetPositions.Length < 2) return false;
+            lock (_sync)
+            {
+                _follow.Release();
+                velocity = ClampInterpVelocity(velocity, 2);
+                if (!EnsureCrd(2)) return false;
+                if (!PrepareCrd(velocity)) return false;
+
+                var center = new[] { centerPoint[0], centerPoint[1] };
+                var ends = new[]
+                {
+                    ClampAxisPosition(0, targetPositions[0]),
+                    ClampAxisPosition(1, targetPositions[1])
+                };
+                if (!Ok(Imc60.IMC_CrdArcCenterXYPlane(_card, 0, center, ends, 0), "插补圆弧")) return false;
+                return Ok(Imc60.IMC_CrdStart(_card, 0), "启动插补");
+            }
+        }
+
+        public bool EmergencyStop()
+        {
+            if (!IsConnected) return false;
+            lock (_sync)
+            {
+                _follow.Clear();
+                Imc60.IMC_CrdStop(_card, 0, StopAbrupt);
+                bool ok = true;
+                foreach (var hw in _hwAxes.Values)
+                {
+                    hw.AbortHome();
+                    if (!Ok(Imc60.IMC_StopMove(_card, (short)hw.AxisNo, StopAbrupt), $"轴{hw.AxisNo}急停"))
+                        ok = false;
+                    hw.MarkJogging(false);
+                }
+                return ok;
+            }
+        }
+
+        public bool CanSimulateDi => false;
+
+        public int GetDiCount(MotionIoBank bank)
+        {
+            if (bank == MotionIoBank.EtherCat)
+                return _ecatDiCnt > 0 ? _ecatDiCnt : Math.Max(0, _cfg.InputCount);
+            int n = _cfg.LocalInputCount > 0 ? _cfg.LocalInputCount : 16;
+            return n;
+        }
+
+        public int GetDoCount(MotionIoBank bank)
+        {
+            if (bank == MotionIoBank.EtherCat)
+                return _ecatDoCnt > 0 ? _ecatDoCnt : Math.Max(0, _cfg.OutputCount);
+            int n = _cfg.LocalOutputCount > 0 ? _cfg.LocalOutputCount : 16;
+            return n;
+        }
+
+        public bool ReadDI(int portIndex) => ReadDI(MotionIoBank.Local, portIndex);
+
+        public bool ReadDI(MotionIoBank bank, int index)
+        {
+            if (!IsConnected || index < 0) return false;
+            short v = 0;
+            uint ret = bank == MotionIoBank.EtherCat
+                ? Imc60.IMC_GetEcatDiBit(_card, (short)index, ref v)
+                : Imc60.IMC_GetLocalDiBit(_card, (short)index, ref v);
+            return ret == Imc60.EXE_SUCCESS && v != 0;
+        }
+
+        public bool ReadDO(MotionIoBank bank, int index)
+        {
+            if (!IsConnected || index < 0) return false;
+            short v = 0;
+            uint ret = bank == MotionIoBank.EtherCat
+                ? Imc60.IMC_GetEcatDoBit(_card, (short)index, ref v)
+                : Imc60.IMC_GetLocalDoBit(_card, (short)index, ref v);
+            return ret == Imc60.EXE_SUCCESS && v != 0;
+        }
+
+        public bool WriteDO(int portIndex, bool value) => WriteDO(MotionIoBank.Local, portIndex, value);
+
+        public bool WriteDO(MotionIoBank bank, int index, bool value)
+        {
+            if (!IsConnected || index < 0) return false;
+            short bit = (short)(value ? 1 : 0);
+            uint ret = bank == MotionIoBank.EtherCat
+                ? Imc60.IMC_SetEcatDoBit(_card, (short)index, bit)
+                : Imc60.IMC_SetLocalDoBit(_card, (short)index, bit);
+            return Ok(ret, bank == MotionIoBank.EtherCat ? $"写 EtherCAT DO{index}" : $"写本地 DO{index}");
+        }
+
+        public bool WriteDI(MotionIoBank bank, int index, bool value) => false;
+
+        internal bool ApplyMovePara(int axis, double velocity)
+        {
+            if (!_hwAxes.TryGetValue(axis, out var a)) return false;
+            if (velocity > 0) a.Velocity = velocity;
+            return Ok(Imc60.IMC_SetAxMvPara(_card, (short)axis, a.Velocity, a.Acceleration, a.Deceleration), $"轴{axis}运动参数");
+        }
+
+        internal bool HardwareSetEnable(int axis, bool enable)
+        {
+            if (enable && RejectEnable()) return false;
+            if (!IsConnected || !_hwAxes.ContainsKey(axis)) return false;
+            lock (_sync)
+            {
+                if (enable)
+                    Imc60.IMC_ClrAxSts(_card, (short)axis, 1);
+                uint ret = enable
+                    ? Imc60.IMC_ServoOn(_card, (short)axis, 1)
+                    : Imc60.IMC_ServoOff(_card, (short)axis, 1);
+                return Ok(ret, enable ? $"轴{axis}使能" : $"轴{axis}下使能");
+            }
+        }
+
+        internal bool HardwareMoveAbsolute(int axis, double position, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !_hwAxes.ContainsKey(axis)) return false;
+            lock (_sync)
+            {
+                position = ClampAxisPosition(axis, position);
+                velocity = Math.Abs(ClampAxisVelocity(axis, velocity));
+                if (!ApplyMovePara(axis, velocity)) return false;
+                return Ok(Imc60.IMC_StartPtpMove(_card, (short)axis, position, 0), $"轴{axis}绝对定位");
+            }
+        }
+
+        internal bool HardwareMoveRelative(int axis, double distance, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !_hwAxes.ContainsKey(axis)) return false;
+            lock (_sync)
+            {
+                double target = ClampAxisPosition(axis, ReadPrfPos(axis) + distance);
+                velocity = Math.Abs(ClampAxisVelocity(axis, velocity));
+                if (!ApplyMovePara(axis, velocity)) return false;
+                return Ok(Imc60.IMC_StartPtpMove(_card, (short)axis, target, 0), $"轴{axis}相对定位");
+            }
+        }
+
+        internal bool HardwareJog(int axis, double velocity)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !_hwAxes.TryGetValue(axis, out var hw)) return false;
+            lock (_sync)
+            {
+                velocity = ClampAxisJog(axis, velocity, ReadPrfPos(axis));
+                if (Math.Abs(velocity) < 1e-9)
+                {
+                    hw.MarkJogging(false);
+                    return Ok(Imc60.IMC_StopMove(_card, (short)axis, StopSmooth), $"轴{axis}停止");
+                }
+                if (!ApplyMovePara(axis, Math.Abs(velocity))) return false;
+                hw.MarkJogging(true);
+                return Ok(Imc60.IMC_StartJogMove(_card, (short)axis, velocity), $"轴{axis}点动");
+            }
+        }
+
+        internal bool HardwareStop(int axis)
+        {
+            if (!IsConnected || !_hwAxes.TryGetValue(axis, out var hw)) return false;
+            lock (_sync)
+            {
+                hw.AbortHome();
+                hw.MarkJogging(false);
+                return Ok(Imc60.IMC_StopMove(_card, (short)axis, StopSmooth), $"轴{axis}停止");
+            }
+        }
+
+        internal bool HardwareHome(int axis, int mode)
+        {
+            if (RejectMotion()) return false;
+            if (!IsConnected || !_hwAxes.TryGetValue(axis, out var a)) return false;
+            lock (_sync)
+            {
+                var para = new Imc60.THomingPara
+                {
+                    homeMethod = MapHomeMethod(mode),
+                    highVel = ToUInt(Math.Abs(a.Velocity)),
+                    lowVel = ToUInt(Math.Abs(a.Velocity) * 0.2),
+                    acc = ToUInt(Math.Max(1, a.Acceleration)),
+                    offset = 0
+                };
+                if (!Ok(Imc60.IMC_StartHoming(_card, (short)axis, ref para), $"轴{axis}回零"))
+                    return false;
+                a.MarkHoming();
+                return true;
+            }
+        }
+
+        internal bool SetCurrentPosition(int axis, double position)
+            => Ok(Imc60.IMC_SetAxCurPos(_card, (short)axis, position), $"轴{axis}位置清零");
+
+        internal int ReadSts(int axis)
+        {
+            var arr = new int[1];
+            return Imc60.IMC_GetAxSts(_card, (short)axis, arr, 1) == Imc60.EXE_SUCCESS ? arr[0] : 0;
+        }
+
+        internal double ReadEncPos(int axis)
+        {
+            var arr = new double[1];
+            return Imc60.IMC_GetAxEncPos(_card, (short)axis, arr, 1) == Imc60.EXE_SUCCESS ? arr[0] : 0;
+        }
+
+        internal double ReadPrfPos(int axis)
+        {
+            var arr = new double[1];
+            return Imc60.IMC_GetAxPrfPos(_card, (short)axis, arr, 1) == Imc60.EXE_SUCCESS ? arr[0] : 0;
+        }
+
+        internal double ReadEncVel(int axis)
+        {
+            var arr = new double[1];
+            return Imc60.IMC_GetAxEncVel(_card, (short)axis, arr, 1) == Imc60.EXE_SUCCESS ? arr[0] : 0;
+        }
+
+        internal short ReadHomeStatus(int axis)
+        {
+            short sts = 0;
+            Imc60.IMC_GetHomingStatus(_card, (short)axis, ref sts);
+            return sts;
+        }
+
+        internal void FinishHome(int axis)
+        {
+            Imc60.IMC_FinishHoming(_card, (short)axis);
+        }
+
+        internal void RefreshHomeFlag(int axis)
         {
             if (!IsConnected) return;
-            Imc60.IMC_CloseCard((short)_cfg.BoardIndex);
+            if (_hwAxes.TryGetValue(axis, out var hw))
+                hw.PollHome(this);
+        }
+
+        private bool PrepareCrd(double velocity)
+        {
+            double acc = Math.Max(100, Math.Abs(velocity) * 5);
+            if (!Ok(Imc60.IMC_CrdClrData(_card, 0), "清空插补缓冲")) return false;
+            if (!Ok(Imc60.IMC_CrdSetIncMode(_card, 0, 0), "插补绝对模式")) return false;
+            if (!Ok(Imc60.IMC_CrdSetTrajVel(_card, 0, velocity), "插补速度")) return false;
+            return Ok(Imc60.IMC_CrdSetTrajAccAndDec(_card, 0, acc, acc), "插补加减速");
+        }
+
+        private bool EnsureCrd(int dim)
+        {
+            dim = Math.Max(2, Math.Min(3, dim));
+            if (_hwAxes.Count < dim) return false;
+            if (_crdReady && _crdDim == dim) return true;
+            if (_crdReady)
+            {
+                Imc60.IMC_CrdStop(_card, 0, StopAbrupt);
+                Imc60.IMC_CrdDeleteMtSys(_card, 0);
+                _crdReady = false;
+            }
+
+            var mask = new short[3];
+            mask[0] = 0;
+            mask[1] = 1;
+            mask[2] = (short)(dim > 2 ? 2 : -1);
+            var hw = _hwAxes.Values.FirstOrDefault();
+            double eStopDec = Math.Max(1000, hw != null ? hw.Deceleration * 4 : 1000);
+            if (!Ok(Imc60.IMC_CrdSetMtSys(_card, 0, mask, 100, eStopDec), "建立插补坐标系"))
+                return false;
+            _crdDim = dim;
+            _crdReady = true;
+            return true;
+        }
+
+        public void ReloadFromConfig()
+        {
+            lock (_sync)
+            {
+                RebuildAxes();
+                if (IsConnected)
+                {
+                    ApplyAxisBonds();
+                    ApplySafetyLimits();
+                }
+            }
+        }
+
+        private void ApplyAxisBonds()
+        {
+            var cfgs = MotionAxisCatalog.GetEffective(_cfg);
+            foreach (var cfg in cfgs)
+            {
+                short axType;
+                short ch = (short)Math.Max(0, cfg.OutputChannel);
+                switch (cfg.Kind)
+                {
+                    case MotionAxisKind.Physical:
+                        axType = 1;
+                        break;
+                    default:
+                        axType = -1;
+                        ch = 0;
+                        break;
+                }
+                Ok(Imc60.IMC_SetAxBondCfg(_card, (short)cfg.AxisNo, axType, ch),
+                    $"轴{cfg.AxisNo}绑定({cfg.KindText})");
+            }
+        }
+
+        internal MotionAxisConfig GetAxisConfig(int axisNo)
+        {
+            if (_axisCfgs.TryGetValue(axisNo, out var cfg) && cfg != null)
+                return cfg;
+            return new MotionAxisConfig { AxisNo = axisNo };
+        }
+
+        internal double ClampAxisPosition(int axis, double position)
+            => GetAxisConfig(axis).ClampPosition(position);
+
+        internal double ClampAxisVelocity(int axis, double velocity)
+            => GetAxisConfig(axis).ClampVelocity(velocity);
+
+        internal double ClampAxisJog(int axis, double velocity, double currentPosition)
+            => GetAxisConfig(axis).ClampJog(velocity, currentPosition);
+
+        private double ClampInterpVelocity(double velocity, int dim)
+        {
+            velocity = Math.Abs(velocity);
+            int n = Math.Max(1, dim);
+            for (int i = 0; i < n; i++)
+                velocity = Math.Abs(ClampAxisVelocity(i, velocity));
+            return velocity;
+        }
+
+        private void ApplySafetyLimits()
+        {
+            foreach (var cfg in MotionAxisCatalog.GetEffective(_cfg))
+            {
+                short en = (short)(cfg.SoftLimitEnabled ? 1 : 0);
+                if (cfg.SoftLimitEnabled)
+                {
+                    int pos = (int)Math.Round(Math.Max(cfg.SoftLimitNeg, cfg.SoftLimitPos));
+                    int neg = (int)Math.Round(Math.Min(cfg.SoftLimitNeg, cfg.SoftLimitPos));
+                    Ok(Imc60.IMC_SetAxSoftLimit(_card, (short)cfg.AxisNo, pos, neg),
+                        $"轴{cfg.AxisNo}软限位");
+                }
+                Ok(Imc60.IMC_SetAxSoftLmtsCheck(_card, (short)cfg.AxisNo, en),
+                    $"轴{cfg.AxisNo}软限位检查");
+
+                if (cfg.MaxVelocity <= 0) continue;
+                var para = new Imc60.TMtPara
+                {
+                    bgVel = 0,
+                    maxVel = cfg.MaxVelocity,
+                    maxAcc = Math.Max(1, cfg.Acceleration),
+                    maxDec = Math.Max(1, cfg.Deceleration),
+                    maxJerk = Math.Max(1, cfg.Jerk),
+                    stopDec = Math.Max(1, cfg.Deceleration),
+                    eStopDec = Math.Max(1, cfg.Deceleration * 4)
+                };
+                Ok(Imc60.IMC_SetAxMaxMtPara(_card, (short)cfg.AxisNo, ref para),
+                    $"轴{cfg.AxisNo}最高速度");
+            }
+        }
+
+        private void RebuildAxes()
+        {
+            _follow.Clear();
+            var oldHw = _hwAxes;
+            var cfgs = MotionAxisCatalog.GetEffective(_cfg);
+            _axisCfgs = new Dictionary<int, MotionAxisConfig>();
+            _hwAxes = new Dictionary<int, InovanceAxis>();
+            _axisMap = new Dictionary<int, IAxis>();
+            var view = new List<IAxis>(cfgs.Count);
+
+            foreach (var cfg in cfgs)
+            {
+                var axis = new InovanceAxis(this, cfg.AxisNo)
+                {
+                    Name = string.IsNullOrWhiteSpace(cfg.Name) ? $"Axis-{cfg.AxisNo}" : cfg.Name,
+                    Velocity = cfg.Velocity,
+                    Acceleration = cfg.Acceleration,
+                    Deceleration = cfg.Deceleration,
+                    Jerk = cfg.Jerk
+                };
+                if (oldHw != null && oldHw.TryGetValue(cfg.AxisNo, out var prev))
+                {
+                    axis.Name = string.IsNullOrWhiteSpace(cfg.Name) ? prev.Name : cfg.Name;
+                    axis.Velocity = cfg.Velocity > 0 ? cfg.Velocity : prev.Velocity;
+                    axis.Acceleration = cfg.Acceleration > 0 ? cfg.Acceleration : prev.Acceleration;
+                    axis.Deceleration = cfg.Deceleration > 0 ? cfg.Deceleration : prev.Deceleration;
+                    axis.Jerk = cfg.Jerk > 0 ? cfg.Jerk : prev.Jerk;
+                }
+                _hwAxes[cfg.AxisNo] = axis;
+                _axisMap[cfg.AxisNo] = axis;
+                _axisCfgs[cfg.AxisNo] = cfg;
+            }
+
+            foreach (var cfg in cfgs)
+            {
+                if (cfg.IsGrouped && _hwAxes.TryGetValue(cfg.AxisNo, out var master))
+                {
+                    var combined = new CombinedMotionAxis(this, cfg, master, no =>
+                        _hwAxes.TryGetValue(no, out var a) ? a : null);
+                    _axisMap[cfg.AxisNo] = combined;
+                    view.Add(combined);
+                }
+                else if (_axisMap.TryGetValue(cfg.AxisNo, out var hw))
+                {
+                    view.Add(hw);
+                }
+            }
+
+            _axes = view.ToArray();
+            _axisCount = _axes.Length;
+        }
+
+        private bool TryCombined(int axis, out CombinedMotionAxis combined)
+        {
+            combined = null;
+            if (_axisMap.TryGetValue(axis, out var a) && a is CombinedMotionAxis c)
+            {
+                combined = c;
+                return true;
+            }
+            return false;
+        }
+
+        private bool IsHardware(int axis) => _hwAxes.ContainsKey(axis);
+
+        private void ParseConfigPaths(out string deviceCfg, out string systemCfg)
+        {
+            deviceCfg = "";
+            systemCfg = "";
+            var raw = _cfg.ConnectionString?.Trim();
+            if (string.IsNullOrEmpty(raw)) return;
+            var parts = raw.Split(new[] { '|', ';' }, StringSplitOptions.RemoveEmptyEntries);
+            if (parts.Length > 0) deviceCfg = ResolvePath(parts[0].Trim());
+            if (parts.Length > 1) systemCfg = ResolvePath(parts[1].Trim());
+        }
+
+        private static string ResolvePath(string path)
+        {
+            if (string.IsNullOrWhiteSpace(path)) return "";
+            path = path.Trim().Trim('"');
+            if (File.Exists(path)) return Path.GetFullPath(path);
+            var baseDir = AppDomain.CurrentDomain.BaseDirectory ?? "";
+            var combined = Path.Combine(baseDir, path);
+            if (File.Exists(combined)) return Path.GetFullPath(combined);
+            var inConfig = Path.Combine(baseDir, "Config", path);
+            if (File.Exists(inConfig)) return Path.GetFullPath(inConfig);
+            return Path.IsPathRooted(path) ? path : Path.GetFullPath(combined);
+        }
+
+        private static short MapHomeMethod(int mode)
+        {
+            if (mode > 3) return (short)mode;
+            switch ((HomeMode)mode)
+            {
+                case HomeMode.LimitSwitch: return (short)Imc60.HOME_NLIMT;
+                case HomeMode.HomeSwitch: return (short)Imc60.HOME_NHOME_FEDGE;
+                case HomeMode.ZPhase: return (short)Imc60.HOME_NEGZINDEX;
+                default: return (short)Imc60.HOME_NLIMT_ZINDEX;
+            }
+        }
+
+        private static uint ToUInt(double v) => (uint)Math.Max(1, Math.Min(uint.MaxValue, v));
+
+        private static bool Has(int sts, uint bit) => (sts & (int)bit) != 0;
+
+        private bool IsValidAxis(int axis) => _axisMap.ContainsKey(axis);
+
+        private bool Ok(uint ret, string what)
+        {
+            if (ret == Imc60.EXE_SUCCESS) return true;
+            RaiseError($"{what}失败 (0x{ret:X8})");
+            return false;
+        }
+
+        private bool FailOpen()
+        {
+            try { Imc60.IMC_DelEcatComm(_card); } catch { }
+            try { Imc60.IMC_CloseCard(_card); } catch { }
             IsConnected = false;
-            ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(false));
+            return false;
+        }
+
+        private void RaiseError(string message)
+        {
+            try { AppLogger.Error(message, null, nameof(InovanceMotionCard)); } catch { }
+            ErrorOccurred?.Invoke(this, new MotionErrorEventArgs(message));
+        }
+    }
+
+    /// <summary>汇川卡单轴门面:实时读 SDK 状态,指令转发到卡。</summary>
+    public class InovanceAxis : IAxis
+    {
+        private readonly InovanceMotionCard _card;
+        private bool _homed;
+        private bool _homing;
+        private bool _jogging;
+        private AxisState _lastState = AxisState.Off;
+
+        public int AxisNo { get; }
+        public string Name { get; set; }
+        public double Velocity { get; set; } = 100;
+        public double Acceleration { get; set; } = 500;
+        public double Deceleration { get; set; } = 500;
+        public double Jerk { get; set; } = 1000;
+
+        public AxisState State
+        {
+            get
+            {
+                if (!_card.IsConnected) return AxisState.Off;
+                int sts = _card.ReadSts(AxisNo);
+                if ((sts & (int)Imc60.AX_ALARM_BIT) != 0 || (sts & (int)Imc60.AX_UNLINK_BIT) != 0)
+                    return AxisState.Error;
+                if ((sts & (int)Imc60.AX_SVON_BIT) == 0) return AxisState.Off;
+                if (_homing) return AxisState.Homing;
+                if (_jogging) return AxisState.ContinuousMotion;
+                if ((sts & (int)Imc60.AX_BUSY_BIT) != 0) return AxisState.DiscreteMotion;
+                return AxisState.StandStill;
+            }
+        }
+
+        public bool IsEnabled => _card.IsConnected && (_card.ReadSts(AxisNo) & (int)Imc60.AX_SVON_BIT) != 0;
+        public bool IsMoving => _card.IsConnected && ((_card.ReadSts(AxisNo) & (int)Imc60.AX_BUSY_BIT) != 0 || _jogging || _homing);
+        public bool IsHomed => _homed;
+        public double CommandPosition => _card.IsConnected ? _card.ReadPrfPos(AxisNo) : 0;
+        public double ActualPosition => _card.IsConnected ? _card.ReadEncPos(AxisNo) : 0;
+        public double ActualVelocity => _card.IsConnected ? _card.ReadEncVel(AxisNo) : 0;
+
+        public event EventHandler<AxisMotionEventArgs> MotionStarted;
+        public event EventHandler<AxisMotionEventArgs> MotionCompleted;
+        public event EventHandler<AxisStateChangedEventArgs> StateChanged;
+        public event EventHandler<AxisErrorEventArgs> ErrorOccurred;
+
+        public InovanceAxis(InovanceMotionCard card, int axisNo)
+        {
+            _card = card;
+            AxisNo = axisNo;
+            Name = $"Axis-{axisNo}";
+        }
+
+        internal void MarkHoming()
+        {
+            _homing = true;
+            _homed = false;
+            NotifyState();
+        }
+
+        internal void MarkJogging(bool on)
+        {
+            _jogging = on;
+            NotifyState();
+        }
+
+        internal void AbortHome()
+        {
+            if (!_homing) return;
+            try { _card.FinishHome(AxisNo); } catch { }
+            _homing = false;
+            NotifyState();
+        }
+
+        internal void PollHome(InovanceMotionCard card)
+        {
+            if (!_homing) return;
+            short hs = card.ReadHomeStatus(AxisNo);
+            if (hs == Imc60.HOME_IN_PROGRESS || hs == Imc60.HOME_INTERRUPTED_OR_NOT_START)
+                return;
+
+            if (hs == Imc60.HOME_SUCESS || hs == Imc60.HOME_ATTAINED_BUT_NOT_REACH)
+            {
+                card.FinishHome(AxisNo);
+                _homing = false;
+                _homed = true;
+                NotifyState();
+                MotionCompleted?.Invoke(this, new AxisMotionEventArgs(AxisNo, 0, ActualPosition));
+                return;
+            }
+
+            card.FinishHome(AxisNo);
+            _homing = false;
+            NotifyState();
+            OnError($"回零失败 (状态 {hs})");
+        }
+
+        public void Enable()
+        {
+            if (!_card.AllowEnable(out var reason))
+            {
+                ErrorOccurred?.Invoke(this, new AxisErrorEventArgs(AxisNo, reason));
+                return;
+            }
+            if (!_card.SetEnable(AxisNo, true))
+                ErrorOccurred?.Invoke(this, new AxisErrorEventArgs(AxisNo, "使能失败"));
+            NotifyState();
+        }
+
+        public void Disable()
+        {
+            _jogging = false;
+            AbortHome();
+            _card.Stop(AxisNo);
+            if (!_card.SetEnable(AxisNo, false))
+                ErrorOccurred?.Invoke(this, new AxisErrorEventArgs(AxisNo, "下使能失败"));
+            NotifyState();
         }
 
-        public bool SetEnable(int axis, bool enable) => throw new NotImplementedException();
-        public bool Home(int axis, int mode = 0) => throw new NotImplementedException();
-        public bool IsHomed(int axis) => throw new NotImplementedException();
-        public bool MoveAbsolute(int axis, double position, double velocity) => throw new NotImplementedException();
-        public bool MoveRelative(int axis, double distance, double velocity) => throw new NotImplementedException();
-        public bool JogStart(int axis, int direction, double velocity) => throw new NotImplementedException();
-        public bool JogStop(int axis) => throw new NotImplementedException();
-        public bool Stop(int axis = -1) => throw new NotImplementedException();
-        public AxisStatus GetAxisStatus(int axis) => throw new NotImplementedException();
-        public List<AxisStatus> GetAllAxisStatus() => throw new NotImplementedException();
-        public IAxis GetAxis(int axisNo) => throw new NotImplementedException();
-        public IList<IAxis> GetAxes() => throw new NotImplementedException();
-        public bool MoveLinear(double[] targetPositions, double velocity) => throw new NotImplementedException();
-        public bool MoveArc(double[] centerPoint, double[] targetPositions, double velocity) => throw new NotImplementedException();
-        public bool EmergencyStop() => throw new NotImplementedException();
-        public bool ReadDI(int portIndex) => throw new NotImplementedException();
-        public bool WriteDO(int portIndex, bool value) => throw new NotImplementedException();
+        public void MoveTo(double position)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (!_card.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (!IsEnabled) { OnError("轴未使能,无法运动"); return; }
+            if (_card.MoveAbsolute(AxisNo, position, Velocity))
+            {
+                NotifyState();
+                MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, position, ActualPosition));
+            }
+            else
+                OnError("绝对定位失败");
+        }
+
+        public void MoveBy(double distance)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (!_card.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (!IsEnabled) { OnError("轴未使能,无法运动"); return; }
+            if (_card.MoveRelative(AxisNo, distance, Velocity))
+            {
+                NotifyState();
+                MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, ActualPosition + distance, ActualPosition));
+            }
+            else
+                OnError("相对定位失败");
+        }
+
+        public void Jog(double velocity)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (!_card.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (!IsEnabled) { OnError("轴未使能,无法点动"); return; }
+            int dir = velocity >= 0 ? 1 : -1;
+            if (_card.JogStart(AxisNo, dir, Math.Abs(velocity)))
+            {
+                NotifyState();
+                MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, double.NaN, ActualPosition));
+            }
+            else
+                OnError("点动失败");
+        }
+
+        public void Stop()
+        {
+            AbortHome();
+            _jogging = false;
+            _card.Stop(AxisNo);
+            NotifyState();
+            MotionCompleted?.Invoke(this, new AxisMotionEventArgs(AxisNo, CommandPosition, ActualPosition));
+        }
+
+        public void Home(HomeMode mode = HomeMode.Default)
+        {
+            if (!_card.IsConnected) { OnError("运动卡未连接"); return; }
+            if (!_card.AllowMotion(out var reason)) { OnError(reason); return; }
+            if (!IsEnabled) { OnError("轴未使能,无法回零"); return; }
+            if (_card.Home(AxisNo, (int)mode))
+            {
+                NotifyState();
+                MotionStarted?.Invoke(this, new AxisMotionEventArgs(AxisNo, 0, ActualPosition));
+            }
+            else
+                OnError("回零失败");
+        }
+
+        public void SetPosition(double position)
+        {
+            if (!_card.SetCurrentPosition(AxisNo, position))
+                OnError("设置当前位置失败");
+        }
+
+        public void SetMotionParams(MotionParams parameters)
+        {
+            if (parameters == null) return;
+            Velocity = parameters.Velocity;
+            Acceleration = parameters.Acceleration;
+            Deceleration = parameters.Deceleration;
+            Jerk = parameters.Jerk;
+            _card.ApplyMovePara(AxisNo, Velocity);
+        }
+
+        private void OnError(string message) => ErrorOccurred?.Invoke(this, new AxisErrorEventArgs(AxisNo, message));
+
+        internal void SyncState() => NotifyState();
+
+        private void NotifyState()
+        {
+            var now = State;
+            if (now == _lastState) return;
+            var old = _lastState;
+            _lastState = now;
+            StateChanged?.Invoke(this, new AxisStateChangedEventArgs(AxisNo, old, now));
+        }
     }
 
 

+ 91 - 0
TeamAAS.Core/TeamAAS.Motion/Motions/MasterFollowSession.cs

@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using TeamAAS.Global.Devices;
+
+namespace TeamAAS.Motion.Motions
+{
+    /// <summary>
+    /// 主从会话:仅在单独驱动某条主轴时挂上从轴;其它运动先解除。
+    /// </summary>
+    internal interface IMasterFollowHost
+    {
+        void BeginSoloMasterFollow(int masterAxisNo, IEnumerable<IAxis> slaves);
+        void ReleaseMasterFollow();
+        IDisposable SuspendFollowRelease();
+    }
+
+    internal sealed class MasterFollowSession
+    {
+        private int _master = -1;
+        private readonly List<IAxis> _slaves = new List<IAxis>();
+        private int _suspend;
+        private bool _releasing;
+
+        public int ActiveMaster => _master;
+        public bool Suspended => _suspend > 0;
+        public bool IsReleasing => _releasing;
+
+        public IDisposable Suspend()
+        {
+            _suspend++;
+            return new Releaser(() =>
+            {
+                if (_suspend > 0) _suspend--;
+            });
+        }
+
+        public void Begin(int masterAxisNo, IEnumerable<IAxis> slaves)
+        {
+            if (masterAxisNo != _master)
+                Release();
+            _master = masterAxisNo;
+            _slaves.Clear();
+            if (slaves == null) return;
+            foreach (var s in slaves)
+            {
+                if (s == null) continue;
+                if (!s.IsEnabled) s.Enable();
+                _slaves.Add(s);
+            }
+        }
+
+        public void Release()
+        {
+            if (_releasing || _master < 0) return;
+            _releasing = true;
+            try
+            {
+                foreach (var s in _slaves.ToList())
+                {
+                    try { s.Stop(); } catch { }
+                }
+            }
+            finally
+            {
+                _master = -1;
+                _slaves.Clear();
+                _releasing = false;
+            }
+        }
+
+        public void Clear()
+        {
+            _master = -1;
+            _slaves.Clear();
+            _suspend = 0;
+            _releasing = false;
+        }
+
+        private sealed class Releaser : IDisposable
+        {
+            private Action _onDispose;
+            public Releaser(Action onDispose) => _onDispose = onDispose;
+            public void Dispose()
+            {
+                _onDispose?.Invoke();
+                _onDispose = null;
+            }
+        }
+    }
+}

+ 136 - 0
TeamAAS.Core/TeamAAS.Motion/Motions/MotionSafetyGuard.cs

@@ -0,0 +1,136 @@
+using System;
+using System.Threading;
+using TeamAAS.Global.Devices;
+using TeamAAS.Motion.Models;
+
+namespace TeamAAS.Motion.Motions
+{
+    /// <summary>
+    /// 连接后轮询急停/停止 DI。急停边沿急停+持续下使能;停止边沿停轴并禁止新运动。
+    /// </summary>
+    internal sealed class MotionSafetyGuard : IDisposable
+    {
+        private readonly Func<MotionDeviceConfig> _cfg;
+        private readonly Func<MotionIoBank, int, bool> _readDi;
+        private readonly Action _onEstopRise;
+        private readonly Action _onEstopHold;
+        private readonly Action _onStopRise;
+        private readonly object _sync = new object();
+        private Timer _timer;
+        private bool _estop;
+        private bool _stop;
+
+        public bool EstopActive { get { lock (_sync) return _estop; } }
+        public bool StopActive { get { lock (_sync) return _stop; } }
+
+        public event EventHandler Changed;
+
+        public MotionSafetyGuard(
+            Func<MotionDeviceConfig> cfg,
+            Func<MotionIoBank, int, bool> readDi,
+            Action onEstopRise,
+            Action onEstopHold,
+            Action onStopRise)
+        {
+            _cfg = cfg;
+            _readDi = readDi;
+            _onEstopRise = onEstopRise;
+            _onEstopHold = onEstopHold;
+            _onStopRise = onStopRise;
+        }
+
+        public void Start()
+        {
+            Stop();
+            _timer = new Timer(_ => Poll(), null, 30, 30);
+        }
+
+        public void Stop()
+        {
+            var t = _timer;
+            _timer = null;
+            t?.Dispose();
+            lock (_sync)
+            {
+                _estop = false;
+                _stop = false;
+            }
+        }
+
+        public bool AllowEnable(out string reason)
+        {
+            if (EstopActive)
+            {
+                reason = "急停有效:禁止使能";
+                return false;
+            }
+            reason = null;
+            return true;
+        }
+
+        public bool AllowMotion(out string reason)
+        {
+            if (EstopActive)
+            {
+                reason = "急停有效:禁止运动";
+                return false;
+            }
+            if (StopActive)
+            {
+                reason = "停止信号有效:禁止运动";
+                return false;
+            }
+            reason = null;
+            return true;
+        }
+
+        public void Dispose() => Stop();
+
+        private void Poll()
+        {
+            MotionDeviceConfig cfg;
+            try { cfg = _cfg(); }
+            catch { return; }
+            if (cfg == null) return;
+
+            bool estop = ReadActive(cfg.EstopDiBank, cfg.EstopDiIndex, cfg.EstopActiveHigh);
+            bool stop = ReadActive(cfg.StopDiBank, cfg.StopDiIndex, cfg.StopActiveHigh);
+
+            bool estopRise, stopRise, changed;
+            lock (_sync)
+            {
+                estopRise = estop && !_estop;
+                stopRise = stop && !_stop;
+                changed = estop != _estop || stop != _stop;
+                _estop = estop;
+                _stop = stop;
+            }
+
+            try
+            {
+                if (estopRise)
+                    _onEstopRise?.Invoke();
+                if (estop)
+                    _onEstopHold?.Invoke();
+                else if (stopRise)
+                    _onStopRise?.Invoke();
+            }
+            catch { }
+
+            if (changed)
+            {
+                try { Changed?.Invoke(this, EventArgs.Empty); }
+                catch { }
+            }
+        }
+
+        private bool ReadActive(MotionIoBank bank, int di, bool activeHigh)
+        {
+            if (di < 0) return false;
+            bool bit;
+            try { bit = _readDi(bank, di); }
+            catch { return false; }
+            return activeHigh ? bit : !bit;
+        }
+    }
+}

+ 435 - 79
TeamAAS.Core/TeamAAS.Motion/Motions/SimulatedMotionCard.cs

@@ -10,58 +10,224 @@ namespace TeamAAS.Motion.Motions
     /// <summary>
     /// 仿真运动卡:无硬件环境下的全功能实现(联调/演示/单元测试用)。
     /// </summary>
-    public class SimulatedMotionCard : IMotionCard
+    public class SimulatedMotionCard : IMotionCard, IMasterFollowHost, IMotionSimulation, IMotionSafetyHost, IMotionIoHost
     {
         private readonly MotionDeviceConfig _cfg;
-        private readonly int _axisCount;
-        private readonly SimulatedAxis[] _axes;
-        private readonly bool[] _simulatedDi = new bool[100];
-        private readonly bool[] _simulatedDo = new bool[100];
+        private int _axisCount;
+        private IAxis[] _axes = Array.Empty<IAxis>();
+        private Dictionary<int, IAxis> _axisMap = new Dictionary<int, IAxis>();
+        private Dictionary<int, SimulatedAxis> _hwAxes = new Dictionary<int, SimulatedAxis>();
+        private Dictionary<int, MotionAxisConfig> _axisCfgs = new Dictionary<int, MotionAxisConfig>();
+        private readonly MasterFollowSession _follow = new MasterFollowSession();
+        private readonly MotionSafetyGuard _guard;
+        private readonly bool[] _localDi = new bool[64];
+        private readonly bool[] _localDo = new bool[64];
+        private readonly bool[] _ecatDi = new bool[256];
+        private readonly bool[] _ecatDo = new bool[256];
         private Timer _updateTimer;
         private bool _isConnected;
         private readonly object _lock = new object();
         private const double UpdateIntervalMs = 10;
 
-        private static readonly string[] DefaultAxisNames = { "X", "Y", "Z", "R" };
-
         public string Name => _cfg.Name;
         public bool IsConnected => _isConnected;
         public int AxisCount => _axisCount;
+        public bool IsEstopActive => _guard.EstopActive;
+        public bool IsStopActive => _guard.StopActive;
+        internal MotionDeviceConfig DeviceConfig => _cfg;
 
         public event EventHandler<MotionConnectionStateChangedEventArgs> ConnectionStateChanged;
         public event EventHandler<MotionErrorEventArgs> ErrorOccurred;
+        public event EventHandler SafetyChanged;
 
         public SimulatedMotionCard(MotionDeviceConfig cfg)
         {
             _cfg = cfg ?? new MotionDeviceConfig();
-            _axisCount = Math.Max(1, _cfg.AxisCount);
-            _axes = new SimulatedAxis[_axisCount];
-            for (int i = 0; i < _axisCount; i++)
+            RebuildAxes();
+            _guard = new MotionSafetyGuard(
+                () => _cfg,
+                ReadDI,
+                OnSafetyEstopRise,
+                OnSafetyEstopHold,
+                OnSafetyStopRise);
+            _guard.Changed += (_, __) =>
             {
-                var axis = new SimulatedAxis(i);
-                axis.Name = i < DefaultAxisNames.Length ? DefaultAxisNames[i] : $"Axis-{i}";
-                _axes[i] = axis;
+                try { SafetyChanged?.Invoke(this, EventArgs.Empty); }
+                catch { }
+            };
+        }
+
+        public bool AllowEnable(out string reason) => _guard.AllowEnable(out reason);
+        public bool AllowMotion(out string reason) => _guard.AllowMotion(out reason);
+
+        private bool RejectEnable()
+        {
+            if (_guard.AllowEnable(out var reason)) return false;
+            RaiseError(reason);
+            return true;
+        }
+
+        private bool RejectMotion()
+        {
+            if (_guard.AllowMotion(out var reason)) return false;
+            RaiseError(reason);
+            return true;
+        }
+
+        private void RaiseError(string message)
+        {
+            ErrorOccurred?.Invoke(this, new MotionErrorEventArgs(message));
+        }
+
+        private void OnSafetyEstopRise()
+        {
+            if (!_isConnected) return;
+            EmergencyStop();
+        }
+
+        private void OnSafetyEstopHold()
+        {
+            if (!_isConnected) return;
+            SimulatedAxis[] axes;
+            lock (_lock)
+            {
+                if (!_isConnected) return;
+                axes = _hwAxes.Values.ToArray();
             }
+            foreach (var a in axes) a.Disable();
+        }
+
+        private void OnSafetyStopRise()
+        {
+            if (!_isConnected) return;
+            Stop(-1);
+        }
+
+        public bool CanSimulateDi => true;
+
+        public int GetDiCount(MotionIoBank bank)
+            => bank == MotionIoBank.EtherCat
+                ? ClampCount(_cfg.InputCount, _ecatDi.Length, 16)
+                : ClampCount(_cfg.LocalInputCount, _localDi.Length, 16);
+
+        public int GetDoCount(MotionIoBank bank)
+            => bank == MotionIoBank.EtherCat
+                ? ClampCount(_cfg.OutputCount, _ecatDo.Length, 16)
+                : ClampCount(_cfg.LocalOutputCount, _localDo.Length, 16);
+
+        private static int ClampCount(int configured, int max, int fallback)
+        {
+            if (configured <= 0) return Math.Min(fallback, max);
+            return Math.Min(configured, max);
+        }
+
+        private static bool[] BankDi(MotionIoBank bank, bool[] local, bool[] ecat)
+            => bank == MotionIoBank.EtherCat ? ecat : local;
 
-            for (int i = 0; i < _simulatedDi.Length; i++)
-                _simulatedDi[i] = i % 2 == 0;
+        public bool ReadDI(int portIndex) => ReadDI(MotionIoBank.Local, portIndex);
+
+        public bool ReadDI(MotionIoBank bank, int index)
+        {
+            var bits = BankDi(bank, _localDi, _ecatDi);
+            if (index < 0 || index >= bits.Length) return false;
+            return bits[index];
+        }
+
+        public bool ReadDO(MotionIoBank bank, int index)
+        {
+            var bits = BankDi(bank, _localDo, _ecatDo);
+            if (index < 0 || index >= bits.Length) return false;
+            return bits[index];
+        }
+
+        public bool WriteDO(int portIndex, bool value) => WriteDO(MotionIoBank.Local, portIndex, value);
+
+        public bool WriteDO(MotionIoBank bank, int index, bool value)
+        {
+            var bits = BankDi(bank, _localDo, _ecatDo);
+            if (index < 0 || index >= bits.Length) return false;
+            bits[index] = value;
+            return true;
+        }
+
+        public bool WriteDI(MotionIoBank bank, int index, bool value)
+        {
+            var bits = BankDi(bank, _localDi, _ecatDi);
+            if (index < 0 || index >= bits.Length) return false;
+            bits[index] = value;
+            return true;
+        }
+
+        public void ReloadFromConfig()
+        {
+            lock (_lock)
+            {
+                _follow.Clear();
+                RebuildAxes();
+            }
         }
 
         public bool Open()
         {
             lock (_lock)
             {
-                if (_isConnected) return true;
+                if (_isConnected)
+                {
+                    EnsureTimerUnlocked();
+                    return true;
+                }
                 _isConnected = true;
-                _updateTimer = new Timer(UpdateAxes, null, Timeout.Infinite, Timeout.Infinite);
+                EnsureTimerUnlocked();
             }
-            _updateTimer.Change(0, (int)UpdateIntervalMs);
+            _guard.Start();
             ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(true));
             return true;
         }
 
+        public void Simulate(double deltaSeconds)
+        {
+            if (deltaSeconds <= 0) return;
+            SimulatedAxis[] axes;
+            lock (_lock)
+            {
+                if (!_isConnected) return;
+                EnsureTimerUnlocked();
+                axes = _hwAxes.Values.ToArray();
+            }
+            foreach (var axis in axes)
+                axis.Update(deltaSeconds);
+        }
+
+        public void FinishDiscreteMoves()
+        {
+            SimulatedAxis[] axes;
+            lock (_lock)
+            {
+                if (!_isConnected) return;
+                axes = _hwAxes.Values.ToArray();
+            }
+            double dt = 0;
+            foreach (var axis in axes)
+                dt = Math.Max(dt, axis.RemainingDiscreteSeconds());
+            if (dt > 0)
+                Simulate(dt + 1e-6);
+            else
+            {
+                foreach (var axis in axes)
+                    axis.SnapDiscreteIfIdleVelocity();
+            }
+        }
+
+        private void EnsureTimerUnlocked()
+        {
+            if (!_isConnected) return;
+            if (_updateTimer != null) return;
+            _updateTimer = new Timer(UpdateAxes, null, 0, (int)UpdateIntervalMs);
+        }
+
         public void Close()
         {
+            _guard.Stop();
             Timer timerToDispose = null;
             lock (_lock)
             {
@@ -69,66 +235,131 @@ namespace TeamAAS.Motion.Motions
                 _isConnected = false;
                 timerToDispose = _updateTimer;
                 _updateTimer = null;
+                _follow.Clear();
             }
             timerToDispose?.Dispose();
             ConnectionStateChanged?.Invoke(this, new MotionConnectionStateChangedEventArgs(false));
         }
 
+        void IMasterFollowHost.BeginSoloMasterFollow(int masterAxisNo, IEnumerable<IAxis> slaves)
+            => _follow.Begin(masterAxisNo, slaves);
+
+        void IMasterFollowHost.ReleaseMasterFollow() => _follow.Release();
+
+        IDisposable IMasterFollowHost.SuspendFollowRelease() => _follow.Suspend();
+
+        internal MotionAxisConfig GetAxisConfig(int axisNo)
+        {
+            if (_axisCfgs.TryGetValue(axisNo, out var cfg) && cfg != null)
+                return cfg;
+            return new MotionAxisConfig { AxisNo = axisNo };
+        }
+
+        private void ReleaseFollowIfIndependent(int axis)
+        {
+            if (_follow.Suspended || _follow.IsReleasing) return;
+            if (axis >= 0 && TryCombined(axis, out _)) return;
+            _follow.Release();
+        }
+
         public bool SetEnable(int axis, bool enable)
         {
+            if (enable && RejectEnable()) return false;
             if (!_isConnected) return false;
             if (axis < 0)
             {
-                foreach (var a in _axes) { if (enable) a.Enable(); else a.Disable(); }
+                foreach (var a in _hwAxes.Values) { if (enable) a.Enable(); else a.Disable(); }
                 return true;
             }
-            if (!IsValidAxis(axis)) return false;
-            if (enable) _axes[axis].Enable(); else _axes[axis].Disable();
+            if (TryCombined(axis, out var combined))
+            {
+                if (enable) combined.Enable(); else combined.Disable();
+                return true;
+            }
+            if (!_hwAxes.TryGetValue(axis, out var hw)) return false;
+            if (enable) hw.Enable(); else hw.Disable();
             return true;
         }
 
         public bool Home(int axis, int mode = 0)
         {
+            if (RejectMotion()) return false;
             if (!_isConnected || !IsValidAxis(axis)) return false;
-            _axes[axis].Home((HomeMode)mode);
+            if (TryCombined(axis, out var combined))
+            {
+                combined.Home((HomeMode)mode);
+                return true;
+            }
+            ReleaseFollowIfIndependent(axis);
+            if (!_hwAxes.TryGetValue(axis, out var hw)) return false;
+            hw.Home((HomeMode)mode);
             return true;
         }
 
         public bool IsHomed(int axis)
         {
             if (!IsValidAxis(axis)) return false;
-            return _axes[axis].IsHomed;
+            return _axisMap[axis].IsHomed;
         }
 
         public bool MoveAbsolute(int axis, double position, double velocity)
         {
+            if (RejectMotion()) return false;
             if (!_isConnected || !IsValidAxis(axis)) return false;
-            _axes[axis].Velocity = velocity;
-            _axes[axis].MoveTo(position);
+            if (TryCombined(axis, out var combined))
+            {
+                combined.Velocity = velocity;
+                combined.MoveTo(position);
+                return true;
+            }
+            ReleaseFollowIfIndependent(axis);
+            var a = _axisMap[axis];
+            var cfg = GetAxisConfig(axis);
+            a.Velocity = Math.Abs(cfg.ClampVelocity(velocity));
+            a.MoveTo(cfg.ClampPosition(position));
             return true;
         }
 
         public bool MoveRelative(int axis, double distance, double velocity)
         {
+            if (RejectMotion()) return false;
             if (!_isConnected || !IsValidAxis(axis)) return false;
-            _axes[axis].Velocity = velocity;
-            _axes[axis].MoveBy(distance);
+            if (TryCombined(axis, out var combined))
+            {
+                combined.Velocity = velocity;
+                combined.MoveBy(distance);
+                return true;
+            }
+            ReleaseFollowIfIndependent(axis);
+            var a = _axisMap[axis];
+            var cfg = GetAxisConfig(axis);
+            a.Velocity = Math.Abs(cfg.ClampVelocity(velocity));
+            double target = cfg.ClampPosition(a.CommandPosition + distance);
+            a.MoveTo(target);
             return true;
         }
 
         public bool JogStart(int axis, int direction, double velocity)
         {
+            if (RejectMotion()) return false;
             if (!_isConnected || !IsValidAxis(axis)) return false;
+            if (TryCombined(axis, out var combined))
+            {
+                combined.Jog((direction >= 0 ? 1 : -1) * Math.Abs(velocity));
+                return true;
+            }
+            ReleaseFollowIfIndependent(axis);
             var dir = direction >= 0 ? 1 : -1;
-            _axes[axis].Jog(dir * Math.Abs(velocity));
+            var axisObj = _axisMap[axis];
+            double vel = GetAxisConfig(axis).ClampJog(dir * Math.Abs(velocity), axisObj.CommandPosition);
+            axisObj.Jog(vel);
             return true;
         }
 
         public bool JogStop(int axis)
         {
             if (!IsValidAxis(axis)) return false;
-            _axes[axis].Stop();
-            return true;
+            return Stop(axis);
         }
 
         public bool Stop(int axis = -1)
@@ -136,18 +367,25 @@ namespace TeamAAS.Motion.Motions
             if (!_isConnected) return false;
             if (axis < 0)
             {
-                foreach (var a in _axes) a.Stop();
+                _follow.Release();
+                foreach (var a in _hwAxes.Values) a.Stop();
                 return true;
             }
             if (!IsValidAxis(axis)) return false;
-            _axes[axis].Stop();
+            if (TryCombined(axis, out var combined))
+            {
+                combined.Stop();
+                return true;
+            }
+            ReleaseFollowIfIndependent(axis);
+            _axisMap[axis].Stop();
             return true;
         }
 
         public AxisStatus GetAxisStatus(int axis)
         {
             if (!IsValidAxis(axis)) return null;
-            var a = _axes[axis];
+            var a = _axisMap[axis];
             return new AxisStatus
             {
                 Index = axis,
@@ -162,9 +400,9 @@ namespace TeamAAS.Motion.Motions
 
         public List<AxisStatus> GetAllAxisStatus()
         {
-            var list = new List<AxisStatus>(_axisCount);
-            for (int i = 0; i < _axisCount; i++)
-                list.Add(GetAxisStatus(i));
+            var list = new List<AxisStatus>(_axes.Length);
+            foreach (var a in _axes)
+                list.Add(GetAxisStatus(a.AxisNo));
             return list;
         }
 
@@ -172,71 +410,109 @@ namespace TeamAAS.Motion.Motions
         {
             if (!IsValidAxis(axisNo))
                 throw new ArgumentOutOfRangeException(nameof(axisNo), "轴号超出范围");
-            return _axes[axisNo];
+            return _axisMap[axisNo];
         }
 
-        public IList<IAxis> GetAxes() => _axes.Cast<IAxis>().ToList();
+        public IList<IAxis> GetAxes() => _axes.ToList();
 
         public bool MoveLinear(double[] targetPositions, double velocity)
         {
+            if (RejectMotion()) return false;
             if (!_isConnected || targetPositions == null) return false;
-            if (targetPositions.Length > _axisCount) return false;
+            _follow.Release();
+            var hw = _hwAxes.Values.OrderBy(a => a.AxisNo).ToList();
+            if (targetPositions.Length > hw.Count) return false;
             for (int i = 0; i < targetPositions.Length; i++)
             {
-                _axes[i].Velocity = velocity;
-                _axes[i].MoveTo(targetPositions[i]);
+                var cfg = GetAxisConfig(hw[i].AxisNo);
+                hw[i].Velocity = Math.Abs(cfg.ClampVelocity(velocity));
+                hw[i].MoveTo(cfg.ClampPosition(targetPositions[i]));
             }
             return true;
         }
 
         public bool MoveArc(double[] centerPoint, double[] targetPositions, double velocity)
-        {
-            if (!_isConnected || targetPositions == null) return false;
-            if (targetPositions.Length > _axisCount) return false;
-            for (int i = 0; i < targetPositions.Length; i++)
-            {
-                _axes[i].Velocity = velocity;
-                _axes[i].MoveTo(targetPositions[i]);
-            }
-            return true;
-        }
+            => MoveLinear(targetPositions, velocity);
 
         public bool EmergencyStop()
         {
             if (!_isConnected) return false;
-            foreach (var axis in _axes) axis.Stop();
+            _follow.Clear();
+            foreach (var axis in _hwAxes.Values) axis.Stop();
             return true;
         }
 
-        public bool ReadDI(int portIndex)
+        private bool IsValidAxis(int axis) => _axisMap.ContainsKey(axis);
+
+        private bool TryCombined(int axis, out CombinedMotionAxis combined)
         {
-            if (portIndex < 0 || portIndex >= _simulatedDi.Length) return false;
-            return _simulatedDi[portIndex];
+            combined = null;
+            if (_axisMap.TryGetValue(axis, out var a) && a is CombinedMotionAxis c)
+            {
+                combined = c;
+                return true;
+            }
+            return false;
         }
 
-        public bool WriteDO(int portIndex, bool value)
+        private void RebuildAxes()
         {
-            if (portIndex < 0 || portIndex >= _simulatedDo.Length) return false;
-            _simulatedDo[portIndex] = value;
-            return true;
-        }
+            _follow.Clear();
+            var oldHw = _hwAxes;
+            var cfgs = MotionAxisCatalog.GetEffective(_cfg);
+            _hwAxes = new Dictionary<int, SimulatedAxis>();
+            _axisCfgs = new Dictionary<int, MotionAxisConfig>();
+            _axisMap = new Dictionary<int, IAxis>();
+            var view = new List<IAxis>(cfgs.Count);
 
-        private bool IsValidAxis(int axis) => axis >= 0 && axis < _axisCount;
+            foreach (var cfg in cfgs)
+            {
+                var axis = new SimulatedAxis(this, cfg.AxisNo)
+                {
+                    Name = string.IsNullOrWhiteSpace(cfg.Name) ? $"Axis-{cfg.AxisNo}" : cfg.Name,
+                    Velocity = cfg.Velocity,
+                    Acceleration = cfg.Acceleration,
+                    Deceleration = cfg.Deceleration,
+                    Jerk = cfg.Jerk
+                };
+                if (oldHw != null && oldHw.TryGetValue(cfg.AxisNo, out var prev))
+                {
+                    axis.SetPosition(prev.ActualPosition);
+                }
+                _hwAxes[cfg.AxisNo] = axis;
+                _axisMap[cfg.AxisNo] = axis;
+                _axisCfgs[cfg.AxisNo] = cfg;
+                axis.BindSafety(cfg);
+            }
 
-        private void UpdateAxes(object state)
-        {
-            lock (_lock)
+            foreach (var cfg in cfgs)
             {
-                if (!_isConnected) return;
-                double deltaTime = UpdateIntervalMs / 1000.0;
-                foreach (var axis in _axes)
-                    axis.Update(deltaTime);
+                if (cfg.IsGrouped && _hwAxes.TryGetValue(cfg.AxisNo, out var master))
+                {
+                    var combined = new CombinedMotionAxis(this, cfg, master, no =>
+                        _hwAxes.TryGetValue(no, out var a) ? a : null);
+                    _axisMap[cfg.AxisNo] = combined;
+                    view.Add(combined);
+                }
+                else if (_axisMap.TryGetValue(cfg.AxisNo, out var hw))
+                {
+                    view.Add(hw);
+                }
             }
+
+            _axes = view.ToArray();
+            _axisCount = _axes.Length;
+        }
+
+        private void UpdateAxes(object state)
+        {
+            Simulate(UpdateIntervalMs / 1000.0);
         }
     }
 
     public class SimulatedAxis : IAxis
     {
+        private readonly SimulatedMotionCard _owner;
         private readonly object _lock = new object();
         private AxisState _state = AxisState.Off;
         private bool _isEnabled;
@@ -251,6 +527,7 @@ namespace TeamAAS.Motion.Motions
         private double _decel = 500.0;
         private double _jerk = 1000.0;
         private double _jogVelocity;
+        private MotionAxisConfig _safety = new MotionAxisConfig();
 
         public int AxisNo { get; }
         public string Name { get; set; }
@@ -273,12 +550,42 @@ namespace TeamAAS.Motion.Motions
         public event EventHandler<AxisStateChangedEventArgs> StateChanged;
         public event EventHandler<AxisErrorEventArgs> ErrorOccurred;
 
-        public SimulatedAxis(int axisNo)
+        public SimulatedAxis(SimulatedMotionCard owner, int axisNo)
         {
+            _owner = owner;
             AxisNo = axisNo;
             Name = $"Axis-{axisNo}";
         }
 
+        internal void BindSafety(MotionAxisConfig cfg)
+        {
+            _safety = cfg ?? new MotionAxisConfig { AxisNo = AxisNo };
+        }
+
+        internal double RemainingDiscreteSeconds()
+        {
+            lock (_lock)
+            {
+                if (!_isMoving || _state != AxisState.DiscreteMotion) return 0;
+                if (_velocity < 1e-9) return 0;
+                return Math.Abs(_targetPos - _actualPos) / _velocity;
+            }
+        }
+
+        internal void SnapDiscreteIfIdleVelocity()
+        {
+            lock (_lock)
+            {
+                if (!_isMoving || _state != AxisState.DiscreteMotion) return;
+                _actualPos = _targetPos;
+                _commandPos = _targetPos;
+                _actualVel = 0;
+                _isMoving = false;
+                SetState(AxisState.StandStill);
+                OnMotionCompleted(new AxisMotionEventArgs(AxisNo, _targetPos, _actualPos));
+            }
+        }
+
         internal void Update(double deltaTime)
         {
             lock (_lock)
@@ -289,8 +596,18 @@ namespace TeamAAS.Motion.Motions
                 if (_state == AxisState.ContinuousMotion && Math.Abs(_jogVelocity) > 0.0001)
                 {
                     double delta = _jogVelocity * deltaTime;
-                    _commandPos += delta;
-                    _actualPos += delta;
+                    double next = _safety.ClampPosition(_actualPos + delta);
+                    if (Math.Abs(next - _actualPos) < 1e-9)
+                    {
+                        _jogVelocity = 0;
+                        _actualVel = 0;
+                        SetState(AxisState.StandStill);
+                        _isMoving = false;
+                        OnMotionCompleted(new AxisMotionEventArgs(AxisNo, _actualPos, _actualPos));
+                        return;
+                    }
+                    _commandPos = next;
+                    _actualPos = next;
                     _actualVel = _jogVelocity;
                     return;
                 }
@@ -310,34 +627,56 @@ namespace TeamAAS.Motion.Motions
                         return;
                     }
 
-                    double speed = Math.Sign(remaining) * _velocity;
+                    double speed = Math.Sign(remaining) * Math.Max(_velocity, 1e-6);
                     double step = speed * deltaTime;
-                    if (Math.Abs(step) > Math.Abs(remaining))
+                    if (Math.Abs(step) > Math.Abs(remaining) || _velocity < 1e-9)
                         step = remaining;
                     _actualPos += step;
                     _commandPos = _actualPos;
-                    _actualVel = step / deltaTime;
+                    _actualVel = deltaTime > 1e-12 ? step / deltaTime : 0;
                 }
             }
         }
 
         private void StartMoveTo(double target)
         {
+            if (_owner != null && !_owner.AllowMotion(out var reason))
+            {
+                OnError(reason);
+                return;
+            }
             lock (_lock)
             {
                 if (!_isEnabled) { OnError("轴未使能,无法运动"); return; }
                 if (_state == AxisState.Error) { OnError("轴处于错误状态"); return; }
 
-                _targetPos = target;
+                _velocity = Math.Abs(_safety.ClampVelocity(_velocity));
+                _targetPos = _safety.ClampPosition(target);
+                _jogVelocity = 0;
+                if (Math.Abs(_targetPos - _actualPos) < 0.0001 || _velocity < 1e-9)
+                {
+                    _actualPos = _targetPos;
+                    _commandPos = _targetPos;
+                    _actualVel = 0;
+                    _isMoving = false;
+                    SetState(AxisState.StandStill);
+                    OnMotionCompleted(new AxisMotionEventArgs(AxisNo, _targetPos, _actualPos));
+                    return;
+                }
+
                 SetState(AxisState.DiscreteMotion);
                 _isMoving = true;
-                _jogVelocity = 0;
-                OnMotionStarted(new AxisMotionEventArgs(AxisNo, target, _actualPos));
+                OnMotionStarted(new AxisMotionEventArgs(AxisNo, _targetPos, _actualPos));
             }
         }
 
         public void Enable()
         {
+            if (_owner != null && !_owner.AllowEnable(out var reason))
+            {
+                OnError(reason);
+                return;
+            }
             lock (_lock)
             {
                 if (_isEnabled) return;
@@ -363,14 +702,26 @@ namespace TeamAAS.Motion.Motions
 
         public void Jog(double velocity)
         {
+            if (_owner != null && !_owner.AllowMotion(out var reason))
+            {
+                OnError(reason);
+                return;
+            }
             lock (_lock)
             {
                 if (!_isEnabled) { OnError("轴未使能,无法点动"); return; }
                 if (_state == AxisState.Error) { OnError("轴处于错误状态"); return; }
-                _jogVelocity = velocity;
+                _jogVelocity = _safety.ClampJog(velocity, _actualPos);
+                if (Math.Abs(_jogVelocity) < 1e-9)
+                {
+                    _isMoving = false;
+                    _actualVel = 0;
+                    SetState(AxisState.StandStill);
+                    return;
+                }
                 SetState(AxisState.ContinuousMotion);
                 _isMoving = true;
-                _actualVel = velocity;
+                _actualVel = _jogVelocity;
                 OnMotionStarted(new AxisMotionEventArgs(AxisNo, double.NaN, _actualPos));
             }
         }
@@ -390,6 +741,11 @@ namespace TeamAAS.Motion.Motions
 
         public void Home(HomeMode mode = HomeMode.Default)
         {
+            if (_owner != null && !_owner.AllowMotion(out var reason))
+            {
+                OnError(reason);
+                return;
+            }
             lock (_lock)
             {
                 if (!_isEnabled) { OnError("轴未使能,无法回零"); return; }

+ 71 - 4
TeamAAS.Core/TeamAAS.Motion/UI/MotionDebugView.xaml

@@ -42,10 +42,24 @@
             <Setter Property="Margin" Value="0,0,8,0"/>
             <Setter Property="MinHeight" Value="72"/>
         </Style>
+        <Style x:Key="IoBitButton" TargetType="ToggleButton">
+            <Setter Property="Foreground" Value="White"/>
+            <Setter Property="BorderThickness" Value="0"/>
+            <Setter Property="Cursor" Value="Hand"/>
+            <Setter Property="Template">
+                <Setter.Value>
+                    <ControlTemplate TargetType="ToggleButton">
+                        <Border Background="{TemplateBinding Background}" CornerRadius="4"
+                                Padding="2" Opacity="{TemplateBinding Opacity}">
+                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
+                        </Border>
+                    </ControlTemplate>
+                </Setter.Value>
+            </Setter>
+        </Style>
     </UserControl.Resources>
 
-    <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="4">
-        <DockPanel>
+    <DockPanel Margin="4">
             <DockPanel DockPanel.Dock="Top" Margin="4,2,4,10">
                 <TextBlock DockPanel.Dock="Left" Text="{loc:Translate '运动调试'}"
                            FontSize="15" FontWeight="Bold" VerticalAlignment="Center"
@@ -85,7 +99,10 @@
                 </DockPanel>
             </Border>
 
-            <Grid>
+            <TabControl x:Name="TabsMain">
+                <TabItem Header="{loc:Translate '轴'}">
+                    <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="4,8,4,4">
+                        <Grid>
                 <Grid.ColumnDefinitions>
                     <ColumnDefinition Width="1.1*"/>
                     <ColumnDefinition Width="16"/>
@@ -94,6 +111,18 @@
 
                 <!-- 左侧:实时状态 -->
                 <StackPanel Grid.Column="0">
+                    <Border x:Name="BannerSafety" CornerRadius="6" Padding="16,18" Margin="0,0,0,10"
+                            Background="#B71C1C" Visibility="Collapsed">
+                        <StackPanel>
+                            <TextBlock x:Name="TxtSafetyHeadline" Text="急停有效"
+                                       FontSize="26" FontWeight="Bold" Foreground="White"/>
+                            <TextBlock x:Name="TxtSafetyHint"
+                                       Text="所有轴已下使能,手动/自动使能与运动均被禁止。"
+                                       FontSize="14" FontWeight="SemiBold" Foreground="#FFECB3"
+                                       Margin="0,8,0,0" TextWrapping="Wrap"/>
+                        </StackPanel>
+                    </Border>
+
                     <Border x:Name="BannerState" CornerRadius="6" Padding="14,16" Margin="0,0,0,10" Background="#616161">
                         <StackPanel>
                             <TextBlock x:Name="TxtAxisTitle" Text="---" FontSize="12" Foreground="#E0E0E0" Margin="0,0,0,6"/>
@@ -240,6 +269,44 @@
                     </StackPanel>
                 </StackPanel>
             </Grid>
+                    </ScrollViewer>
+                </TabItem>
+
+                <TabItem Header="{loc:Translate 'IO'}">
+                    <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="4,8,4,4">
+                        <StackPanel x:Name="IoRoot" IsEnabled="False">
+                            <TextBlock x:Name="TxtIoHint"
+                                       Text="{loc:Translate '本地端子与 EtherCAT 扩展点号相互独立。仿真卡可点击 DI 模拟输入;真实卡 DI 只读,DO 点击翻转。急停/停止点会标红或橙色。'}"
+                                       TextWrapping="Wrap" FontSize="12" Margin="0,0,0,12"
+                                       Foreground="{DynamicResource SecondaryTextBrush}"/>
+
+                            <Border Style="{StaticResource SectionCard}">
+                                <StackPanel>
+                                    <TextBlock Text="{loc:Translate '本地 DI'}" Style="{StaticResource SectionTitle}"/>
+                                    <WrapPanel x:Name="PanelLocalDi"/>
+                                </StackPanel>
+                            </Border>
+                            <Border Style="{StaticResource SectionCard}">
+                                <StackPanel>
+                                    <TextBlock Text="{loc:Translate '本地 DO'}" Style="{StaticResource SectionTitle}"/>
+                                    <WrapPanel x:Name="PanelLocalDo"/>
+                                </StackPanel>
+                            </Border>
+                            <Border Style="{StaticResource SectionCard}">
+                                <StackPanel>
+                                    <TextBlock Text="{loc:Translate 'EtherCAT DI'}" Style="{StaticResource SectionTitle}"/>
+                                    <WrapPanel x:Name="PanelEcatDi"/>
+                                </StackPanel>
+                            </Border>
+                            <Border Style="{StaticResource SectionCard}">
+                                <StackPanel>
+                                    <TextBlock Text="{loc:Translate 'EtherCAT DO'}" Style="{StaticResource SectionTitle}"/>
+                                    <WrapPanel x:Name="PanelEcatDo"/>
+                                </StackPanel>
+                            </Border>
+                        </StackPanel>
+                    </ScrollViewer>
+                </TabItem>
+            </TabControl>
         </DockPanel>
-    </ScrollViewer>
 </UserControl>

+ 259 - 7
TeamAAS.Core/TeamAAS.Motion/UI/MotionDebugView.xaml.cs

@@ -2,21 +2,27 @@ using System;
 using System.Globalization;
 using System.Windows;
 using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
 using System.Windows.Input;
 using System.Windows.Media;
 using TeamAAS.Global.Devices;
+using TeamAAS.Motion.Models;
+using TeamAAS.Motion.Motions;
 
 namespace TeamAAS.Motion
 {
     /// <summary>
-    /// 运动卡通用调试页:轴选择 + 使能/回零/点动/定位/急停。
-    /// 仿真卡与后续真实卡共用;绑定 <see cref="IMotionCard"/> 实例。
+    /// 运动卡通用调试页:轴选择 + 使能/回零/点动/定位/急停 + 本地/EtherCAT IO。
     /// </summary>
     public partial class MotionDebugView : UserControl
     {
         private IMotionCard _motion;
+        private IMotionSafetyHost _safety;
+        private IMotionIoHost _io;
         private IAxis _selectedAxis;
         private bool _jogActive;
+        private bool _updatingIo;
+        private int _ioLayoutKey = int.MinValue;
         private readonly System.Windows.Threading.DispatcherTimer _refreshTimer;
 
         public MotionDebugView()
@@ -27,37 +33,64 @@ namespace TeamAAS.Motion
                 Interval = TimeSpan.FromMilliseconds(200)
             };
             _refreshTimer.Tick += (_, __) => RefreshStatus();
-            Unloaded += (_, __) => _refreshTimer.Stop();
+            Unloaded += (_, __) =>
+            {
+                _refreshTimer.Stop();
+                UnhookSafety();
+            };
         }
 
         public void Bind(object motion)
         {
+            UnhookSafety();
             _motion = motion as IMotionCard;
+            _safety = _motion as IMotionSafetyHost;
+            _io = _motion as IMotionIoHost;
             _selectedAxis = null;
             _jogActive = false;
+            _ioLayoutKey = int.MinValue;
             CmbAxis.ItemsSource = null;
 
             if (_motion == null)
             {
                 OpsRoot.IsEnabled = false;
+                IoRoot.IsEnabled = false;
                 _refreshTimer.Stop();
                 TxtStatus.Text = "---";
                 TxtMessage.Text = "未绑定运动卡";
                 ClearAxisMetrics();
                 SetConnDot(false);
+                ApplySafetyBanner();
+                RebuildIoPanels();
                 return;
             }
 
+            if (_safety != null)
+                _safety.SafetyChanged += OnSafetyChanged;
+
             var axes = _motion.GetAxes();
             CmbAxis.ItemsSource = axes;
             if (axes != null && axes.Count > 0)
                 CmbAxis.SelectedIndex = 0;
 
             OpsRoot.IsEnabled = _motion.IsConnected;
+            IoRoot.IsEnabled = _motion.IsConnected;
             _refreshTimer.Start();
             RefreshStatus();
         }
 
+        private void UnhookSafety()
+        {
+            if (_safety != null)
+                _safety.SafetyChanged -= OnSafetyChanged;
+            _safety = null;
+        }
+
+        private void OnSafetyChanged(object sender, EventArgs e)
+        {
+            Dispatcher.BeginInvoke(new Action(RefreshStatus));
+        }
+
         private IAxis CurrentAxis
         {
             get
@@ -100,6 +133,11 @@ namespace TeamAAS.Motion
                 Msg($"轴未使能,无法{action}");
                 return false;
             }
+            if (_safety != null && !_safety.AllowMotion(out var reason))
+            {
+                Msg(reason);
+                return false;
+            }
             return true;
         }
 
@@ -119,7 +157,10 @@ namespace TeamAAS.Motion
                 TxtStatus.Text = connected ? "已连接" : "未连接";
                 SetConnDot(connected);
                 OpsRoot.IsEnabled = connected;
+                IoRoot.IsEnabled = connected && _io != null;
                 RefreshAxisInfo();
+                RebuildIoPanels();
+                RefreshIoBits();
             }
             catch { }
         }
@@ -139,16 +180,62 @@ namespace TeamAAS.Motion
             TxtVelocity.Text = axis.ActualVelocity.ToString("F3");
 
             ApplyBanner(axis);
+            ApplySafetyBanner();
             SetTile(TileEnable, TxtEnable, axis.IsEnabled, "已使能", "未使能", "#2E7D32", "#C62828");
             SetTile(TileMoving, TxtMoving, axis.IsMoving, "运动中", "静止", "#EF6C00", "#757575");
             SetTile(TileHomed, TxtHomed, axis.IsHomed, "已回零", "未回零", "#1565C0", "#757575");
 
             var enabled = axis.IsEnabled;
-            BtnEnable.IsEnabled = !enabled;
+            bool estop = _safety != null && _safety.IsEstopActive;
+            bool stop = _safety != null && _safety.IsStopActive;
+            BtnEnable.IsEnabled = !enabled && !estop;
             BtnDisable.IsEnabled = enabled;
             BtnStop.IsEnabled = axis.IsMoving;
-            MotionOps.IsEnabled = enabled;
-            TxtNeedEnable.Visibility = enabled ? Visibility.Collapsed : Visibility.Visible;
+            MotionOps.IsEnabled = enabled && !estop && !stop;
+            if (estop)
+            {
+                TxtNeedEnable.Text = "急停有效:禁止使能与运动";
+                TxtNeedEnable.Visibility = Visibility.Visible;
+            }
+            else if (stop)
+            {
+                TxtNeedEnable.Text = "停止信号有效:禁止运动";
+                TxtNeedEnable.Visibility = Visibility.Visible;
+            }
+            else
+            {
+                TxtNeedEnable.Text = "当前未使能,请先点击「使能」后再点动或定位";
+                TxtNeedEnable.Visibility = enabled ? Visibility.Collapsed : Visibility.Visible;
+            }
+        }
+
+        private void ApplySafetyBanner()
+        {
+            if (_safety == null || _motion == null || !_motion.IsConnected)
+            {
+                BannerSafety.Visibility = Visibility.Collapsed;
+                return;
+            }
+
+            if (_safety.IsEstopActive)
+            {
+                BannerSafety.Visibility = Visibility.Visible;
+                BannerSafety.Background = BrushOf("#B71C1C");
+                TxtSafetyHeadline.Text = "急停有效";
+                TxtSafetyHint.Text = "所有轴已下使能。手动调试与流程自动使能/运动均无法成功,请先复位急停按钮。";
+                return;
+            }
+
+            if (_safety.IsStopActive)
+            {
+                BannerSafety.Visibility = Visibility.Visible;
+                BannerSafety.Background = BrushOf("#E65100");
+                TxtSafetyHeadline.Text = "停止信号有效";
+                TxtSafetyHint.Text = "所有轴禁止运动。手动调试与流程自动定位/点动/回零均无法成功,请先松开停止按钮。";
+                return;
+            }
+
+            BannerSafety.Visibility = Visibility.Collapsed;
         }
 
         private void ApplyBanner(IAxis axis)
@@ -163,6 +250,18 @@ namespace TeamAAS.Motion
                 hint = "请先在上方连接控制卡";
                 color = "#616161";
             }
+            else if (_safety != null && _safety.IsEstopActive)
+            {
+                headline = "急停有效";
+                hint = "禁止使能与运动,请先复位急停";
+                color = "#B71C1C";
+            }
+            else if (_safety != null && _safety.IsStopActive)
+            {
+                headline = "停止信号有效";
+                hint = "禁止运动,请先松开停止按钮";
+                color = "#E65100";
+            }
             else if (axis.State == AxisState.Error)
             {
                 headline = "轴故障";
@@ -222,6 +321,7 @@ namespace TeamAAS.Motion
             BtnStop.IsEnabled = false;
             MotionOps.IsEnabled = false;
             TxtNeedEnable.Visibility = Visibility.Collapsed;
+            ApplySafetyBanner();
         }
 
         private static void SetTile(Border tile, TextBlock label, bool on, string onText, string offText, string onColor, string offColor)
@@ -242,10 +342,15 @@ namespace TeamAAS.Motion
         {
             var axis = CurrentAxis;
             if (axis == null) { Msg("未选择轴"); return; }
+            if (_safety != null && !_safety.AllowEnable(out var reason))
+            {
+                Msg(reason);
+                return;
+            }
             try
             {
                 axis.Enable();
-                Msg("已使能");
+                Msg(axis.IsEnabled ? "已使能" : "使能失败");
                 RefreshAxisInfo();
             }
             catch (Exception ex) { Msg("使能失败: " + ex.Message); }
@@ -386,6 +491,153 @@ namespace TeamAAS.Motion
             }
             catch (Exception ex) { Msg("清零失败: " + ex.Message); }
         }
+
+        private MotionDeviceConfig CurrentIoConfig
+        {
+            get
+            {
+                if (_motion is SimulatedMotionCard sim) return sim.DeviceConfig;
+                if (_motion is InovanceMotionCard imc) return imc.DeviceConfig;
+                return null;
+            }
+        }
+
+        private void RebuildIoPanels()
+        {
+            int diL = _io == null ? 0 : _io.GetDiCount(MotionIoBank.Local);
+            int doL = _io == null ? 0 : _io.GetDoCount(MotionIoBank.Local);
+            int diE = _io == null ? 0 : _io.GetDiCount(MotionIoBank.EtherCat);
+            int doE = _io == null ? 0 : _io.GetDoCount(MotionIoBank.EtherCat);
+            int key = diL | (doL << 8) | (diE << 16) | (doE << 24);
+            if (_io != null && _io.CanSimulateDi) key ^= 0x40000000;
+            if (key == _ioLayoutKey) return;
+            _ioLayoutKey = key;
+
+            FillIoPanel(PanelLocalDi, MotionIoBank.Local, false, diL);
+            FillIoPanel(PanelLocalDo, MotionIoBank.Local, true, doL);
+            FillIoPanel(PanelEcatDi, MotionIoBank.EtherCat, false, diE);
+            FillIoPanel(PanelEcatDo, MotionIoBank.EtherCat, true, doE);
+        }
+
+        private void FillIoPanel(WrapPanel panel, MotionIoBank bank, bool isDo, int count)
+        {
+            panel.Children.Clear();
+            if (count <= 0)
+            {
+                panel.Children.Add(new TextBlock
+                {
+                    Text = "无",
+                    FontSize = 12,
+                    Foreground = BrushOf("#9E9E9E"),
+                    Margin = new Thickness(0, 4, 0, 0)
+                });
+                return;
+            }
+
+            string prefix = isDo ? "DO" : "DI";
+            bool canWrite = isDo || (_io != null && _io.CanSimulateDi);
+            for (int i = 0; i < count; i++)
+            {
+                var btn = new ToggleButton
+                {
+                    Content = prefix + i,
+                    Width = 52,
+                    Height = 34,
+                    Margin = new Thickness(0, 0, 6, 6),
+                    FontSize = 11,
+                    Tag = new IoBitTag { Bank = bank, IsOutput = isDo, Index = i, CanWrite = canWrite },
+                    Cursor = canWrite ? Cursors.Hand : Cursors.Arrow
+                };
+                btn.Style = (Style)FindResource("IoBitButton");
+                btn.Click += IoBit_Click;
+                panel.Children.Add(btn);
+            }
+        }
+
+        private void RefreshIoBits()
+        {
+            if (_io == null) return;
+            _updatingIo = true;
+            try
+            {
+                RefreshIoPanel(PanelLocalDi, MotionIoBank.Local, false);
+                RefreshIoPanel(PanelLocalDo, MotionIoBank.Local, true);
+                RefreshIoPanel(PanelEcatDi, MotionIoBank.EtherCat, false);
+                RefreshIoPanel(PanelEcatDo, MotionIoBank.EtherCat, true);
+            }
+            finally { _updatingIo = false; }
+        }
+
+        private void RefreshIoPanel(WrapPanel panel, MotionIoBank bank, bool isDo)
+        {
+            foreach (var child in panel.Children)
+            {
+                var btn = child as ToggleButton;
+                if (btn == null) continue;
+                var tag = btn.Tag as IoBitTag;
+                if (tag == null) continue;
+                bool on = isDo ? _io.ReadDO(bank, tag.Index) : _io.ReadDI(bank, tag.Index);
+                btn.IsChecked = on;
+                string role = SafetyRole(bank, tag.Index, isDo);
+                if (role == "estop")
+                    btn.Background = BrushOf(on ? "#B71C1C" : "#7F1D1D");
+                else if (role == "stop")
+                    btn.Background = BrushOf(on ? "#E65100" : "#BF360C");
+                else
+                    btn.Background = BrushOf(on ? "#2E7D32" : "#757575");
+                btn.Foreground = Brushes.White;
+                btn.ToolTip = role == "estop" ? "急停 DI" : role == "stop" ? "停止 DI" : (isDo ? "输出" : "输入");
+            }
+        }
+
+        private string SafetyRole(MotionIoBank bank, int index, bool isDo)
+        {
+            if (isDo) return null;
+            var cfg = CurrentIoConfig;
+            if (cfg == null) return null;
+            if (cfg.EstopDiIndex == index && cfg.EstopDiBank == bank) return "estop";
+            if (cfg.StopDiIndex == index && cfg.StopDiBank == bank) return "stop";
+            return null;
+        }
+
+        private void IoBit_Click(object sender, RoutedEventArgs e)
+        {
+            if (_updatingIo) return;
+            var btn = sender as ToggleButton;
+            var tag = btn == null ? null : btn.Tag as IoBitTag;
+            if (tag == null || _io == null || _motion == null || !_motion.IsConnected)
+                return;
+
+            bool on = btn.IsChecked == true;
+            bool ok;
+            if (tag.IsOutput)
+            {
+                ok = _io.WriteDO(tag.Bank, tag.Index, on);
+                Msg(ok ? $"{BankName(tag.Bank)} DO{tag.Index} = {(on ? 1 : 0)}" : "写 DO 失败");
+            }
+            else if (tag.CanWrite)
+            {
+                ok = _io.WriteDI(tag.Bank, tag.Index, on);
+                Msg(ok ? $"{BankName(tag.Bank)} DI{tag.Index} = {(on ? 1 : 0)}" : "写 DI 失败");
+            }
+            else
+            {
+                RefreshIoBits();
+                return;
+            }
+            if (!ok) RefreshIoBits();
+        }
+
+        private static string BankName(MotionIoBank bank)
+            => bank == MotionIoBank.EtherCat ? "EtherCAT" : "本地";
+
+        private sealed class IoBitTag
+        {
+            public MotionIoBank Bank;
+            public bool IsOutput;
+            public int Index;
+            public bool CanWrite;
+        }
     }
 
     /// <summary>运动卡通用调试页工厂。</summary>

+ 150 - 0
TeamAAS.Plugins/Plugins.Motion/Core/MotionAxisNoConverter.cs

@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using Plugins.Motion.Models;
+using TeamAAS.Core;
+using TeamAAS.FlowEngine;
+using TeamAAS.Motion.Models;
+
+namespace Plugins.Motion.Core
+{
+    /// <summary>
+    /// 流程属性面板「轴」下拉:选项来自当前节点已选运动卡的轴配置。
+    /// </summary>
+    public class MotionAxisNoConverter : StringConverter
+    {
+        public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
+
+        public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
+
+        public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
+        {
+            var labels = GetAxes(GetModel(context))
+                .Select(a => a.Label)
+                .ToList();
+            // PropertyGridLib 在 Count==0 时不会把属性渲染成下拉;未选卡时给占位项,选卡后由面板刷新/展开时重取。
+            if (labels.Count == 0)
+                labels.Add("请先选择运动卡");
+            return new StandardValuesCollection(labels);
+        }
+
+        internal static MotionAxisModel GetModel(ITypeDescriptorContext context)
+        {
+            if (context?.Instance is MotionAxisModel m) return m;
+            try
+            {
+                return PluginLoader.Instance.SelectFlow?.PluginModel?.GetModel as MotionAxisModel;
+            }
+            catch
+            {
+                return null;
+            }
+        }
+
+        internal static bool AllowAllAxes(MotionAxisModel model) =>
+            model is MotionEnableModel || model is MotionDisableModel || model is MotionStopModel;
+
+        internal static List<(int No, string Label)> GetAxes(MotionAxisModel model)
+        {
+            var list = new List<(int, string)>();
+            bool allowAll = AllowAllAxes(model);
+            if (allowAll)
+                list.Add((-1, "-1  全部轴"));
+
+            string cardName = model?.MotionCardName;
+            if (string.IsNullOrWhiteSpace(cardName))
+                return list;
+
+            try
+            {
+                var mgr = CoreManager.Motion;
+                if (mgr == null) return list;
+
+                var cfg = mgr.Configs?.FirstOrDefault(c =>
+                    string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase));
+                if (cfg != null)
+                {
+                    foreach (var a in MotionAxisCatalog.GetEffective(cfg))
+                    {
+                        if (a == null) continue;
+                        list.Add((a.AxisNo, FormatAxis(a.AxisNo, a.Name, a.KindText)));
+                    }
+                    return list;
+                }
+
+                var card = mgr.GetMotion(cardName);
+                if (card == null) return list;
+
+                var axes = card.GetAxes();
+                if (axes != null && axes.Count > 0)
+                {
+                    foreach (var a in axes.OrderBy(x => x.AxisNo))
+                        list.Add((a.AxisNo, FormatAxis(a.AxisNo, a.Name, null)));
+                    return list;
+                }
+
+                for (int i = 0; i < Math.Max(0, card.AxisCount); i++)
+                    list.Add((i, FormatAxis(i, $"Axis-{i}", null)));
+            }
+            catch { }
+
+            return list;
+        }
+
+        internal static string Format(int axisNo, MotionAxisModel model)
+        {
+            foreach (var a in GetAxes(model))
+            {
+                if (a.No == axisNo) return a.Label;
+            }
+            return axisNo.ToString(CultureInfo.InvariantCulture);
+        }
+
+        internal static int Parse(string text, MotionAxisModel model)
+        {
+            if (string.IsNullOrWhiteSpace(text)) return 0;
+            text = text.Trim();
+            if (text == "请先选择运动卡") return 0;
+
+            var axes = GetAxes(model);
+            foreach (var a in axes)
+            {
+                if (string.Equals(a.Label, text, StringComparison.OrdinalIgnoreCase))
+                    return a.No;
+            }
+
+            int space = text.IndexOf(' ');
+            string head = space > 0 ? text.Substring(0, space).Trim() : text;
+            if (int.TryParse(head, NumberStyles.Integer, CultureInfo.InvariantCulture, out int byNo) ||
+                int.TryParse(head, NumberStyles.Integer, CultureInfo.CurrentCulture, out byNo))
+                return byNo;
+
+            foreach (var a in axes)
+            {
+                var name = AxisNameOf(a.Label);
+                if (!string.IsNullOrEmpty(name) &&
+                    string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
+                    return a.No;
+            }
+
+            return 0;
+        }
+
+        private static string FormatAxis(int no, string name, string kindText)
+        {
+            string n = string.IsNullOrWhiteSpace(name) ? $"Axis-{no}" : name.Trim();
+            if (string.IsNullOrWhiteSpace(kindText) || kindText == "实轴")
+                return $"{no}  {n}";
+            return $"{no}  {n}  {kindText}";
+        }
+
+        private static string AxisNameOf(string label)
+        {
+            if (string.IsNullOrWhiteSpace(label)) return "";
+            var parts = label.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+            return parts.Length >= 2 ? parts[1] : "";
+        }
+    }
+}

+ 17 - 4
TeamAAS.Plugins/Plugins.Motion/Models/MotionPluginModels.cs

@@ -11,16 +11,29 @@ namespace Plugins.Motion.Models
     public class MotionAxisModel : BasePluginModel
     {
         [DisplayName("运动卡")]
-        [Description("从已配置的运动卡中选择")]
+        [Description("从已配置的运动卡中选择;选中后「轴」下拉会填充该卡的轴列表")]
         [Category("I.设备")]
+        [RefreshProperties(RefreshProperties.All)]
         [TypeConverter(typeof(MotionCardNameConverter))]
         public string MotionCardName { get; set; } = "";
 
-        [DisplayName("轴号")]
-        [Description("轴号从 0 开始;使能/下使能/停止可用 -1 表示全部轴")]
-        [Category("I.设备")]
+        [Browsable(false)]
         public int AxisNo { get; set; } = 0;
 
+        [DisplayName("轴")]
+        [Description("根据所选运动卡的轴配置自动列出;使能/下使能/停止可用「-1  全部轴」")]
+        [Category("I.设备")]
+        [TypeConverter(typeof(MotionAxisNoConverter))]
+        public string Axis
+        {
+            get => MotionAxisNoConverter.Format(AxisNo, this);
+            set
+            {
+                if (string.IsNullOrWhiteSpace(value)) return;
+                AxisNo = MotionAxisNoConverter.Parse(value, this);
+            }
+        }
+
         [DisplayName("未连接时自动连接")]
         [Description("运动卡未连接时先尝试 Open")]
         [Category("II.执行选项")]

+ 2 - 0
TeamAAS.Plugins/Plugins.Motion/MotionEnablePlugins.cs

@@ -38,6 +38,8 @@ namespace Plugins.Motion
                 if (card == null) return Fail(err);
                 err = MotionPluginHelper.EnsureConnected(card, Model.AutoConnect);
                 if (err != null) return Fail(err);
+                err = MotionPluginHelper.CheckEnableAllowed(card);
+                if (err != null) return Fail(err);
 
                 Log(3, $"轴使能 卡={card.Name} 轴={Model.AxisNo}");
                 if (!card.SetEnable(Model.AxisNo, true))

+ 6 - 0
TeamAAS.Plugins/Plugins.Motion/MotionMovePlugins.cs

@@ -42,6 +42,8 @@ namespace Plugins.Motion
                 if (err != null) return Fail(err);
                 err = MotionPluginHelper.EnsureEnabled(card, Model.AxisNo, Model.AutoEnable);
                 if (err != null) return Fail(err);
+                err = MotionPluginHelper.CheckMotionAllowed(card);
+                if (err != null) return Fail(err);
 
                 Log(3, $"轴回零 卡={card.Name} 轴={Model.AxisNo} 模式={Model.HomeMode}");
                 if (!card.Home(Model.AxisNo, Model.HomeMode))
@@ -97,6 +99,8 @@ namespace Plugins.Motion
                 if (err != null) return Fail(err);
                 err = MotionPluginHelper.EnsureEnabled(card, Model.AxisNo, Model.AutoEnable);
                 if (err != null) return Fail(err);
+                err = MotionPluginHelper.CheckMotionAllowed(card);
+                if (err != null) return Fail(err);
 
                 double target = GetValue(Model.TargetPosition);
                 double vel = GetValue(Model.Velocity);
@@ -157,6 +161,8 @@ namespace Plugins.Motion
                 if (err != null) return Fail(err);
                 err = MotionPluginHelper.EnsureEnabled(card, Model.AxisNo, Model.AutoEnable);
                 if (err != null) return Fail(err);
+                err = MotionPluginHelper.CheckMotionAllowed(card);
+                if (err != null) return Fail(err);
 
                 double dist = GetValue(Model.Distance);
                 double vel = GetValue(Model.Velocity);

+ 20 - 0
TeamAAS.Plugins/Plugins.Motion/MotionPluginHelper.cs

@@ -36,6 +36,8 @@ namespace Plugins.Motion
 
         public static string EnsureEnabled(IMotionCard card, int axis, bool autoEnable)
         {
+            var lockErr = CheckEnableAllowed(card);
+            if (lockErr != null) return lockErr;
             if (axis < 0)
             {
                 if (!autoEnable) return "未使能且未勾选自动使能";
@@ -49,8 +51,26 @@ namespace Plugins.Motion
             return card.SetEnable(axis, true) ? null : $"轴 {axis} 自动使能失败";
         }
 
+        public static string CheckEnableAllowed(IMotionCard card)
+        {
+            if (card is IMotionSafetyHost s && !s.AllowEnable(out var reason))
+                return reason;
+            return null;
+        }
+
+        public static string CheckMotionAllowed(IMotionCard card)
+        {
+            if (card is IMotionSafetyHost s && !s.AllowMotion(out var reason))
+                return reason;
+            return null;
+        }
+
         public static string WaitIdle(IMotionCard card, int axis, int timeoutMs, CancellationToken token)
         {
+            token.ThrowIfCancellationRequested();
+            // 仿真点到点:按剩余路程一次拨完时钟,与等待超时无关。
+            (card as IMotionSimulation)?.FinishDiscreteMoves();
+
             var start = DateTime.UtcNow;
             while (true)
             {

+ 6 - 1
TeamAAS/TeamAAS.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
 	  <OutputType>WinExe</OutputType>
@@ -43,6 +43,11 @@
     <ProjectReference Include="..\TeamAAS.Base\TeamAAS.Database\TeamAAS.Database.csproj" />
   </ItemGroup>
 
+  <!-- 汇川 IMC60G 原生库:输出到 dll\x64,PostBuild 再搬到 Runtime\dll\x64,供 SetDllDirectory 加载 -->
+  <ItemGroup>
+    <None Include="..\..\IMC60.MotionStudio\IMC_Library_x64.dll" Link="dll\x64\IMC_Library_x64.dll" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\IMC60.MotionStudio\IMC_Library_x64.dll')" />
+  </ItemGroup>
+
   <!-- PostBuild锛氭妸鏍圭洰褰曟墍鏈?DLL/PDB/XML/Plugins/native 搴撶Щ鍒?Runtime 瀛愮洰褰曪紝
        鏍圭洰褰曞彧淇濈暀 EXE + 閰嶇疆绫绘枃浠跺す锛圕onfig/Logs/Products/Recipe/zh-CN锛夈€?
        鍚姩鏃?App.OnStartup 閫氳繃 AssemblyResolve + SetDllDirectory 鍦?Runtime 涓嬪姞杞姐€?-->

+ 64 - 0
TeamAAS/ViewModels/SettingViewModel.MotionCard.cs

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
 using System.Windows;
 using TeamAAS.Motion;
 using TeamAAS.Motion.Models;
+using TeamAAS.Motion.Motions;
 using TeamAAS.Views;
 
 namespace TeamAAS.ViewModels
@@ -184,6 +185,7 @@ namespace TeamAAS.ViewModels
             {
                 if (edited == null) return;
                 _motionManager.SaveConfig();
+                ReloadMotionCardAxes(edited);
                 RefreshMotionDebugHost();
                 DialogHelper.Success("控制卡属性已更新并保存");
             }
@@ -193,6 +195,68 @@ namespace TeamAAS.ViewModels
             }
         }
 
+        private void EditMotionAxes()
+        {
+            if (SelectedMotionCard == null) return;
+            var current = MotionAxisCatalog.GetEffective(SelectedMotionCard);
+            var dialog = new EditMotionAxesDialog(current)
+            {
+                Owner = Application.Current.MainWindow
+            };
+            if (dialog.ShowDialog() != true || dialog.Result == null)
+                return;
+
+            SelectedMotionCard.Axes = dialog.Result;
+            SelectedMotionCard.AxisCount = dialog.Result.Count;
+            _motionManager.SaveConfig();
+            ReloadMotionCardAxes(SelectedMotionCard);
+            RefreshMotionDebugHost();
+            DialogHelper.Success("轴配置已保存");
+        }
+
+        private void EditMotionSafety()
+        {
+            if (SelectedMotionCard == null) return;
+            var current = MotionAxisCatalog.GetEffective(SelectedMotionCard);
+            var dialog = new EditMotionSafetyDialog(SelectedMotionCard, current)
+            {
+                Owner = Application.Current.MainWindow
+            };
+            if (dialog.ShowDialog() != true || dialog.Result == null)
+                return;
+
+            SelectedMotionCard.EstopDiIndex = dialog.EstopDiIndex;
+            SelectedMotionCard.EstopDiBank = dialog.EstopDiBank;
+            SelectedMotionCard.EstopActiveHigh = dialog.EstopActiveHigh;
+            SelectedMotionCard.StopDiIndex = dialog.StopDiIndex;
+            SelectedMotionCard.StopDiBank = dialog.StopDiBank;
+            SelectedMotionCard.StopActiveHigh = dialog.StopActiveHigh;
+            if (SelectedMotionCard.Axes == null || SelectedMotionCard.Axes.Count == 0)
+                SelectedMotionCard.Axes = current;
+            foreach (var row in dialog.Result)
+            {
+                var ax = SelectedMotionCard.Axes.FirstOrDefault(a => a != null && a.AxisNo == row.AxisNo);
+                if (ax == null) continue;
+                ax.SoftLimitEnabled = row.SoftLimitEnabled;
+                ax.SoftLimitNeg = row.SoftLimitNeg;
+                ax.SoftLimitPos = row.SoftLimitPos;
+                ax.MaxVelocity = row.MaxVelocity;
+            }
+            _motionManager.SaveConfig();
+            ReloadMotionCardAxes(SelectedMotionCard);
+            RefreshMotionDebugHost();
+            DialogHelper.Success("安全配置已保存");
+        }
+
+        private void ReloadMotionCardAxes(MotionDeviceConfig cfg)
+        {
+            var motion = _motionManager.GetMotion(cfg.Name);
+            if (motion is InovanceMotionCard inovance)
+                inovance.ReloadFromConfig();
+            else if (motion is SimulatedMotionCard simulated)
+                simulated.ReloadFromConfig();
+        }
+
         private void RefreshMotionCardList()
         {
             MotionCards.Clear();

+ 4 - 0
TeamAAS/ViewModels/SettingViewModel.cs

@@ -361,6 +361,8 @@ namespace TeamAAS.ViewModels
         public DelegateCommand ConnectMotionCardCommand { get; }
         public DelegateCommand DisconnectMotionCardCommand { get; }
         public DelegateCommand SaveMotionCardConfigCommand { get; }
+        public DelegateCommand EditMotionAxesCommand { get; }
+        public DelegateCommand EditMotionSafetyCommand { get; }
         #endregion
 
         public SettingViewModel(ICameraManager cameraManager, IRobotManager robotManager,
@@ -445,6 +447,8 @@ namespace TeamAAS.ViewModels
             ConnectMotionCardCommand = new DelegateCommand(async () => await ConnectMotionCardAsync());
             DisconnectMotionCardCommand = new DelegateCommand(DisconnectMotionCard);
             SaveMotionCardConfigCommand = new DelegateCommand(SaveMotionCardConfig);
+            EditMotionAxesCommand = new DelegateCommand(EditMotionAxes);
+            EditMotionSafetyCommand = new DelegateCommand(EditMotionSafety);
 
 
             // 全局变量命令

+ 1 - 0
TeamAAS/Views/AddMotionCardDialog.xaml.cs

@@ -60,6 +60,7 @@ namespace TeamAAS.Views
                 AdapterKey = AdapterKey,
                 AxisCount = 4,
                 BoardIndex = 0,
+                Axes = MotionAxisCatalog.CreateDefaults(4),
             };
 
             DialogResult = true;

+ 68 - 0
TeamAAS/Views/EditMotionAxesDialog.xaml

@@ -0,0 +1,68 @@
+<hc:Window x:Class="TeamAAS.Views.EditMotionAxesDialog"
+           xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+           xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+           xmlns:hc="https://handyorg.github.io/handycontrol"
+           xmlns:loc="clr-namespace:TeamAAS.Localization"
+           Title="{loc:Translate '编辑轴'}"
+           Width="860"
+           Height="520"
+           WindowStartupLocation="CenterOwner"
+           Background="{DynamicResource RegionBrush}">
+    <Grid Margin="16">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+
+        <TextBlock Grid.Row="0"
+                   Text="{loc:Translate '实轴绑定 EtherCAT 通道。虚轴作主轴、不绑输出;在虚轴上填写从轴后即可主从跟随(1 条从轴即可,多条则同步驱动)。'}"
+                   TextWrapping="Wrap"
+                   Foreground="{DynamicResource SecondaryTextBrush}"
+                   Margin="0,0,0,10"/>
+
+        <DataGrid Grid.Row="1"
+                  x:Name="GridAxes"
+                  AutoGenerateColumns="False"
+                  CanUserAddRows="False"
+                  CanUserDeleteRows="True"
+                  HeadersVisibility="All"
+                  GridLinesVisibility="Horizontal"
+                  BorderThickness="1"
+                  ItemsSource="{Binding Rows}">
+            <DataGrid.Columns>
+                <DataGridTextColumn Header="{loc:Translate '轴号'}" Binding="{Binding AxisNo, UpdateSourceTrigger=PropertyChanged}" Width="70"/>
+                <DataGridTextColumn Header="{loc:Translate '名称'}" Binding="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Width="120"/>
+                <DataGridComboBoxColumn Header="{loc:Translate '类型'}"
+                                        SelectedItemBinding="{Binding KindName, UpdateSourceTrigger=PropertyChanged}"
+                                        Width="100">
+                    <DataGridComboBoxColumn.ItemsSource>
+                        <x:Array Type="sys:String" xmlns:sys="clr-namespace:System;assembly=mscorlib">
+                            <sys:String>实轴</sys:String>
+                            <sys:String>虚轴</sys:String>
+                            <sys:String>组合轴</sys:String>
+                        </x:Array>
+                    </DataGridComboBoxColumn.ItemsSource>
+                </DataGridComboBoxColumn>
+                <DataGridTextColumn Header="{loc:Translate 'EtherCAT通道'}" Binding="{Binding OutputChannel, UpdateSourceTrigger=PropertyChanged}" Width="110"/>
+                <DataGridTextColumn Header="{loc:Translate '从轴'}" Binding="{Binding CombinedMembers, UpdateSourceTrigger=PropertyChanged}" Width="160"/>
+                <DataGridTextColumn Header="{loc:Translate '速度'}" Binding="{Binding Velocity, UpdateSourceTrigger=PropertyChanged}" Width="80"/>
+            </DataGrid.Columns>
+        </DataGrid>
+
+        <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,10,0,8">
+            <Button Content="{loc:Translate '添加轴'}" MinWidth="80" Padding="12,0" Click="BtnAdd_Click" Style="{StaticResource ButtonDefault}" Margin="0,0,8,0"/>
+            <Button Content="{loc:Translate '删除选中'}" MinWidth="80" Padding="12,0" Click="BtnRemove_Click" Style="{StaticResource ButtonDefault}" Margin="0,0,8,0"/>
+            <Button Content="{loc:Translate '添加从轴'}" MinWidth="90" Padding="12,0" Click="BtnAddCombined_Click" Style="{StaticResource ButtonPrimary}"/>
+            <TextBlock VerticalAlignment="Center" Margin="16,0,0,0"
+                       Foreground="{DynamicResource SecondaryTextBrush}"
+                       Text="{loc:Translate '先选中虚轴(主轴),再点「添加从轴」。也可多选虚轴+从轴后点击。轴号逗号分隔,负号反向,例如 1 或 1,-2'}"/>
+        </StackPanel>
+
+        <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
+            <Button Content="{loc:Translate '取消'}" MinWidth="80" Padding="16,0" Margin="0,0,12,0" IsCancel="True" Style="{StaticResource ButtonDefault}"/>
+            <Button Content="{loc:Translate '确定'}" MinWidth="80" Padding="16,0" IsDefault="True" Click="BtnOK_Click" Style="{StaticResource ButtonPrimary}"/>
+        </StackPanel>
+    </Grid>
+</hc:Window>

+ 232 - 0
TeamAAS/Views/EditMotionAxesDialog.xaml.cs

@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+using TeamAAS.Motion.Models;
+
+namespace TeamAAS.Views
+{
+    public partial class EditMotionAxesDialog
+    {
+        public ObservableCollection<AxisEditRow> Rows { get; } = new ObservableCollection<AxisEditRow>();
+        public List<MotionAxisConfig> Result { get; private set; }
+
+        public EditMotionAxesDialog(IEnumerable<MotionAxisConfig> axes)
+        {
+            InitializeComponent();
+            DataContext = this;
+            foreach (var cfg in axes ?? Enumerable.Empty<MotionAxisConfig>())
+                Rows.Add(AxisEditRow.FromConfig(cfg));
+            if (Rows.Count == 0)
+            {
+                foreach (var cfg in MotionAxisCatalog.CreateDefaults(4))
+                    Rows.Add(AxisEditRow.FromConfig(cfg));
+            }
+        }
+
+        private void BtnAdd_Click(object sender, RoutedEventArgs e)
+        {
+            int next = Rows.Count == 0 ? 0 : Rows.Max(r => r.AxisNo) + 1;
+            Rows.Add(new AxisEditRow
+            {
+                AxisNo = next,
+                Name = next < MotionAxisCatalog.DefaultNames.Length ? MotionAxisCatalog.DefaultNames[next] : $"Axis-{next}",
+                KindName = "实轴",
+                OutputChannel = next
+            });
+        }
+
+        private void BtnAddCombined_Click(object sender, RoutedEventArgs e)
+        {
+            var selectedItems = GridAxes.SelectedItems.Cast<AxisEditRow>().ToList();
+            var master = GridAxes.SelectedItem as AxisEditRow;
+            if (master == null || master.Kind != MotionAxisKind.Virtual)
+                master = selectedItems.FirstOrDefault(r => r.Kind == MotionAxisKind.Virtual);
+
+            if (master == null || master.Kind != MotionAxisKind.Virtual)
+            {
+                DialogHelper.ShowWarning("请先选中一条虚轴作为主轴,再添加从轴");
+                return;
+            }
+
+            var existing = new HashSet<int>(master.ToConfig().ParseMembers().Select(m => m.AxisNo))
+            {
+                master.AxisNo
+            };
+
+            var slaves = selectedItems
+                .Where(r => r != master && !r.ToConfig().IsGrouped)
+                .Select(r => r.AxisNo)
+                .Where(no => !existing.Contains(no))
+                .Distinct()
+                .ToList();
+
+            if (slaves.Count == 0)
+            {
+                var candidate = Rows.FirstOrDefault(r =>
+                    r != master && !r.ToConfig().IsGrouped && !existing.Contains(r.AxisNo));
+                if (candidate != null)
+                    slaves.Add(candidate.AxisNo);
+            }
+
+            if (slaves.Count == 0)
+            {
+                DialogHelper.ShowWarning("没有可添加的从轴。请先添加一条实轴或未跟随的虚轴");
+                return;
+            }
+
+            var parts = string.IsNullOrWhiteSpace(master.CombinedMembers)
+                ? new List<string>()
+                : master.CombinedMembers
+                    .Split(new[] { ',', ';', ' ', ',' }, StringSplitOptions.RemoveEmptyEntries)
+                    .Select(s => s.Trim())
+                    .Where(s => s.Length > 0)
+                    .ToList();
+            foreach (var no in slaves)
+            {
+                if (!existing.Contains(no))
+                    parts.Add(no.ToString());
+            }
+
+            master.CombinedMembers = string.Join(",", parts);
+            GridAxes.SelectedItem = master;
+            GridAxes.ScrollIntoView(master);
+            DialogHelper.Info($"虚轴 {master.Name} 已添加从轴:{string.Join(",", slaves)}");
+        }
+
+        private void BtnRemove_Click(object sender, RoutedEventArgs e)
+        {
+            var selected = GridAxes.SelectedItems.Cast<AxisEditRow>().ToList();
+            foreach (var row in selected)
+                Rows.Remove(row);
+        }
+
+        private void BtnOK_Click(object sender, RoutedEventArgs e)
+        {
+            string err = Validate();
+            if (err != null)
+            {
+                DialogHelper.ShowWarning(err);
+                return;
+            }
+
+            Result = Rows.Select(r => r.ToConfig()).ToList();
+            DialogResult = true;
+        }
+
+        private string Validate()
+        {
+            if (Rows.Count == 0) return "至少配置一条轴";
+            var nos = new HashSet<int>();
+            foreach (var row in Rows)
+            {
+                if (row.AxisNo < 0 || row.AxisNo > 63) return $"轴号 {row.AxisNo} 超出范围 [0,63]";
+                if (!nos.Add(row.AxisNo)) return $"轴号 {row.AxisNo} 重复";
+                if (string.IsNullOrWhiteSpace(row.Name)) return $"轴 {row.AxisNo} 名称为空";
+            }
+
+            var leafNos = new HashSet<int>(Rows.Where(r => !r.ToConfig().IsGrouped).Select(r => r.AxisNo));
+            foreach (var row in Rows)
+            {
+                var cfg = row.ToConfig();
+                var members = cfg.ParseMembers();
+                bool wantGroup = cfg.Kind == MotionAxisKind.Combined
+                                 || (cfg.Kind == MotionAxisKind.Virtual && !string.IsNullOrWhiteSpace(cfg.CombinedMembers));
+                if (!wantGroup) continue;
+                if (members.Count < 1) return $"{cfg.KindText} {row.Name} 至少需要 1 个从轴";
+                foreach (var m in members)
+                {
+                    if (m.AxisNo == row.AxisNo) return $"{row.Name} 不能把自身当作从轴";
+                    if (!leafNos.Contains(m.AxisNo))
+                        return $"{row.Name} 的从轴 {m.AxisNo} 必须是已有的实轴或未跟随的虚轴";
+                }
+            }
+            return null;
+        }
+    }
+
+    public class AxisEditRow : INotifyPropertyChanged
+    {
+        private int _axisNo;
+        private string _name = "Axis";
+        private MotionAxisKind _kind;
+        private int _outputChannel;
+        private string _combinedMembers = "";
+        private double _velocity = 100;
+        private bool _softLimitEnabled;
+        private double _softLimitNeg;
+        private double _softLimitPos;
+        private double _maxVelocity;
+
+        public int AxisNo { get => _axisNo; set { _axisNo = value; OnChanged(nameof(AxisNo)); } }
+        public string Name { get => _name; set { _name = value; OnChanged(nameof(Name)); } }
+        public MotionAxisKind Kind { get => _kind; set { _kind = value; OnChanged(nameof(Kind)); OnChanged(nameof(KindName)); } }
+        public string KindName
+        {
+            get
+            {
+                switch (_kind)
+                {
+                    case MotionAxisKind.Virtual: return "虚轴";
+                    case MotionAxisKind.Combined: return "组合轴";
+                    default: return "实轴";
+                }
+            }
+            set
+            {
+                if (value == "虚轴") _kind = MotionAxisKind.Virtual;
+                else if (value == "组合轴") _kind = MotionAxisKind.Combined;
+                else _kind = MotionAxisKind.Physical;
+                OnChanged(nameof(Kind));
+                OnChanged(nameof(KindName));
+            }
+        }
+        public int OutputChannel { get => _outputChannel; set { _outputChannel = value; OnChanged(nameof(OutputChannel)); } }
+        public string CombinedMembers { get => _combinedMembers; set { _combinedMembers = value; OnChanged(nameof(CombinedMembers)); } }
+        public double Velocity { get => _velocity; set { _velocity = value; OnChanged(nameof(Velocity)); } }
+        public bool SoftLimitEnabled { get => _softLimitEnabled; set { _softLimitEnabled = value; OnChanged(nameof(SoftLimitEnabled)); } }
+        public double SoftLimitNeg { get => _softLimitNeg; set { _softLimitNeg = value; OnChanged(nameof(SoftLimitNeg)); } }
+        public double SoftLimitPos { get => _softLimitPos; set { _softLimitPos = value; OnChanged(nameof(SoftLimitPos)); } }
+        public double MaxVelocity { get => _maxVelocity; set { _maxVelocity = value; OnChanged(nameof(MaxVelocity)); } }
+
+        public event PropertyChangedEventHandler PropertyChanged;
+        private void OnChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+
+        public static AxisEditRow FromConfig(MotionAxisConfig cfg)
+        {
+            cfg = cfg ?? new MotionAxisConfig();
+            return new AxisEditRow
+            {
+                AxisNo = cfg.AxisNo,
+                Name = cfg.Name,
+                Kind = cfg.Kind,
+                OutputChannel = cfg.OutputChannel,
+                CombinedMembers = cfg.CombinedMembers,
+                Velocity = cfg.Velocity,
+                SoftLimitEnabled = cfg.SoftLimitEnabled,
+                SoftLimitNeg = cfg.SoftLimitNeg,
+                SoftLimitPos = cfg.SoftLimitPos,
+                MaxVelocity = cfg.MaxVelocity
+            };
+        }
+
+        public MotionAxisConfig ToConfig()
+        {
+            return new MotionAxisConfig
+            {
+                AxisNo = AxisNo,
+                Name = Name?.Trim(),
+                Kind = Kind,
+                OutputChannel = OutputChannel,
+                CombinedMembers = CombinedMembers?.Trim() ?? "",
+                Velocity = Velocity,
+                SoftLimitEnabled = SoftLimitEnabled,
+                SoftLimitNeg = SoftLimitNeg,
+                SoftLimitPos = SoftLimitPos,
+                MaxVelocity = MaxVelocity
+            };
+        }
+    }
+}

+ 117 - 0
TeamAAS/Views/EditMotionSafetyDialog.xaml

@@ -0,0 +1,117 @@
+<hc:Window x:Class="TeamAAS.Views.EditMotionSafetyDialog"
+           xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+           xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+           xmlns:hc="https://handyorg.github.io/handycontrol"
+           xmlns:loc="clr-namespace:TeamAAS.Localization"
+           Title="{loc:Translate '安全配置'}"
+           Width="980"
+           Height="600"
+           WindowStartupLocation="CenterOwner"
+           Background="{DynamicResource RegionBrush}">
+    <Grid Margin="16">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+
+        <Border Grid.Row="0"
+                BorderBrush="{DynamicResource BorderBrush}"
+                BorderThickness="1"
+                CornerRadius="6"
+                Padding="12"
+                Margin="0,0,0,12">
+            <StackPanel>
+                <TextBlock Text="{loc:Translate '卡级急停 / 停止'}"
+                           FontSize="14" FontWeight="Bold"
+                           Foreground="{DynamicResource PrimaryTextBrush}"/>
+                <TextBlock Text="{loc:Translate '控制卡连接后后台实时监控这两个 DI。填 -1 表示不启用。分区选本地端子或 EtherCAT 扩展。高电平有效=常开(DI=1 触发);不勾选=常闭(DI=0 触发)。'}"
+                           TextWrapping="Wrap"
+                           Foreground="{DynamicResource SecondaryTextBrush}"
+                           Margin="0,4,0,10"/>
+                <Grid>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="Auto"/>
+                        <ColumnDefinition Width="88"/>
+                        <ColumnDefinition Width="110"/>
+                        <ColumnDefinition Width="Auto"/>
+                        <ColumnDefinition Width="20"/>
+                        <ColumnDefinition Width="Auto"/>
+                        <ColumnDefinition Width="88"/>
+                        <ColumnDefinition Width="110"/>
+                        <ColumnDefinition Width="Auto"/>
+                    </Grid.ColumnDefinitions>
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto"/>
+                    </Grid.RowDefinitions>
+
+                    <TextBlock Grid.Column="0" Text="{loc:Translate '急停 DI'}"
+                               VerticalAlignment="Center" Margin="0,0,8,0"
+                               Foreground="{DynamicResource PrimaryTextBrush}"/>
+                    <TextBox Grid.Column="1" Height="28"
+                             Text="{Binding EstopDiIndex, UpdateSourceTrigger=PropertyChanged}"
+                             VerticalContentAlignment="Center" Margin="0,0,8,0"/>
+                    <ComboBox Grid.Column="2" Height="28" Margin="0,0,8,0"
+                              SelectedIndex="{Binding EstopDiBankIndex}"
+                              VerticalContentAlignment="Center">
+                        <ComboBoxItem Content="{loc:Translate '本地'}"/>
+                        <ComboBoxItem Content="EtherCAT"/>
+                    </ComboBox>
+                    <CheckBox Grid.Column="3" Content="{loc:Translate '高电平有效'}"
+                              IsChecked="{Binding EstopActiveHigh}" VerticalAlignment="Center"/>
+
+                    <TextBlock Grid.Column="5" Text="{loc:Translate '停止 DI'}"
+                               VerticalAlignment="Center" Margin="0,0,8,0"
+                               Foreground="{DynamicResource PrimaryTextBrush}"/>
+                    <TextBox Grid.Column="6" Height="28"
+                             Text="{Binding StopDiIndex, UpdateSourceTrigger=PropertyChanged}"
+                             VerticalContentAlignment="Center" Margin="0,0,8,0"/>
+                    <ComboBox Grid.Column="7" Height="28" Margin="0,0,8,0"
+                              SelectedIndex="{Binding StopDiBankIndex}"
+                              VerticalContentAlignment="Center">
+                        <ComboBoxItem Content="{loc:Translate '本地'}"/>
+                        <ComboBoxItem Content="EtherCAT"/>
+                    </ComboBox>
+                    <CheckBox Grid.Column="8" Content="{loc:Translate '高电平有效'}"
+                              IsChecked="{Binding StopActiveHigh}" VerticalAlignment="Center"/>
+                </Grid>
+                <TextBlock Text="{loc:Translate '急停:下使能全部轴,手动/自动使能与运动均失败。停止:全部轴不能运动,手动/自动运动均失败。'}"
+                           TextWrapping="Wrap"
+                           Foreground="#C62828"
+                           FontWeight="SemiBold"/>
+            </StackPanel>
+        </Border>
+
+        <TextBlock Grid.Row="1"
+                   Text="{loc:Translate '按轴设置软限位与最高速度。指令超出时按边界运行:位置钳到正/负限位,速度钳到最高速度。最高速度填 0 表示不限制。'}"
+                   TextWrapping="Wrap"
+                   Foreground="{DynamicResource SecondaryTextBrush}"
+                   Margin="0,0,0,10"/>
+
+        <DataGrid Grid.Row="2"
+                  x:Name="GridSafety"
+                  AutoGenerateColumns="False"
+                  CanUserAddRows="False"
+                  CanUserDeleteRows="False"
+                  HeadersVisibility="All"
+                  GridLinesVisibility="Horizontal"
+                  BorderThickness="1"
+                  ItemsSource="{Binding Rows}">
+            <DataGrid.Columns>
+                <DataGridTextColumn Header="{loc:Translate '轴号'}" Binding="{Binding AxisNo}" IsReadOnly="True" Width="70"/>
+                <DataGridTextColumn Header="{loc:Translate '名称'}" Binding="{Binding Name}" IsReadOnly="True" Width="120"/>
+                <DataGridTextColumn Header="{loc:Translate '类型'}" Binding="{Binding KindText}" IsReadOnly="True" Width="90"/>
+                <DataGridCheckBoxColumn Header="{loc:Translate '软限位'}" Binding="{Binding SoftLimitEnabled, UpdateSourceTrigger=PropertyChanged}" Width="70"/>
+                <DataGridTextColumn Header="{loc:Translate '负限位'}" Binding="{Binding SoftLimitNeg, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
+                <DataGridTextColumn Header="{loc:Translate '正限位'}" Binding="{Binding SoftLimitPos, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
+                <DataGridTextColumn Header="{loc:Translate '最高速度'}" Binding="{Binding MaxVelocity, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
+            </DataGrid.Columns>
+        </DataGrid>
+
+        <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
+            <Button Content="{loc:Translate '取消'}" MinWidth="80" Padding="16,0" Margin="0,0,12,0" IsCancel="True" Style="{StaticResource ButtonDefault}"/>
+            <Button Content="{loc:Translate '确定'}" MinWidth="80" Padding="16,0" IsDefault="True" Click="BtnOK_Click" Style="{StaticResource ButtonPrimary}"/>
+        </StackPanel>
+    </Grid>
+</hc:Window>

+ 133 - 0
TeamAAS/Views/EditMotionSafetyDialog.xaml.cs

@@ -0,0 +1,133 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+using TeamAAS.Global.Devices;
+using TeamAAS.Motion.Models;
+
+namespace TeamAAS.Views
+{
+    public partial class EditMotionSafetyDialog
+    {
+        public ObservableCollection<SafetyEditRow> Rows { get; } = new ObservableCollection<SafetyEditRow>();
+        public List<MotionAxisConfig> Result { get; private set; }
+
+        public int EstopDiIndex { get; set; } = -1;
+        public int EstopDiBankIndex { get; set; }
+        public bool EstopActiveHigh { get; set; } = true;
+        public int StopDiIndex { get; set; } = -1;
+        public int StopDiBankIndex { get; set; }
+        public bool StopActiveHigh { get; set; } = true;
+
+        public MotionIoBank EstopDiBank => EstopDiBankIndex == 1 ? MotionIoBank.EtherCat : MotionIoBank.Local;
+        public MotionIoBank StopDiBank => StopDiBankIndex == 1 ? MotionIoBank.EtherCat : MotionIoBank.Local;
+
+        public EditMotionSafetyDialog(MotionDeviceConfig cfg, IEnumerable<MotionAxisConfig> axes)
+        {
+            EstopDiIndex = cfg != null ? cfg.EstopDiIndex : -1;
+            EstopDiBankIndex = cfg != null && cfg.EstopDiBank == MotionIoBank.EtherCat ? 1 : 0;
+            EstopActiveHigh = cfg == null || cfg.EstopActiveHigh;
+            StopDiIndex = cfg != null ? cfg.StopDiIndex : -1;
+            StopDiBankIndex = cfg != null && cfg.StopDiBank == MotionIoBank.EtherCat ? 1 : 0;
+            StopActiveHigh = cfg == null || cfg.StopActiveHigh;
+            InitializeComponent();
+            DataContext = this;
+            foreach (var axisCfg in axes ?? Enumerable.Empty<MotionAxisConfig>())
+                Rows.Add(SafetyEditRow.FromConfig(axisCfg));
+        }
+
+        private void BtnOK_Click(object sender, RoutedEventArgs e)
+        {
+            foreach (var row in Rows)
+            {
+                if (row.SoftLimitEnabled && row.SoftLimitNeg == row.SoftLimitPos)
+                {
+                    DialogHelper.ShowWarning($"{row.Name} 的正负软限位不能相同");
+                    return;
+                }
+                if (row.MaxVelocity < 0)
+                {
+                    DialogHelper.ShowWarning($"{row.Name} 的最高速度不能为负");
+                    return;
+                }
+            }
+
+            if (EstopDiIndex < -1 || StopDiIndex < -1)
+            {
+                DialogHelper.ShowWarning("急停/停止 DI 填 -1 表示不启用,不能小于 -1");
+                return;
+            }
+
+            Result = Rows.Select(r => r.ToConfig()).ToList();
+            DialogResult = true;
+        }
+    }
+
+    public class SafetyEditRow : INotifyPropertyChanged
+    {
+        private bool _softLimitEnabled;
+        private double _softLimitNeg;
+        private double _softLimitPos;
+        private double _maxVelocity;
+
+        public int AxisNo { get; set; }
+        public string Name { get; set; }
+        public string KindText { get; set; }
+
+        public bool SoftLimitEnabled
+        {
+            get => _softLimitEnabled;
+            set { _softLimitEnabled = value; OnChanged(nameof(SoftLimitEnabled)); }
+        }
+
+        public double SoftLimitNeg
+        {
+            get => _softLimitNeg;
+            set { _softLimitNeg = value; OnChanged(nameof(SoftLimitNeg)); }
+        }
+
+        public double SoftLimitPos
+        {
+            get => _softLimitPos;
+            set { _softLimitPos = value; OnChanged(nameof(SoftLimitPos)); }
+        }
+
+        public double MaxVelocity
+        {
+            get => _maxVelocity;
+            set { _maxVelocity = value; OnChanged(nameof(MaxVelocity)); }
+        }
+
+        public event PropertyChangedEventHandler PropertyChanged;
+        private void OnChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+
+        public static SafetyEditRow FromConfig(MotionAxisConfig cfg)
+        {
+            cfg = cfg ?? new MotionAxisConfig();
+            return new SafetyEditRow
+            {
+                AxisNo = cfg.AxisNo,
+                Name = cfg.Name,
+                KindText = cfg.KindText,
+                SoftLimitEnabled = cfg.SoftLimitEnabled,
+                SoftLimitNeg = cfg.SoftLimitNeg,
+                SoftLimitPos = cfg.SoftLimitPos,
+                MaxVelocity = cfg.MaxVelocity
+            };
+        }
+
+        public MotionAxisConfig ToConfig()
+        {
+            return new MotionAxisConfig
+            {
+                AxisNo = AxisNo,
+                Name = Name,
+                SoftLimitEnabled = SoftLimitEnabled,
+                SoftLimitNeg = SoftLimitNeg,
+                SoftLimitPos = SoftLimitPos,
+                MaxVelocity = MaxVelocity
+            };
+        }
+    }
+}

+ 13 - 1
TeamAAS/Views/SettingView.xaml

@@ -462,6 +462,18 @@
                                 Margin="4,0,8,0"
                                 Padding="16,6"
                                 IsEnabled="{Binding HasSelectedMotionCard}" />
+                        <Button Content="{loc:Translate '编辑轴'}"
+                                Command="{Binding EditMotionAxesCommand}"
+                                Style="{StaticResource ButtonDefault}"
+                                Margin="4,0,4,0"
+                                Padding="16,6"
+                                IsEnabled="{Binding HasSelectedMotionCard}" />
+                        <Button Content="{loc:Translate '安全配置'}"
+                                Command="{Binding EditMotionSafetyCommand}"
+                                Style="{StaticResource ButtonDefault}"
+                                Margin="4,0,8,0"
+                                Padding="16,6"
+                                IsEnabled="{Binding HasSelectedMotionCard}" />
                         <Rectangle Width="1"
                                    Height="24"
                                    Fill="{DynamicResource BorderBrush}"
@@ -514,7 +526,7 @@
                                       BorderThickness="0"
                                       AlternatingRowBackground="{DynamicResource SecondaryRegionBrush}"
                                       Margin="8,0,8,8"
-                                      MouseDoubleClick="RobotDataGrid_MouseDoubleClick">
+                                      MouseDoubleClick="MotionCardDataGrid_MouseDoubleClick">
                                 <DataGrid.Columns>
                                     <DataGridTextColumn Header="{loc:Translate '编号'}"
                                                         Binding="{Binding RobotNo}"

+ 6 - 0
TeamAAS/Views/SettingView.xaml.cs

@@ -41,6 +41,12 @@ namespace TeamAAS.Views
             }
         }
 
+        private void MotionCardDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+        {
+            if (DataContext is SettingViewModel vm)
+                vm.EditMotionAxesCommand?.Execute();
+        }
+
         private void FeederDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
         {
             if (DataContext is SettingViewModel vm && vm.SelectedFeeder is FeederInfo feeder)