简易Jog
RC+ 配备了可通过机器人管理器等调用的全功能“微动&示教”,但在创建 Extension 时,可以只调用该“微动&示教”的必要功能,实现自定义微动面板 (窗口)。
根据使用场景,通过创建自定义微动面板,有可能提升示教操作的效率。
此外,不仅限于自定义微动面板,通过 RC+ Extensions 对 RC+ 进行自定义,可以打造专属的 RC+,从而实现更加舒适的作业体验。
在本教程的初级篇中,将制作如下简单的微动面板,并说明功能的调用方式。
- 点击电机的“切换”按钮,可切换电机的开 / 关状态。
- 面板上可通过鼠标拖动移动,并分别在左右各配置一个类似游戏手柄“摇杆”风格的控制器。
- 左侧摇杆可上下操作,实现沿 Z 坐标轴的微动。
- 右侧摇杆可上下操作实现沿 Y 坐标轴的微动,左右操作实现沿 X 坐标轴的微动。
- 点击“Teach”按钮后,将机器人当前的位置姿态,在目标点文件中选择尚未定义的点,依次进行示教。
- 对于点,将添加注释,记录在本 Extension 中进行示教的信息以及示教的日期时间。
- 面板上将显示示教点的日志。
在中级篇中,若实际连接了游戏手柄,则可通过该游戏手柄进行操作。
- 电机的“切换”功能分配给左侧的肩部按钮 (也称为 Shoulder)。
- 左右的“摇杆”风格控制器,将实现可通过实际摇杆进行操作。
- “Teach”分配给 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 控件添加可通过鼠标移动“旋钮”的代码的代码后台文件。
(前略) 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 稍后创建。 - 为显示末尾新增的最新日志信息,设置 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 对象的同时启动定时器,并根据定时器周期获取的摇杆位置,依次对各轴方向进行微动,采用轮询算法。禁止同时执行多个微动任务,若前一周期启动的微动未结束,则新微动启动会报错。
- 也可实现取消正在进行的微动并启动新的微动。
- 与微动相关的参数 (微动移动距离、速度等) 在 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 的鼠标处理也有类似的描述,但在此还会进行死区和平滑处理。游戏手柄的摇杆即使处于中立位置,值也可能不是零。在特定范围内将其视为零的处理就是死区处理。此外,即使摇杆动作很急,值也会经过调整,使其变化相对平缓,这就是平滑处理。
(前略) 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,从而跳过确认。在最终代码中已实现此功能,有兴趣的用户请自行查阅。
