简易Jog

RC+ 配备了可通过机器人管理器等调用的全功能“微动&示教”,但在创建 Extension 时,可以只调用该“微动&示教”的必要功能,实现自定义微动面板 (窗口)。
根据使用场景,通过创建自定义微动面板,有可能提升示教操作的效率。
此外,不仅限于自定义微动面板,通过 RC+ Extensions 对 RC+ 进行自定义,可以打造专属的 RC+,从而实现更加舒适的作业体验。

在本教程的初级篇中,将制作如下简单的微动面板,并说明功能的调用方式。

  • 点击电机的“切换”按钮,可切换电机的开 / 关状态。
  • 面板上可通过鼠标拖动移动,并分别在左右各配置一个类似游戏手柄“摇杆”风格的控制器。
    • 左侧摇杆可上下操作,实现沿 Z 坐标轴的微动。
    • 右侧摇杆可上下操作实现沿 Y 坐标轴的微动,左右操作实现沿 X 坐标轴的微动。
  • 点击“Teach”按钮后,将机器人当前的位置姿态,在目标点文件中选择尚未定义的点,依次进行示教。
    • 对于点,将添加注释,记录在本 Extension 中进行示教的信息以及示教的日期时间。
    • 面板上将显示示教点的日志。

在中级篇中,若实际连接了游戏手柄,则可通过该游戏手柄进行操作。

  • 电机的“切换”功能分配给左侧的肩部按钮 (也称为 Shoulder)。
  • 左右的“摇杆”风格控制器,将实现可通过实际摇杆进行操作。
  • “Teach”分配给 A 按钮。

注意


在机器人实机上测试此 Extension 时,请基于安全考量进行设计,并务必在安全护栏外侧操作

那么,现在开始吧。

■ 初级篇

  1. 按照[使用开始]的步骤,创建新的 RC+ Extensions 项目。

    • 名称设为 SimpleJog。
    • 初始功能,勾选Main menu and tool bar itemDocking window
    • 在 ARM64 版 Windows 上,请将配置更改为 x64。
  2. 在 Visual Studio 上进行构建和调试,确认功能正常运行。

    • 在 RC+ 主菜单的扩展标签菜单项中,将新增 SimpleJog (xx) (xx 为显示语言名),选择菜单项后显示停靠窗口即为正常。
  3. 模板确认已完成,暂时关闭 RC+。

  4. 在 DockingWindow 文件夹中添加如下文件。

    • Stick.xaml

      • 实现摇杆风格外观的用户控件文件。
        • 当控件处于激活状态时,中央“旋钮”显示为红色,并可通过鼠标拖动进行操作。
      <UserControl x:Class="SimpleJog.DockingWindow.Stick"
                  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
                  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                  xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
                  xmlns:local="clr-namespace:SimpleJog.DockingWindow"
                  mc:Ignorable="d" 
                  d:DesignHeight="300" d:DesignWidth="300">
      
          <Canvas
              Width="300"
              Height="300">
      
              (中略)
      
          </Canvas>
      
      </UserControl>
      
    • Stick.xaml.cs

      • 为 Stick 控件添加可通过鼠标移动“旋钮”的代码的代码后台文件。
      (前略)
      
      namespace SimpleJog.DockingWindow
      {
          (中略)
      
          /// <summary>
          /// Stick.xaml interaction logic
          /// </summary>
          public partial class Stick : UserControl
          {
              (中略)
      
              /// <summary>
              /// Constructor
              /// </summary>
              public Stick()
              {
                  InitializeComponent();
      
                  Knob.Loaded += (_, _) =>
                  {
                      _radius = Math.Min(KnobRange.RenderSize.Width, KnobRange.Height) / 2.0 * _limitFactor;
                      _deadZone = _radius * _deadZoneFactor;
                      _center = new Point(KnobRange.RenderSize.Width / 2.0, KnobRange.RenderSize.Height / 2.0);
                  };
      
                  Knob.MouseLeftButtonDown += (_, ev) =>
                  {
                      Knob.CaptureMouse();
      
                      _dragging = true;
      
                      _offset = ev.GetPosition(KnobRange) - _center;
                      _smoothed = new Vector();
                  };
      
                  (中略)
              }
      
              /// <summary>
              /// Update knob position
              /// </summary>
              /// <param name="mousePosInRange">Relative mouse position in knob range</param>
              private void UpdateKnobPosition(
                  Point mousePosInRange
              )
              {
                  var x = mousePosInRange.X - _center.X;
                  var y = mousePosInRange.Y - _center.Y;
      
                  var distanceFromCenter = Math.Sqrt(x * x + y * y);
                  if (distanceFromCenter < _deadZone)
                  {
                      Position = _smoothed = new Vector();
                  }
                  else if (distanceFromCenter < _radius)
                  {
                      _smoothed = new Vector(
                          _smoothed.X * (1 - _smoothingFactor) + (x / _radius) * _smoothingFactor,
                          _smoothed.Y * (1 - _smoothingFactor) + (y / _radius) * _smoothingFactor
                      );
                      Position = new Vector(_smoothed.X, -_smoothed.Y);
                  }
              }
          }
      }
      
    • StickProperties.cs

      • 为 Stick 控件添加用于指示“旋钮”位置的 Vector 类型 Position 属性的文件。
        • Vector 的各要素 (X 和 Y) 会被归一化为 -1.0 到 +1.0 的值。
      (前略)
      
      namespace SimpleJog.DockingWindow
      {
          using System.Windows;
      
          /// <summary>
          /// Stick.xaml dependency properties
          /// </summary>
          public partial class Stick
          {
              /// <summary>
              /// Normalized position
              /// </summary>
              public Vector Position
              {
                  get => (Vector)GetValue(PositionProperty);
                  set => SetValue(PositionProperty, value);
              }
      
              /// <summary>
              /// Field of the "Position"
              /// </summary>
              public static readonly DependencyProperty PositionProperty =
                  DependencyProperty.Register(
                      nameof(Position),
                      typeof(Vector),
                      typeof(Stick),
                      new FrameworkPropertyMetadata(
                          default(Vector),
                          (FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                          | FrameworkPropertyMetadataOptions.AffectsRender),
                          OnPositionChanged,
                          CoercePositionNormalized
                      )
                  );
      
              /// <summary>
              /// Position changed event handler
              /// </summary>
              /// <param name="d">The object</param>
              /// <param name="ev">The event</param>
              private static void OnPositionChanged(
                  DependencyObject d,
                  DependencyPropertyChangedEventArgs ev
              )
              {
                  if (d is Stick stick)
                  {
                      stick.UpdateRawPosition();
                  }
              }
      
              /// <summary>
              /// Coerce value of the "Position"
              /// </summary>
              /// <param name="d">The object</param>
              /// <param name="value">The value</param>
              /// <returns>Corrected value</returns>
              private static object CoercePositionNormalized(
                  DependencyObject d,
                  object value
              )
              {
                  var vector = (Vector)value;
      
                  vector.X = Math.Clamp(vector.X, -1.0, 1.0);
                  vector.Y = Math.Clamp(vector.Y, -1.0, 1.0);
      
                  return vector;
              }
      
              /// <summary>
              /// Field key of the "RawPosition"
              /// </summary>
              private static readonly DependencyPropertyKey RawPositionPropertyKey =
                  DependencyProperty.RegisterReadOnly(
                      nameof(RawPosition),
                      typeof(Vector),
                      typeof(Stick),
                      new PropertyMetadata(default(Vector))
                  );
      
              /// <summary>
              /// Field of the "RawPosition"
              /// </summary>
              public static readonly DependencyProperty RawPositionProperty =
                  RawPositionPropertyKey.DependencyProperty;
      
              /// <summary>
              /// Raw (pixel) position
              /// </summary>
              public Vector RawPosition => (Vector)GetValue(RawPositionProperty);
      
              /// <summary>
              /// Set raw position
              /// </summary>
              private void UpdateRawPosition()
              {
                  var rawPosition = new Vector(Position.X * _radius, -(Position.Y * _radius));
      
                  SetValue(RawPositionPropertyKey, rawPosition);
              }
          }
      }
      
  5. 此处进行构建,使 Stick 能在 .xaml 的设计视图中显示。

  6. 编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml 文件。

    • 原有的 DockPanel 删除。
    • 改为配置 3 行 3 列的 Grid,并在各单元格中配置如下内容。以下以 0 为起点,R 行 C 列用 (R, C) 表示。
      • 第 3 行、第 3 列的 Height、Width 设为*。这些为边距,Grid 实际为 2 行 2 列。
      • (0, 0): 配置 DockPanel,内部放置 Label“Motor:”、Border、Button、TextBlock。
        • Border 作为指示器,参考 IsMotorOn(ReactivePropertySlim<bool>) 和 MotorState(ReactivePropertySlim<string>),用于指示电机状态。电机开启时显示绿色底白字“ON”,关闭时显示浅灰底黑字“OFF”。
        • Button 用于切换电机开 / 关。
          • Content 绑定到 Captions[LabelToggle].Value。
          • Command 绑定到 MotorToggleCommand(ReactiveCommand)。
          • IsEnabled 绑定到 IsOnline.Value。IsOnline(ReactivePropertySlim<bool>) 为与机器人控制器连接已建立时为 true 的标志。
        • TextBlock 的 Text 绑定到 APIResult.Value。APIResult(ReactivePropertySlim<string>) 用于本 Extension 的调试,显示所调用的 Extensions API 的状态 (RCXResult 类型) 的字符串表示。部分 API 除状态外还会返回附加信息,因此为显示该附加信息,准备了 APIResultAux(ReactivePropertySlim<string>),并将 APIResultAux.Value 绑定到 ToolTip。
      • (1, 0): 配置 3 行 4 列的 Grid,内部放置 6 个用于指示坐标方向的 Label 和 2 个 Stick。
        • Stick 用 Viewbox 包裹,以便可调整大小。
          • IsEnabled 绑定到 IsMotorOn.Value。
          • Position 关于左侧 Stick,绑定到 LeftStickPosition.Value。LeftStickPosition(ReactivePropertySlim<Vector>) 为左侧 Stick 的“旋钮”位置。右侧 Stick 同理。
      • (0, 1): 为 Label。Content 绑定到 Captions[CaptionLogHeader].Value。
      • (1, 1): 配置 DockPanel,内部放置 Button 和 ListBox。
        • Button 用于示教。
          • Content 绑定到 Captions[LabelTeach].Value。
          • Command 绑定到 TeachCommand(ReactiveCommand)。
          • IsEnabled 绑定到 CanTeach.Value。CanTeach(ReactivePropertySlim<bool>) 为是否可进行示教的标志。
        • ListBox 用于记录示教点信息的日志。
          • ItemsSource 绑定到 LogItems(ReactiveCollection<LogItem>)。LogItem 稍后创建。
          • 为显示末尾新增的最新日志信息,设置 AutoScrollBehavior。AutoScrollBehavior 也将在后续创建。
      <UserControl x:Class="SimpleJog.DockingWindow.DockingWindowContent"
                  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                  xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
                  xmlns:local="clr-namespace:SimpleJog.DockingWindow"
                  mc:Ignorable="d"
                  d:DesignHeight="450" d:DesignWidth="800">
      
          <UserControl.DataContext>
              <local:DockingWindowContentViewModel />
          </UserControl.DataContext>
      
          <Grid
              Margin="10">
      
              <Grid.RowDefinitions>
                  <RowDefinition Height="30" />
                  <RowDefinition Height="Auto" />
                  <RowDefinition Height="*" />
              </Grid.RowDefinitions>
      
              <Grid.ColumnDefinitions>
                  <ColumnDefinition Width="Auto" />
                  <ColumnDefinition Width="Auto" />
                  <ColumnDefinition Width="*" />
              </Grid.ColumnDefinitions>
                  
              <DockPanel
                  Grid.Row="0" Grid.Column="0"
                  LastChildFill="True">
      
                  <Label
                      Content="Motor:"
                      VerticalAlignment="Center" />
      
                  <Border
                      CornerRadius="10"
                      Width="60"
                      Height="20"
                      Margin="10,0,0,0"
                      VerticalAlignment="Center">
                      <TextBlock
                          Text="{Binding MotorState.Value}"
                          HorizontalAlignment="Center"
                          VerticalAlignment="Center">
                          <TextBlock.Style>
                              <Style
                                  TargetType="TextBlock">
                                  <Style.Triggers>
                                      <DataTrigger
                                          Binding="{Binding IsMotorOn.Value}"
                                          Value="True">
                                          <Setter
                                              Property="Foreground"
                                              Value="White" />
                                      </DataTrigger>
                                      <DataTrigger
                                          Binding="{Binding IsMotorOn.Value}"
                                          Value="False">
                                          <Setter
                                              Property="Foreground"
                                              Value="Black" />
                                      </DataTrigger>
                              </Style.Triggers>
                              </Style>
                          </TextBlock.Style>
                      </TextBlock>
                      <Border.Style>
                          <Style
                              TargetType="Border">
                              <Style.Triggers>
                                  <DataTrigger
                                      Binding="{Binding IsMotorOn.Value}"
                                      Value="True">
                                      <Setter
                                          Property="Background"
                                          Value="#00bb00" />
                                  </DataTrigger>
                                  <DataTrigger
                                      Binding="{Binding IsMotorOn.Value}"
                                      Value="False">
                                      <Setter
                                          Property="Background"
                                          Value="LightGray" />
                                  </DataTrigger>
                              </Style.Triggers>
                          </Style>
                      </Border.Style>
                  </Border>
      
                  <Button
                      Command="{Binding MotorToggleCommand}"
                      IsEnabled="{Binding IsOnline.Value}"
                      Content="{Binding Captions[LabelToggle].Value}"
                      Width="90"
                      Margin="10,0,0,0"
                      VerticalAlignment="Center" />
      
                  <TextBlock
                      Text="{Binding APIResult.Value}"
                      ToolTip="{Binding APIResultAux.Value}"
                      TextAlignment="Right"
                      VerticalAlignment="Center"
                      Margin="10,0,20,0" />
      
              </DockPanel>
      
              <Grid
                  Grid.Row="1" Grid.Column="0"
                  Margin="0,10,0,0">
      
                  <Grid.Resources>
                      <Style
                          TargetType="Label">
                          <Setter
                              Property="FontSize"
                              Value="16" />
                      </Style>
                  </Grid.Resources>
      
                  <Grid.RowDefinitions>
                      <RowDefinition Height="Auto" />
                      <RowDefinition Height="Auto" />
                      <RowDefinition Height="Auto" />
                  </Grid.RowDefinitions>
      
                  <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="Auto" />
                      <ColumnDefinition Width="Auto" />
                      <ColumnDefinition Width="Auto" />
                      <ColumnDefinition Width="Auto" />
                  </Grid.ColumnDefinitions>
      
                  <Label
                      Grid.Row="0" Grid.Column="0"
                      Content="+Z"
                      HorizontalAlignment="Center" />
                  <Label
                      Grid.Row="2" Grid.Column="0"
                      Content="-Z"
                      HorizontalAlignment="Center" />
                  <Viewbox
                      Grid.Row="1" Grid.Column="0"
                      Width="200">
                      <local:Stick
                          IsEnabled="{Binding IsMotorOn.Value}"
                          Position="{Binding InputService.LeftStickPosition.Value}" />
                  </Viewbox>
      
                  <Label
                      Grid.Row="1" Grid.Column="1"
                      Content="-X"
                      Margin="20,0,0,0"
                      VerticalAlignment="Center" />
                  <Label
                      Grid.Row="1" Grid.Column="3"
                      Content="+X"
                      Margin="0,0,10,0"
                      VerticalAlignment="Center" />
                  <Label
                      Grid.Row="0" Grid.Column="2"
                      Content="+Y"
                      HorizontalAlignment="Center" />
                  <Label
                      Grid.Row="2" Grid.Column="2"
                      Content="-Y"
                      HorizontalAlignment="Center" />
                  <Viewbox
                      Grid.Row="1" Grid.Column="2"
                      Width="200">
                      <local:Stick
                          IsEnabled="{Binding IsMotorOn.Value}"
                          Position="{Binding InputService.RightStickPosition.Value}" />
                  </Viewbox>
      
              </Grid>
      
              <Label
                  Grid.Row="0" Grid.Column="1"
                  Content="{Binding Captions[CaptionLogHeader].Value}"
                  VerticalAlignment="Center" />
      
              <DockPanel
                  Grid.Row="1" Grid.Column="1"
                  LastChildFill="True">
      
                  <Button
                      DockPanel.Dock="Bottom"
                      Command="{Binding TeachCommand}"
                      IsEnabled="{Binding CanTeach.Value}"
                      Content="{Binding Captions[LabelTeach].Value}"
                      Width="100"
                      Margin="0,10,0,0"
                      HorizontalAlignment="Center" />
      
                  <ListBox
                      x:Name="TeachingLog"
                      ItemsSource="{Binding LogItems}"
                      Width="200">
                      <i:Interaction.Behaviors>
                          <local:AutoScrollBehavior />
                      </i:Interaction.Behaviors>
                  </ListBox>
      
              </DockPanel>
      
          </Grid>
      
      </UserControl>
      
  7. 在 DockingWindow 文件夹中创建 LogItem.cs 文件。

    • 在本 Extension 中,示教日志将显示点编号及世界坐标的 X、Y、Z 值。

      (前略)
      
      namespace SimpleJog.DockingWindow
      {
          using static Epson.RoboticsShared.ExtensionsAPI.IRCXRobotManagerAPI;
      
          /// <summary>
          /// Teaching log list box item
          /// </summary>
          public class LogItem
          {
              /// <summary>
              /// Point number
              /// </summary>
              public int PointNumber { get; }
      
              /// <summary>
              /// Point position
              /// </summary>
              public IDictionary<RCXJogCartesianAxis, double>? WorldPosition { get; }
      
              /// <inheritdoc />
              public override string ToString()
              {
                  if (WorldPosition == null)
                  {
                      return $"P{PointNumber}";
                  }
                  else
                  {
                      var x = WorldPosition[RCXJogCartesianAxis.X];
                      var y = WorldPosition[RCXJogCartesianAxis.Y];
                      var z = WorldPosition[RCXJogCartesianAxis.Z];
      
                      return $"P{PointNumber}  X: {x:f2}, Y: {y:f2}, Z: {z:f2}";
                  }
              }
      
              (后略)
      
  8. 在 DockingWindow 文件夹中创建 AutoScrollBehavior.cs 文件。

    • 仅与 WPF 相关,细节省略。
  9. 编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。

    • 在 .xaml 中添加绑定的属性和命令。

    • 与机器人控制器是否连接,参考控制器连接 API 的 IsOnline 属性。IsOnline 在连接建立时为 true,断开时为 false,其他 (如正在尝试连接等中间状态) 为 null。连接状态变更时会触发 PropertyChanged 事件。

      • ObserveProperty(x => x.PropName).Subscribe(...) 是监控 API 对象中名为 PropName 的属性变更的常用方法。在本 Extension 中也有应用。
    • 电机状态参考控制器 API 的 IsMotorOn 属性。IsMotorOn 可能因控制器处于错误状态等原因为 null。电机状态变更时会触发 PropertyChanged 事件。

    • 要进行微动操作,使用 Jogger 对象。获取机器人管理器 API 对象并调用 CreateJoggerAsync 方法后,可获得 Jogger 对象。Jogger 对象有 IsValid 标志,仅在为 true 时功能可执行。调用 Jogger 对象方法时,请务必确认该标志。如因控制器断开等导致 Jogger 对象无效,请重新生成 Jogger 对象。

      • 与微动相关的参数 (微动移动距离、速度等) 在 RC+ 全局共享。在 RC+ 主体的“微动&示教”中进行的更改,原则上也适用于 SimpleJog。本次不实现 SimpleJog 的参数更改,如有需要请结合主程序的“微动&示教”使用。
        • 也可以考虑根据摇杆位置设定微动移动距离 (如小幅操作对应短距离,大幅操作对应长距离等) 的扩展。
      • 使用游戏手柄时,可同时操作左右摇杆。当前 API 不支持指定多个方向进行微动操作。因此,在本 Extension 中,生成 Jogger 对象的同时启动定时器,并根据定时器周期获取的摇杆位置,依次对各轴方向进行微动,采用轮询算法。禁止同时执行多个微动任务,若前一周期启动的微动未结束,则新微动启动会报错。
        • 也可实现取消正在进行的微动并启动新的微动。
    • 点文件可通过点 API 对象的 PointFileDescriptors 属性获得。本 Extension 旨在示教当前机器人的位置姿态,若控制器连接了多个机器人,原则上需示教到当前机器人关联或共用的点文件。

      • 本 Extension 中,优先示教到当前机器人的默认点文件,若无法获取,则以共用点文件之一为目标。若未找到目标点文件,则将 CanTeach.Value 设为 false,禁用示教按钮和命令。
      • 当前机器人的编号可通过机器人管理器 API 的 CurrentRobotNumber 属性获取。切换当前机器人时,该属性会触发 PropertyChanged 事件,请在此时重新选择目标点文件。
      (前略)
      
      namespace SimpleJog.DockingWindow
      {
          (中略)
      
          /// <summary>
          /// Extension : Docking Window (Specific Part)
          /// </summary>
          internal partial class DockingWindowContentViewModel
          {
              /// <summary>
              /// The controller is online or not
              /// </summary>
              public ReactivePropertySlim<bool> IsOnline { get; } = new(false);
      
              /// <summary>
              /// Motors are powered or not
              /// </summary>
              public ReactivePropertySlim<bool> IsMotorOn { get; } = new(false);
      
              /// <summary>
              /// Motor state expression
              /// </summary>
              public ReactivePropertySlim<string> MotorState { get; } = new("Off");
      
              /// <summary>
              /// Toggle motor state command
              /// </summary>
              public AsyncReactiveCommand MotorToggleCommand { get; }
      
              /// <summary>
              /// TeachCommand feasibility
              /// </summary>
              public ReactivePropertySlim<bool> CanTeach { get; } = new(false);
      
              /// <summary>
              /// Teach command
              /// </summary>
              public AsyncReactiveCommand TeachCommand { get; }
      
              /// <summary>
              /// Teached points information for log
              /// </summary>
              public ReactiveCollection<LogItem> LogItems { get; } = [];
      
              /// <summary>
              /// API result expression
              /// </summary>
              public ReactivePropertySlim<string> APIResult { get; } = new();
      
              /// <summary>
              /// Auxiliary information for API result (Error message etc.)
              /// </summary>
              public ReactivePropertySlim<string> APIResultAux { get; } = new();
      
              /// <summary>
              /// Controller connection API object
              /// </summary>
              private IRCXControllerConnectionAPI? _connectionAPI;
      
              /// <summary>
              /// Controller API object
              /// </summary>
              private IRCXControllerAPI? _controllerAPI;
      
              /// <summary>
              /// Robot manager API object
              /// </summary>
              private IRCXRobotManagerAPI? _robotManagerAPI;
      
              /// <summary>
              /// Point API object
              /// </summary>
              private IRCXPointAPI? _pointAPI;
      
              /// <summary>
              /// Jogger object
              /// </summary>
              private IRCXRobotManagerAPI.IRCXJogger? _jogger;
      
              /// <summary>
              /// Polling timer
              /// </summary>
              private PeriodicTimer? _pollingTimer;
      
              /// <summary>
              /// Polling task
              /// </summary>
              private Task? _pollingTask;
      
              /// <summary>
              /// Next axis to jog
              /// </summary>
              private IRCXRobotManagerAPI.RCXJogCartesianAxis _targetAxis = IRCXRobotManagerAPI.RCXJogCartesianAxis.Z;
      
              /// <summary>
              /// Polling interval
              /// </summary>
              private const long _pollingMSec = 10;
      
              /// <summary>
              /// Target point file for teaching
              /// </summary>
              private string? _targetPointFile;
      
              /// <summary>
              /// Toggles the motor state
              /// </summary>
              /// <returns>Task</returns>
              private async Task OnMotorToggleAsync()
              {
                  if (_controllerAPI != null)
                  {
                      if (_controllerAPI.IsMotorOn == true)
                      {
                          var result = await _controllerAPI.MotorOffAsync();
                          APIResult.Value = result.ToString();
                          APIResultAux.Value = string.Empty;
                      }
                      else if (_controllerAPI.IsMotorOn == false)
                      {
                          var result = await _controllerAPI.MotorOnAsync();
                          APIResult.Value = result.ToString();
                          APIResultAux.Value = string.Empty;
                      }
                  }
              }
      
              /// <summary>
              /// Jog along specified axis
              /// </summary>
              /// <param name="axis">Axis</param>
              /// <param name="position">Stick position</param>
              /// <returns>Task</returns>
              private async Task Jog(
                  IRCXRobotManagerAPI.RCXJogCartesianAxis axis,
                  double position
              )
              {
                  if (_jogger != null && _jogger.IsValid)
                  {
                      var oppositeDirection = (position > 0);
                      var (result, message) = await _jogger.StartCartesianJogAsync(axis, oppositeDirection);
                      APIResult.Value = result.ToString() + (string.IsNullOrEmpty(message) ? string.Empty : " *");
                      APIResultAux.Value = message;
                  }
              }
      
              /// <summary>
              /// Check the stick positions and jog
              /// </summary>
              /// <returns>Task</returns>
              private async Task CheckStickPosition()
              {
                  switch (_targetAxis)
                  {
                      case IRCXRobotManagerAPI.RCXJogCartesianAxis.X:
                          if (Math.Abs(RightStickPosition.Value.X) >= _positionThreshold)
                          {
                              await Jog(_targetAxis, RightStickPosition.Value.X);
                          }
                          _targetAxis = IRCXRobotManagerAPI.RCXJogCartesianAxis.Y;
                          break;
      
                      case IRCXRobotManagerAPI.RCXJogCartesianAxis.Y:
                          if (Math.Abs(RightStickPosition.Value.Y) >= _positionThreshold)
                          {
                              await Jog(_targetAxis, RightStickPosition.Value.Y);
                          }
                          _targetAxis = IRCXRobotManagerAPI.RCXJogCartesianAxis.Z;
                          break;
      
                      case IRCXRobotManagerAPI.RCXJogCartesianAxis.Z:
                          if (Math.Abs(LeftStickPosition.Value.Y) >= _positionThreshold)
                          {
                              await Jog(_targetAxis, LeftStickPosition.Value.Y);
                          }
                          _targetAxis = IRCXRobotManagerAPI.RCXJogCartesianAxis.X;
                          break;
                  }
              }
      
              /// <summary>
              /// Set target point file
              /// </summary>
              /// <param name="robotNumber">Robot number</param>
              private void SetTargetPointFile(
                  int? robotNumber
              )
              {
                  _targetPointFile = null;
      
                  if (_pointAPI != null)
                  {
                      var descriptors = _pointAPI.PointFileDescriptors;
      
                      _targetPointFile = descriptors
                          .Where(x => x.RobotNumber == robotNumber && x.IsDefault)
                          .Select(x => x.FileName)
                          .FirstOrDefault();
      
                      if (_targetPointFile == null)
                      {
                          _targetPointFile = descriptors
                              .Where(x => x.RobotNumber == null)
                              .Select(x => x.FileName)
                              .FirstOrDefault();
                      }
                  }
      
                  CanTeach.Value = (IsOnline.Value && !string.IsNullOrEmpty(_targetPointFile));
              }
      
              /// <summary>
              /// Teach point
              /// </summary>
              /// <returns>Task</returns>
              private Task OnTeachAsync()
              {
                  if (_pointAPI != null && _targetPointFile != null)
                  {
                      var (result, points) = _pointAPI.GetPoints(_targetPointFile);
                      if (result == RCXResult.Success && points != null)
                      {
                          var pointNumbers = points.Select(x => (int)x["Number"].Value).ToHashSet();
                          var pointNumberRange = Enumerable.Range(
                              _pointAPI.PointNumberMin,
                              _pointAPI.PointNumberMax - _pointAPI.PointNumberMin + 1
                          );
                          foreach (var number in pointNumberRange)
                          {
                              if (!pointNumbers.Contains(number))
                              {
                                  var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
      
                                  var teachResult = _pointAPI.TeachPoint(
                                      _targetPointFile,
                                      number,
                                      description: $"SimpleJog: {stamp}",
                                      shouldSave: true
                                  );
                                  APIResult.Value = teachResult.ToString();
                                  APIResultAux.Value = string.Empty;
      
                                  if (teachResult == RCXResult.Success)
                                  {
                                      LogItems.Add(new(number, _robotManagerAPI?.WorldPosition));
                                  }
                                  break;
                              }
                          }
                      }
                  }
      
                  return Task.CompletedTask;
              }
      
              /// <summary>
              /// Constructor
              /// </summary>
              public DockingWindowContentViewModel()
              {
                  MotorToggleCommand = IsOnline
                  .ToAsyncReactiveCommand()
                  .WithSubscribe(OnMotorToggleAsync)
                  .AddTo(_disposables);
      
                  TeachCommand = CanTeach
                  .ToAsyncReactiveCommand()
                  .WithSubscribe(OnTeachAsync)
                  .AddTo(_disposables);
              }
      
              /// <inheritdoc />
              public Task WindowCreated()
              {
                  _connectionAPI = Main.GetAPI<IRCXControllerConnectionAPI>();
      
                  _connectionAPI?.ObserveProperty(x => x.IsOnline).Subscribe((isOnline) =>
                  {
                      IsOnline.Value = (isOnline == true);
      
                      CanTeach.Value = (IsOnline.Value && !string.IsNullOrWhiteSpace(_targetPointFile));
                  })
                  .AddTo(_disposables);
      
                  _controllerAPI = Main.GetAPI<IRCXControllerAPI>();
                  _robotManagerAPI = Main.GetAPI<IRCXRobotManagerAPI>();
                  _pointAPI = Main.GetAPI<IRCXPointAPI>();
      
                  _controllerAPI?.ObserveProperty(x => x.IsMotorOn).Subscribe(async (isMotorOn) =>
                  {
                      IsMotorOn.Value = (isMotorOn == true);
                      MotorState.Value = (isMotorOn == true) ? "On" : "Off";
      
                      if (_robotManagerAPI != null)
                      {
                          if (isMotorOn == true)
                          {
                              _jogger = await _robotManagerAPI.CreateJoggerAsync();
                              _pollingTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(_pollingMSec));
                              _pollingTask = Task.Factory.StartNew(async () =>
                              {
                                  while (await _pollingTimer.WaitForNextTickAsync())
                                  {
                                      await CheckStickPosition();
                                  }
                              });
                          }
                          else
                          {
                              if (_jogger != null)
                              {
                                  await _jogger.DisposeAsync();
                                  _jogger = null;
                              }
                              _pollingTask?.Dispose();
                              _pollingTimer?.Dispose();
                          }
      
                      }
                  })
                  .AddTo(_disposables);
      
                  _robotManagerAPI?.ObserveProperty(x => x.CurrentRobotNumber).Subscribe((robotNumber) =>
                  {
                      SetTargetPointFile(robotNumber);
                  })
                  .AddTo(_disposables);
      
                  return Task.CompletedTask;
              }
          }
      }
      
  10. 编辑 MainMenuItem.cs 文件。

    • 微动操作以与机器人控制器 (虚拟或实机) 连接已建立为前提。因此,若通过工具栏打开窗口时未连接,则需进行控制器连接。

      • 连接控制器需使用控制器连接 API。ConnectControllerAsync 方法与 RC+ 主体的控制器连接相同,自动连接时将尝试连接上次连接的控制器。非自动连接则显示“PC 与控制器连接”画面。
      (前略)
              /// <inheritdoc />
              public async Task ExecuteMainMenuItemCommandAsync(
                  string commandName,
                  bool fromToolBar
              )
              {
                  if (fromToolBar)
                  {
                      var controllerConnectionAPI = Main.GetAPI<IRCXControllerConnectionAPI>();
                      if (controllerConnectionAPI?.IsOnline == false)
                      {
                          _ = await controllerConnectionAPI.ConnectControllerAsync().ConfigureAwait(true);
                      }
                  }
      
                  await DockingWindowContentViewModel.Show();
              }
      (后略)
      
  11. 编辑 Captions.xlsx 文件。

  12. 进行构建和调试。

    • 请打开 Extension 画面、机器人管理器的“微动&示教”,以及“模拟器”画面,测试机器人是否可移动。
      • 根据机器人的位置姿态,有时无法沿直角坐标进行微动操作。此时请先通过其他方式更改机器人位置姿态后再操作。

■ 中级篇

在中级篇中,将实现作为输入设备使用游戏手柄。(已在 Xbox Wireless Controller 上确认可用。)

  1. 在 Visual Studio 的解决方案资源管理器中双击 SimpleJog 项目,进行如下更改。

    • 将 TargetFramework 修改为 net8.0-windows10.0.19041.0。
      • 由此可使用 Windows 运行时 (WinRT) 中的 Windows.Gaming.Input API,便于处理游戏手柄。
  2. 编辑 install.json 文件。

    • 该文件用于指定如下内容。

      • 除构建输出文件夹外,Extension 使用的需另行复制的内容文件夹
      • 需与 Extension 主体一起显式加载的程序集
    • 此处内容如下。

      {
          "Contents": [
          ],
          "Dependents": [
              "Microsoft.Windows.SDK.NET.dll",
              "WinRT.Runtime.dll"
          ]
      }
      
  3. 在 DockingWindow 文件夹中添加如下文件。

    • GamepadInfo.cs

      • 用于识别游戏手柄信息的 GamepadInfo 类文件。

        • Windows.Gaming.Input 的 Gamepad 类由于规范限制,无法直接获取易于理解的名称等信息。因此,在本 Extension 中,仅按发现顺序对游戏手柄进行识别。
        (前略)
        
        namespace SimpleJog.DockingWindow
        {
            using Windows.Gaming.Input;
        
            /// <summary>
            /// Gamepad information
            /// </summary>
            public class GamepadInfo
            {
                /// <summary>
                /// Gamepad object
                /// </summary>
                public Gamepad Gamepad { get; }
        
                /// <summary>
                /// Gamepad number
                /// </summary>
                public int Number { get; }
        
                /// <summary>
                /// Gamepad name
                /// </summary>
                public string Name => $"Gamepad #{Number}";
        
                /// <summary>
                /// Constructor
                /// </summary>
                /// <param name="gamepad">Gamepad object</param>
                /// <param name="number">Gamepad number</param>
                public GamepadInfo(
                    Gamepad gamepad,
                    int number
                )
                {
                    Gamepad = gamepad;
                    Number = number;
                }
            }
        }
        
    • IGamepadInputService.cs

      • 用于本 Extension 中游戏手柄输入接口 IGamepadInputService 的描述文件。

        (前略)
        
        namespace SimpleJog.DockingWindow
        {
            using Reactive.Bindings;
            using Windows.Gaming.Input;
        
            /// <summary>
            /// Interface of gamepad input service
            /// </summary>
            public interface IGamepadInputService
            {
                /// <summary>
                /// Property for current reading
                /// </summary>
                public IReadOnlyReactiveProperty<GamepadReading> CurrentReading { get; }
        
                /// <summary>
                /// Set target gamepad
                /// </summary>
                /// <param name="gamepad">Gamepad object</param>
                public void SetGamepad(
                    Gamepad gamepad
                );
        
                /// <summary>
                /// Start service
                /// </summary>
                public void Start();
        
                /// <summary>
                /// Stop service
                /// </summary>
                public void Stop();
            }
        }
        
    • GamepadInputService.cs

      • 用于描述实现 IGamepadInputService 接口的 GamepadInputService 类的文件。

        • 通过使用定时器进行轮询,更新输入状态。但是,当鼠标按钮被按下时,会跳过更新。由于 DispatcherTimer 的 Tick 会在 UI 线程中调用,因此可以访问 System.Windows.Input 的 Mouse 实例。
        (前略)
        
        namespace SimpleJog.DockingWindow
        {
            using Reactive.Bindings;
            using System.Windows.Threading;
            using Windows.Gaming.Input;
        
            /// <summary>
            /// Implementation of gamepad input service
            /// </summary>
            public class GamepadInputService : IGamepadInputService
            {
                /// <inheritdoc />
                public IReadOnlyReactiveProperty<GamepadReading> CurrentReading => _reading;
        
                /// <summary>
                /// The substance of CurrentReading
                /// </summary>
                private readonly ReactivePropertySlim<GamepadReading> _reading = new(mode: ReactivePropertyMode.None);
        
                /// <summary>
                /// Target gamepad
                /// </summary>
                private Gamepad? _gamepad;
        
                /// <summary>
                /// Timer for polling
                /// </summary>
                private DispatcherTimer _timer;
        
                /// <summary>
                /// Polling interval
                /// </summary>
                private const int _pollingIntervalMSec = 16;
        
                /// <summary>
                /// Constructor
                /// </summary>
                public GamepadInputService()
                {
                    _timer = new()
                    {
                        Interval = TimeSpan.FromMilliseconds(_pollingIntervalMSec),
                    };
        
                    _timer.Tick += (_, _) =>
                    {
                        if (_gamepad != null)
                        {
                            if (Mouse.LeftButton == MouseButtonState.Pressed)
                            {
                                return;
                            }
        
                            _reading.Value = _gamepad.GetCurrentReading();
                        }
                    };
                }
        
                /// <inheritdoc />
                public void SetGamepad(
                    Gamepad? gamepad
                )
                {
                    _gamepad = gamepad;
                }
        
                /// <inheritdoc />
                public void Start()
                {
                    _timer.Start();
                }
        
                /// <inheritdoc />
                public void Stop()
                {
                    _timer.Stop();
                }
            }
        }
        
    • InputService.cs

      • 用于描述将游戏手柄输入转换为本Extension专用输入的服务类 InputService 的文件。

        • Stick 的鼠标处理也有类似的描述,但在此还会进行死区和平滑处理。游戏手柄的摇杆即使处于中立位置,值也可能不是零。在特定范围内将其视为零的处理就是死区处理。此外,即使摇杆动作很急,值也会经过调整,使其变化相对平缓,这就是平滑处理。
        (前略)
        
        namespace SimpleJog.DockingWindow
        {
            (中略)
        
            /// <summary>
            /// Input service
            /// </summary>
            public class InputService : IDisposable
            {
                /// <summary>
                /// State of gamepad buttons
                /// </summary>
                public ReactivePropertySlim<GamepadButtons> Buttons { get; } = new(GamepadButtons.None);
        
                /// <summary>
                /// Left stick position
                /// </summary>
                public ReactivePropertySlim<Vector> LeftStickPosition { get; } = new();
        
                /// <summary>
                /// Right stick position
                /// </summary>
                public ReactivePropertySlim<Vector> RightStickPosition { get; } = new();
        
                /// <summary>
                /// Stores the most recently calculated smoothed position for the left stick.
                /// </summary>
                private Vector _leftSmoothedPosition;
        
                /// <summary>
                /// Stores the most recently calculated smoothed position for the right stick.
                /// </summary>
                private Vector _rightSmoothedPosition;
        
                /// <summary>
                /// Dead zone definition
                /// </summary>
                private const double _deadZoneFactor = 0.05;
        
                /// <summary>
                /// Represents the smoothing factor used in calculations that require exponential smoothing.
                /// </summary>
                /// <remarks>This constant determines the weight given to new data points versus historical data
                /// in smoothing algorithms. A lower value results in smoother output but slower response to changes.</remarks>
                private const double _smoothingFactor = 0.2;
        
                /// <summary>
                /// Disposables
                /// </summary>
                private readonly CompositeDisposable _disposables = [];
        
                /// <summary>
                /// Constructor
                /// </summary>
                /// <param name="gamepadInputService">Gamepad input service</param>
                public InputService(
                    IGamepadInputService gamepadInputService
                )
                {
                    gamepadInputService.CurrentReading.Subscribe((reading) =>
                    {
                        Buttons.Value = reading.Buttons;
        
                        _leftSmoothedPosition = AdjustPosition(
                            new Vector(reading.LeftThumbstickX, reading.LeftThumbstickY),
                            _leftSmoothedPosition
                        );
                        _rightSmoothedPosition = AdjustPosition(
                            new Vector(reading.RightThumbstickX, reading.RightThumbstickY),
                            _rightSmoothedPosition
                        );
        
                        LeftStickPosition.Value = _leftSmoothedPosition;
                        RightStickPosition.Value = _rightSmoothedPosition;
                    })
                    .AddTo(_disposables);
                }
        
                /// <summary>
                /// Dead zone check and smoothing
                /// </summary>
                /// <param name="currentPosition">Current stick position</param>
                /// <param name="lastPosition">Last stick position</param>
                /// <returns>Adjusted stick position</returns>
                private Vector AdjustPosition(
                    Vector currentPosition,
                    Vector lastPosition
                )
                {
                    var distance = Math.Sqrt(
                        Math.Pow(currentPosition.X, 2.0)
                        + Math.Pow(currentPosition.Y, 2.0)
                    );
        
                    if (distance < _deadZoneFactor)
                    {
                        return new Vector();
                    }
                    else
                    {
                        return new Vector(
                            lastPosition.X * (1.0 - _smoothingFactor) + currentPosition.X * _smoothingFactor,
                            lastPosition.Y * (1.0 - _smoothingFactor) + currentPosition.Y * _smoothingFactor
                        );
                    }
                }
        
                /// <inheritdoc />
                public void Dispose()
                {
                    _disposables.Dispose();
                }
            }
        }
        
  4. 编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml 文件。

    • 在最上层的 Grid 中添加列,并放置用于选择游戏手柄的 ComboBox。

      • ItemsSource 绑定到 Gamepads(ReactiveCollection<GamepadInfo>)。
      • SelectedIndex 绑定到 SelectedGamepadIndex.Value。SelectedGamepadIndex 是 ReactivePropertySlim<int>
    • 将绑定到 Stick 的 LeftStickPosition 和 RightStickPosition 分别更改为 InputService.LeftStickPosition 和 InputService.RightStickPosition。

      (前略)
      
              <StackPanel
                  Grid.Row="2" Grid.Column="0"
                  Orientation="Horizontal">
      
                  <Label
                      Content="Gamepads:"
                      VerticalAlignment="Center" />
                  <ComboBox
                      ItemsSource="{Binding Gamepads}"
                      SelectedIndex="{Binding SelectedGamepadIndex.Value}"
                      DisplayMemberPath="Name"
                      IsReadOnly="True"
                      MinWidth="100"
                      VerticalAlignment="Center"
                      Margin="10,0,0,0" />
      
              </StackPanel>
      
      (后略)
      
  5. 编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。

    • CheckStickPosition 方法中的 LeftStickPosition 等要改写为 InputService.LeftStickPosition 等。

    • 在窗口显示过程中,如果游戏手柄有插拔,会触发 GamepadAdded 和 GamepadRemoved 事件。如果在窗口显示前游戏手柄已连接,则不会触发这些事件,因此需要另外检查已连接的游戏手柄 (ScanGamepads 方法)。

    • 游戏手柄按钮的按下,通过监视 InputService 实例的 Buttons 属性,调用相应的命令。

      (前略)
              /// <summary>
              /// Input service object
              /// </summary>
              public InputService InputService { get; }
      
              (中略)
      
              /// <summary>
              /// List of connected game pads
              /// </summary>
              public ReactiveCollection<GamepadInfo> Gamepads { get; } = new();
      
              /// <summary>
              /// Selected game pad index
              /// </summary>
              public ReactivePropertySlim<int> SelectedGamepadIndex { get; } = new(-1);
      
              (中略)
      
              /// <summary>
              /// Gamepad input service object
              /// </summary>
              private GamepadInputService _gamepadInputService = new();
      
              (中略)
      
              /// <summary>
              /// Scans for connected gamepads
              /// </summary>
              private void ScanGamepads()
              {
                  SelectedGamepadIndex.Value = -1;
      
                  Gamepads.Clear();
      
                  const int _waitMSec = 100;
                  const int _maxRetryCount = 30;
      
                  for (var retryCount = 0; retryCount < _maxRetryCount; retryCount++)
                  {
                      if (Gamepad.Gamepads.Count <= 0)
                      {
                          Thread.Sleep(_waitMSec);
                      }
                      else
                      {
                          foreach (var (gamepad, index) in Gamepad.Gamepads.Select((x, index) => (x, index)))
                          {
                              Gamepads.Add(new GamepadInfo(gamepad, 1 + index));
                          }
                          SelectedGamepadIndex.Value = 0;
                          break;
                      }
                  }
              }
      
              /// <summary>
              /// Constructor
              /// </summary>
              public DockingWindowContentViewModel()
              {
                  InputService = new(_gamepadInputService);
      
                  (中略)
      
                  InputService.Buttons.Subscribe((buttons) =>
                  {
                      if ((buttons & GamepadButtons.LeftShoulder) != 0)
                      {
                          MotorToggleCommand.Execute();
                      }
      
                      if ((buttons & GamepadButtons.A) != 0)
                      {
                          TeachCommand.Execute();
                      }
                  })
                  .AddTo(_disposables);
      
                  SelectedGamepadIndex.Subscribe((index) =>
                  {
                      if (index >= 0)
                      {
                          _gamepadInputService.SetGamepad(Gamepads[index].Gamepad);
                      }
                  })
                  .AddTo(_disposables);
      
                  Gamepad.GamepadAdded += (_, gamepad) =>
                  {
                      Gamepads.AddOnScheduler(new GamepadInfo(gamepad, Gamepads.Count));
                  };
      
                  Gamepad.GamepadRemoved += (_, gamepad) =>
                  {
                      var target = Gamepads.FirstOrDefault(x => ReferenceEquals(x.Gamepad, gamepad));
                      if (target != null)
                      {
                          Gamepads.RemoveOnScheduler(target);
                      }
                  };
      
                  ScanGamepads();
      
                  _gamepadInputService.Start();
              }
      
              (后略)
      
  6. 请编辑 DockingWindow 文件夹中的 DockingWindowContentViewMode.cs 文件。

    • 关闭窗口时,也要停止 GamepadInputService。

      (前略)
      
      /// <inheritdoc />
      public Task<bool> CloseAsync()
      {
          _gamepadInputService.Stop();
      
          return Task.FromResult(true);
      }
      
      (后略)
      
  7. 进行构建和调试。

    • 与初级篇一样,打开各窗口,确认能否通过游戏手柄进行操作。
    • 本 Extension 在游戏手柄支持方面存在以下限制。
      • 如果 Extension 的窗口没有焦点,则无法捕获游戏手柄输入。
      • 特别是在通过 API 调用打开确认对话框等情况下,无法使用游戏手柄点击对话框的按钮,因此必须中断游戏手柄操作,改用 PC 的鼠标或键盘。
        • 在本 Extension 中,开启马达时的确认对话框属于此类情况。在可以判断为可以省略确认对话框显示的场合 (请谨慎考虑),可以通过执行 SPEL+ 命令 "Motor On" 替代马达开启的 API,从而跳过确认。在最终代码中已实现此功能,有兴趣的用户请自行查阅。