簡易點動
RC+ 配備了可由機器人管理器等呼叫的全功能「點動&示教」,但若建立 Extension,則可僅呼叫「點動&示教」所需功能,實現自定義點動面板(視窗)。
根據使用情境,透過建立自定義點動面板,有可能提升示教的效率。
此外,不僅限於自定義點動面板,透過 RC+ Extensions 客製化 RC+,可望打造專屬於自己的 RC+,進而提升作業舒適度。
本教學初級篇將製作以下簡易點動面板,並說明功能的呼叫方式。
- 按一下馬達的「切換」按鈕時,會切換馬達的開/關狀態。
- 面板左右各有一個以滑鼠拖曳操作、類似遊戲手把「搖桿」的控制項。
- 左側搖桿可上下操作,對應 Z 座標方向進行點動。
- 右側搖桿可上下操作對應 Y 座標,左右操作對應 X 座標方向進行點動。
- 按一下「示教」按鈕時,會將機器人目前的位置姿態,依序示教至目標點檔案中尚未定義的點。
- 於點位中,會加入本 Extension 示教的註解及示教的日期時間。
- 面板上會顯示已示教點位的記錄。
在中級篇中,若實際連接遊戲手把,則可透過該遊戲手把進行操作。
- 馬達的「切換」將指派給左側的 Bumper 按鈕(亦稱 Shoulder)。
- 左右的搖桿風格控制項,將可由實體搖桿操作。
- 「示教」將指派給 A 按鈕。
注意
若於機器人實機上測試本 Extension,請務必在考量安全設計的前提下,僅於安全柵外側進行操作。
那麼,讓我們開始吧。
■ 初級篇
請依照[開始使用]步驟,建立新的 RC+ Extensions 專案。
- 名稱請設定為 SimpleJog。
- 初始功能請勾選Main menu and tool bar item以及Docking window。
- 於 ARM64 版 Windows,請將組態變更為 x64。
請於 Visual Studio 上進行建置與除錯,確認功能可正常運作。
- 於 RC+ 主選單的擴展標籤選單項目中,會新增
SimpleJog (xx)(xx 為顯示語言名稱),選取該選單項目後,若顯示停駐視窗即表示設定成功。
- 於 RC+ 主選單的擴展標籤選單項目中,會新增
範本確認完成後,請先結束 RC+。
請於 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); } } }- 此為於 Stick 控制項中,新增用以表示「旋鈕」位置之 Vector 型別 Position 屬性的檔案。
於此進行建置,並確認 Stick 能於 .xaml 設計檢視中顯示。
請編輯 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。
- Border 會參照 IsMotorOn(
- (1, 0):配置 3 行 4 列的 Grid,內含 6 個顯示座標方向的 Label 及 2 個 Stick。
- Stick 以 Viewbox 包覆,以利調整尺寸。
- IsEnabled 綁定至 IsMotorOn.Value。
- Position(位置)於左 Stick 綁定至 LeftStickPosition.Value。LeftStickPosition(
ReactivePropertySlim<Vector>)為左 Stick 的「旋鈕」位置。右 Stick 亦同理。
- Stick 以 Viewbox 包覆,以利調整尺寸。
- (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 亦於後續建立。
- ItemsSource 綁定至 LogItems(
- Button 用於示教。
<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>
請於 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}"; } } (後略)
請於 DockingWindow 資料夾建立 AutoScrollBehavior.cs 檔案。
- 此僅與 WPF 相關,細節略。
請編輯 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)演算法。禁止同時執行多個點動任務,若前一週期啟動的點動尚未結束,則新點動啟動會發生錯誤。
- 亦可實作取消進行中的點動並啟動新點動。
- 點動相關參數(點動移動距離、速度等)於 RC+ 全域共用。於 RC+ 本體「點動&示教」變更時,原則上亦適用於 SimpleJog。本次不於 SimpleJog 實作參數變更,請視需要搭配本體「點動&示教」使用。
點檔可由點位 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; } } }
請編輯 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(); } (後略)
請編輯 Captions.xlsx 檔案。
請進行建置與除錯。
- 請開啟 Extension 畫面、機器人管理器的「點動&示教」及「模擬器」畫面,測試機器人是否可動作。
- 依機器人位置姿態,有時無法沿直角座標進行點動。此時請先以其他方式變更機器人位置姿態後再操作。

- 請開啟 Extension 畫面、機器人管理器的「點動&示教」及「模擬器」畫面,測試機器人是否可動作。
■ 中級篇
中級篇將可使用遊戲手把作為輸入裝置。(已於 Xbox Wireless Controller 驗證運作。)
於 Visual Studio 的方案總管中,按兩下 SimpleJog 專案並進行以下變更。
- 請將 TargetFramework 變更為 net8.0-windows10.0.19041.0。
- 如此即可使用 Windows 執行階段(WinRT)中的 Windows.Gaming.Input API,輕鬆處理遊戲手把。
- 請將 TargetFramework 變更為 net8.0-windows10.0.19041.0。
請編輯 install.json 檔案。
此檔案用於指定以下內容。
- 於建置輸出資料夾外,需另行複製供 Extension 使用的內容等資料夾
- 需與 Extension 本體一併明確載入的組件
此處內容如下。
{ "Contents": [ ], "Dependents": [ "Microsoft.Windows.SDK.NET.dll", "WinRT.Runtime.dll" ] }
請於 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(); } } }
請編輯 DockingWindow 資料夾下的 DockingWindowContent.xaml 檔案。
於最上層的 Grid 新增欄位,並配置用於選擇遊戲手把的 ComboBox。
- ItemsSource 綁定至 Gamepads(
ReactiveCollection<GamepadInfo>)。 - SelectedIndex 綁定至 SelectedGamepadIndex.Value。SelectedGamepadIndex 為
ReactivePropertySlim<int>。
- ItemsSource 綁定至 Gamepads(
原本綁定於 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> (後略)
請編輯 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(); } (後略)
請編輯 DockingWindow 資料夾下的 DockingWindowContentViewMode.cs 檔案。
關閉視窗時,請同時停止 GamepadInputService。
(前略) /// <inheritdoc /> public Task<bool> CloseAsync() { _gamepadInputService.Stop(); return Task.FromResult(true); } (後略)
請進行建置與除錯。
- 如同初級篇,請開啟各個視窗,確認能否以遊戲手把進行操作。
- 本 Extension 的遊戲手把支援有以下限制。
- 若 Extension 視窗未取得焦點,則無法接收遊戲手把的輸入。
- 特別是在 API 呼叫時若開啟確認對話框等,將無法使用遊戲手把按一下對話框按鈕,因此需暫停遊戲手把操作,改用 PC 滑鼠或鍵盤。
- 於本 Extension 中,開啟馬達時的確認對話框即屬此情形。若可判斷可省略顯示確認對話框的情境(請謹慎評估),可改為執行 SPEL+ 指令 "Motor On",以略過確認流程,取代呼叫馬達啟動的 API。於最終程式碼中已有此實作,有興趣者可自行查閱。
