簡易點動

RC+ 配備了可由機器人管理器等呼叫的全功能「點動&示教」,但若建立 Extension,則可僅呼叫「點動&示教」所需功能,實現自定義點動面板(視窗)。
根據使用情境,透過建立自定義點動面板,有可能提升示教的效率。
此外,不僅限於自定義點動面板,透過 RC+ Extensions 客製化 RC+,可望打造專屬於自己的 RC+,進而提升作業舒適度。

本教學初級篇將製作以下簡易點動面板,並說明功能的呼叫方式。

  • 按一下馬達的「切換」按鈕時,會切換馬達的開/關狀態。
  • 面板左右各有一個以滑鼠拖曳操作、類似遊戲手把「搖桿」的控制項。
    • 左側搖桿可上下操作,對應 Z 座標方向進行點動。
    • 右側搖桿可上下操作對應 Y 座標,左右操作對應 X 座標方向進行點動。
  • 按一下「示教」按鈕時,會將機器人目前的位置姿態,依序示教至目標點檔案中尚未定義的點。
    • 於點位中,會加入本 Extension 示教的註解及示教的日期時間。
    • 面板上會顯示已示教點位的記錄。

在中級篇中,若實際連接遊戲手把,則可透過該遊戲手把進行操作。

  • 馬達的「切換」將指派給左側的 Bumper 按鈕(亦稱 Shoulder)。
  • 左右的搖桿風格控制項,將可由實體搖桿操作。
  • 「示教」將指派給 A 按鈕。

注意


若於機器人實機上測試本 Extension,請務必在考量安全設計的前提下,僅於安全柵外側進行操作

那麼,讓我們開始吧。

■ 初級篇

  1. 請依照[開始使用]步驟,建立新的 RC+ Extensions 專案。

    • 名稱請設定為 SimpleJog。
    • 初始功能請勾選Main menu and tool bar item以及Docking 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 控制項中,新增以滑鼠操作「旋鈕」的程式碼之 code-behind 檔案。
      (前略)
      
      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 於後續建立。
          • 為顯示最新追加的 Log 資訊,設定 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 物件同時啟動計時器,並依計時器週期取得搖桿位置,依序對各軸方向進行點動,採用輪詢(Round Robin)演算法。禁止同時執行多個點動任務,若前一週期啟動的點動尚未結束,則新點動啟動會發生錯誤。
        • 亦可實作取消進行中的點動並啟動新點動。
    • 點檔可由點位 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 的滑鼠處理也有類似的描述,此外,死區(Dead Zone)與平滑化(Smoothing)處理也在此進行。遊戲手把的搖桿即使在中立位置,數值有時也不會正好為零。在特定範圍內將其視為零的處理稱為死區(Dead Zone)處理。另外,即使搖桿動作很快,為了讓數值變化較為平緩,會進行平滑化(Smoothing)處理。
        (前略)
        
        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。於最終程式碼中已有此實作,有興趣者可自行查閱。