Sherpa-ONNX Speech To Text icon Sherpa-ONNX Speech To Text — Offline continuous speech recognition with wake word support, custom phrases, and real-time transcription using sherpa-onnx models on ARC Windows. Try it →
 

Robot Skill Services And Core

What a robot skill can call: the robot controllers (servos, digital, ADC, PWM, I2C, UART), video, audio, speech, movement, kinematics, navigation, game controllers and HID devices, browser windows on the robot's own screens, neural networks with ONNX Runtime, scripting, variables, logging and configuration.

This is the companion to RobotSkill-UI-Components.md, which covers the visual side. Both sit beside ARCx.exe, ARCx.dll and ARCx.xml. ARCx.xml is the authoritative reference for every public member; this manual tells you what exists, which of several overloads to reach for, and the rules that are not obvious from a signature.


Contents

  1. The worker: lifecycle and overrides
  2. Getting hold of a service
  3. EZBManager: the robot controllers
  4. Servos
  5. Digital, ADC, PWM
  6. I2C, UART and ping sensors
  7. Serial COM ports
  8. Video routing
  9. Audio
  10. Text to speech
  11. Speech to text
  12. Movement
  13. Kinematics
  14. Navigation messaging (NMS)
  15. Auto Position
  16. Scripting, control commands and variables
  17. Talking to other robot skills
  18. The project
  19. Configuration and the JSON schema
  20. Logging
  21. Persistence and runtime state
  22. Custom UI and robot configuration services
  23. Utilities
  24. Gamepads, joysticks and HID devices
  25. Browser windows: web pages, dashboards, video calls and kiosks
  26. Depth cameras
  27. Robot Bus: topics, services and frames
  28. Rules that will bite you

1. The worker: lifecycle and overrides

ARCx.Services.ProjectTools.RobotSkillWorkerAbstractEntity, which derives from ARCx.Core.Disposable.DisposableBase.

The worker holds the skill's logic and state. One instance per skill instance in the project, shared by every browser.

Lifecycle, in order

Member Notes
Constructor Create nothing here. No timers, no executors, no subscriptions
abstract RobotSkillWorkerDefaultParams Setup() Window defaults: Width (320), Height (200), AllowMultipleInstances (true), AllowFullScreen (true). Applied when the skill is added to a project; width and height only when the saved properties have none
abstract void Initialize() Create objects here: timers, executors, service registrations. Errors here are handled cleanly by the skill loader
virtual void OnSetConfiguration(OnSetConfigurationParams p) The saved configuration arrives. p.IncludesConfig is false for a skill the user just added by hand - use your defaults. p.ConfigStream is owned by the caller; do not dispose it
virtual void OnProjectLoadCompleted() Every skill is loaded and configured by now. The only safe place to look up another robot skill
virtual void GetConfigOnProjectSave(Stream s, JsonSerializerOptions o) Write your configuration. It is your stream; a zip holding many files is fine
protected abstract void DisposeOverride() Unsubscribe from every event and dispose everything. A leaked handler pins the plugin assembly and the skill will not rebuild

Between Setup() and Initialize() ARCx assigns Properties (the window title made unique), creates the never-rendered prototype UI instances and the speech engine, and checks the single-instance rules: a second TTS engine, a second movement panel, or a second instance of a skill whose Setup() returned AllowMultipleInstances = false is refused there. During Initialize() the skill is not yet in the project list, so other skills cannot see it. Throwing from Initialize() aborts the add and unloads the assembly.

How each way into a project calls the worker

Entry OnSetConfiguration OnProjectLoadCompleted
Project load IncludesConfig = true, stream from the project file Raised on every skill after all are loaded
Merge IncludesConfig = true Raised on every skill in the project, not only the merged ones
User adds from the menu, or the AI agent adds IncludesConfig = false, ConfigStream null Raised on the new skill only
Duplicate button on the workspace IncludesConfig = false Raised on the new skill only
New project IncludesConfig = false for each default skill Raised on all
Programmatic configuration change (config page save, AI agent, another skill) IncludesConfig = true, wrapped by OnConfigWindowOpening / OnConfigWindowClosed Not raised

Exceptions from OnSetConfiguration and OnProjectLoadCompleted during a load are caught, logged and reported as warnings; the project still loads. A programmatic configuration change does not set AllWorkersPaused.

Loading and removal

Each robot skill instance gets its own collectible AssemblyLoadContext. Dependencies and native libraries resolve through the skill's .deps.json, so two skills can ship different versions of one package. ARCx, MudBlazor, Microsoft.AspNetCore.Components and Microsoft.JSInterop always come from ARCx's own copy, whatever the skill ships.

Two instances of one skill mean two copies of every DLL it bundles. A library that finds its own assembly by name across the AppDomain (the OPC UA stack does) can pick up the other instance's copy and fail with a cast error. Suspect this whenever a bundled library works with one instance and fails with two.

ARCx takes the first type in the assembly assignable to each base class: RobotSkillWorkerAbstractEntity and RobotSkillUIMainBaseEntity (required), RobotSkillUIConfigBaseEntity and SpeechToTextEngineAbstractEntity (optional). Each is created with ActivatorUtilities.CreateInstance, which is why constructors can take services.

On removal (or clearing the project) ARCx, in order: removes the worker from the project list, clears its logs and canvases, disposes the UI components, raises ProjectToolsService.OnRobotSkillRemoved, calls DisposeOverride(), unloads the load context and runs a garbage collection. Anything still subscribed keeps the context alive and the DLL locked.

State to check

Member Notes
static bool AllWorkersPaused True while any skill's configuration window is open. Timers should skip their work
IsDisposing / IsDisposed From DisposableBase. A queued timer event can fire after removal; bail out
AnyActiveClients True when a browser is connected - any browser, even one looking at a different workspace
HasUIClient True when a browser is showing this skill's window (and so any UCRefreshRegion in it)
HasCustomUIClient True when a browser is showing a Custom UI screen with one of this skill's widgets on it
HasAnyUIClient HasUIClient or HasCustomUIClient. The check that matches RefreshUI(). Skip building display-only data when it is false
Properties RobotSkillProperties: InstanceGuid, StoreGUID, Title, WindowTitle, Notes, Width, Height, DesktopNum
AllowMultipleInstances, AllowFullScreen As returned from Setup()
CurrentRobotSkillUIMainComponent, CurrentRobotSkillUIConfigComponent The rendered components, when there are any

WindowTitle is the unique per-instance name the user sees and the address scripts use. InstanceGuid survives a rename, so a configuration that points at another skill is better off remembering the guid - but a user-facing setting is normally the window title, marked with [RobotSkillReference].

Redraw

Member Notes
RefreshUI() Redraws the skill window on every browser and every Custom UI widget the skill provides. Safe from any thread, safe before anything is rendered, does nothing when nobody is watching
RefreshCustomUIWidgets() Only the widgets
RefreshUIRegion(name) Only the UCRefreshRegion components with that name, on every browser. Use it for what a loop updates often (a play position, a reading) so the rest of the window is not redrawn. See RobotSkill-UI-Components.md section 3

Other overrides

Member Notes
virtual Type GetConfigurationType() Return the configuration class so ARCx can build its JSON Schema. Return null to opt out. See section 19
virtual ConfigWindowOpenStatus OnConfigWindowOpening() Set CanOpen = false and a ReasonMessage to refuse. Stop threads here if the configuration cannot change under them
virtual void OnConfigWindowClosed(ConfigWindowClosedStatus s) s.ConfigChanged says whether SAVE or CANCEL was pressed. Call base
virtual void UpdateMainUIStateRequested() The UI component is initializing and wants the latest display values. Set model variables only - do not call RefreshUI(), and there are no rendered references yet
virtual List<ControlCommandItem> GetSupportedControlCommands() See section 16
virtual object ControlCommand(ControlCommandArgs args) Fall through to base.ControlCommand(args), which throws the "no such command" exception for the caller
virtual DynamicContentMiddlewareResponse OnDynamicResourceRequest(DynamicContentMiddlewareRequest r) Serve bytes at /_dynamic/[instance guid]/.... See the UI manual, UCDynamicImage
virtual List<CustomUIWidgetDefinition> GetCustomUIWidgets() Widgets offered to the Custom UI
virtual List<BlocklySoundItem> GetBlocklySoundItems() Sounds for Blockly's Play Audio blocks. Title is the choice shown, Code the JavaScript that starts the sound, WaitCode the JavaScript that plays it and returns when it has finished (leave empty if the skill cannot tell; the wait block then does not wait). Code is also what the block saves, so keep it the same for the same sound
virtual List<BlocklyCameraItem> GetBlocklyCameraItems() Actions for Blockly's Camera category. Code is a JavaScript call without a semicolon, used both as a statement and as a value. Most skills return BlocklyCameraItemsFromControlCommands(), which offers every control command that runs without extra input
virtual GetBlocklyAutoPositionItemsResponse GetBlocklyAutoPositionItems(...) Auto Position actions offered to Blockly

Interfaces a worker can implement

Interface Effect
ARCx.Services.Movement.MovementPanelInterface Declares the skill the project's movement panel: it subscribes to MovementManagerService events and drives the motors. Marker interface, no members. Only one per project; adding a second is refused
ARCx.Services.TextToSpeech.TextToSpeechEngineInterface Declares the skill the project's TTS engine. One method, OnTextToSpeech(TextToSpeechRequestCls). Only one per project

Speech recognition engines are wired differently - see section 11.


2. Getting hold of a service

ARCx services are registered as singletons (one robot, not one per browser tab). Inject them through the worker's constructor:

public class RobotSkillWorker : RobotSkillWorkerAbstractEntity {

  readonly VideoRouteService _videoRouteService;
  readonly ProjectToolsService _projectToolsService;

  public RobotSkillWorker(VideoRouteService videoRouteService, ProjectToolsService projectToolsService) {

    // Constructor only stores references. Objects are created in Initialize()
    _videoRouteService = videoRouteService;
    _projectToolsService = projectToolsService;
  }
}

The injectable services:

Type Namespace Covered in
AiAgentService, AiAgentToolExecutor ARCx.Services.AiAgent -
AudioService ARCx.Services.AudioServices 9
BrowserPresentationService ARCx.Services.BrowserPresentation 25
CheckForUpdateService ARCx.Services.CheckForUpdate -
ConfigRegistryManagerService ARCx.Services.ConfigRegistry 21
CustomRegistryManagerService ARCx.Services.CustomRegistry 21
CustomUIService ARCx.Services.CustomUI 22
DepthSensorService ARCx.Services.DepthSensing 26
EZBScanService ARCx.Services.EZBScan -
InputDeviceService ARCx.Services.InputDevices 24
JsARCxService ARCx.Services.JSHelper -
KinematicsService ARCx.Services.Kinematics 13
MovementManagerService ARCx.Services.Movement 12
NMSService ARCx.Services.NMS.NMS2Dv1 14
PluginManagerService ARCx.Services.PluginManager -
ProjectToolsService ARCx.Services.ProjectTools 17, 18
RobotConfigService ARCx.Services.RobotConfig 22
RobotBusService ARCx.Services.RobotBus 27
RuntimeStateService ARCx.Services.RuntimeState 21
ScriptManagerService ARCx.Services.ScriptManager 16
AccountSecurityService ARCx.Services.Security -
SpeechToTextService ARCx.Services.SpeechToText 11
StartupScriptService ARCx.Services.StartupScript -
SynthiamFeedService ARCx.Services.SynthiamFeed -
TextToSpeechService ARCx.Services.TextToSpeech 10
VideoRouteService ARCx.Services.VideoRouting 8
ThemeStateService ARCx.Services.Theme Scoped (per browser), not for workers

Not injected - these are static and called directly:

EZBManager, VariableManager, ARCLogService, ComPortManager, FileManagerService, ConfigSchemaBuilder, UCLogBuffered.AddEntry, UCSkillCanvas.TryBeginDraw, ProjectToolsService.CurrentProjectProperties.


3. EZBManager: the robot controllers

ARCx.Core.EZBManager (static). The EZB classes live in ARCx.Core.EZBController.

An "EZB" is a robot controller. A project always has at least one, at index 0. The list is contiguous - there is never a gap between 0 and EZBSlotCount - 1 - and removing one shifts the rest down.

Member Notes
EZB PrimaryEZB Index 0. Always exists. Used when no board index is given, and by the movement panels and speech recognition
EZB[] EZBs A snapshot array. No entry is ever null
int EZBSlotCount Number of controllers
EZB GetEZB(int index) Throws when out of range
EZB TryGetEZB(int index) Null when out of range. Use this when a project may reference a board that is no longer there
int GetIndexOfEZB(EZB ezb) -1 when not in this project
int AddEZBToList() Returns the new index
void RemoveEZBByIndex(int id)
void ClearEZB() Disposes all and recreates index 0. Used on project close/load
event OnConnectionChange(EZB ezb, bool isConnected) Raised on whichever thread connected or disconnected
event EventHandler OnPanicReleaseServosAndStopMovementPressed The panic button. Stop whatever your skill is doing
RandomUnique GetRandomUnique A random-and-unique value generator
static readonly DateTime EZBuilderLoadedDateTime Effectively when ARCx started

The EZB object

Member Notes
bool IsConnected Check this before every hardware call
void Connect(string hostname) / Connect(string hostname, int baudRate) Hostname is a COM port name, or an IP such as 192.168.1.5:23. Baud is ignored for TCP
Task ConnectAsync(string hostname, CancellationToken) / ConnectAsync(string hostname, int baudRate, CancellationToken) Same as Connect, without holding the calling thread. Use this from Blazor event handlers so the page stays responsive during the connect timeout and handshake. Only one connect runs per EZB at a time; a second throws
int Index, string GUID
string ConnectedEndPointAddress COM port or IP of the current connection
FirmwareCls Firmware Query the connected board's capabilities
bool PingController() Is it still responding
event OnConnectionChange(EZB sender, bool isConnected)
event OnDataSend(byte[] data, int expectedReturnSize) / OnDataReceive(byte[] data)
TimeSpan ConnectedTimeSpan, DateTime LastCommandSentDateTime, LastCommandReturnDateTime
int BaudRate, bool DTREnable, bool RTSEnable, SerialPort _Serial Advanced serial control
TCPServer TCPServer, StartServer(int port), StopServer() For an EZB that connects to ARCx
ProtocolVersionEnum GetProtocolVersionEnum(), string GetProtocolVersion()

Peripheral sub-objects on the EZB: Servo, Digital, ADC, PWM, I2C, Uart, HC_SR04, Audio, Power, MusicSynth, Recorder, ConfigurationManager.

The pattern for every hardware call:

var ezb = EZBManager.TryGetEZB(_Configuration.Port.BoardIndex);

if (ezb == null || !ezb.IsConnected)
  return;

var value = ezb.ADC.GetADCValue(_Configuration.Port.Port);

4. Servos

Port enums

Servo.ServoPortEnum covers D0..D255 (physical digital ports), then V0..V255 (virtual ports, which robot skills such as a Dynamixel or Feetech driver hook onto and drive over their own bus), then NA for "not assigned".

Constants: Servo.SERVO_MIN (1), Servo.SERVO_OFF (0), Servo.SERVO_MAX (the project's MaxServoPositions, default 180), Servo.SERVO_CENTER, Servo.SERVO_PWM_VELOCITY_FASTEST (0), Servo.SERVO_PWM_VELOCITY_SLOWEST (20).

The descriptor helpers (preferred)

ARCx.Config.Helpers.ServoDescriptor is what a skill stores in its configuration, normally inside a ServoCollection edited by UCServoSelector.

ServoDescriptor member Default Notes
Port NA
BoardIndex 0
Minimum / Maximum SERVO_CENTER Positions outside are clamped
Invert false Increasing input moves toward Minimum
Multiplier 1 (decimal) So a servo can follow another at a different ratio
Velocity / Acceleration -1 -1 leaves the servo's own value untouched
Description empty Shown in the servo selector

ServoCollection.Servos is a List<ServoDescriptor>, defaulting to one unassigned servo. Servos[0] is the master; every following servo derives its position from the first using its own Multiplier.

The EZBManager helpers apply all of that for you:

Method Notes
SetServoPosition(ServoCollection servos, int position) Exact position, clamped per servo
SetServoPosition(ServoDescriptor servo, int position)
SetServoPositionScalar(ServoCollection servos, int inputMin, int inputMax, int input) Arduino map() for servos: maps the input range onto each servo's own min..max
SetServoPositionScalar(ServoCollection servos, float inputMin, float inputMax, float input) Float overload, so 0..1 with 0.5 means halfway
SetServoIncrement(ServoCollection servos, int steps) / (ServoDescriptor, int)
GetServoPosition(ServoCollection servos) / (ServoDescriptor) The last commanded position, not a reading. For a bi-directional servo use GetServoPositionRealtime()
SetReleaseServo(ServoCollection) / (ServoDescriptor) Release from holding
GetReleaseServo(ServoCollection servos) Whether the first servo is released
ReleaseAllServos() Every servo on every board, and raises the panic event

Moving many servos in sync

One command per board rather than one per servo, which keeps a multi-servo move together:

var buffers = EZBManager.CreateServoBuffers();

EZBManager.TryBufferServoPosition(buffers, boardIndex, new ServoPositionItem(port, position));
// ... buffer more ...

EZBManager.DispatchServoBuffers(buffers);

TryBufferServoPosition silently ignores a board index that is out of range, so a project referencing a missing controller moves the servos it can rather than throwing.

The raw servo API

ezb.Servo when you need a specific port directly. Classes are in ARCx.Core.EZBController.Classes.

Method Notes
SetServoPosition(ServoPortEnum port, int position)
SetServoPosition(ServoPortEnum port, int position, int velocity)
SetServoPosition(ServoPortEnum port, int position, int velocity, int acceleration)
SetServoPosition(ServoPositionItem[] servos) One command for many servos on this board
SetServoPositionScalar(port, servoMin, servoMax, clientWidthMin, clientWidthMax, clientPosition, bool invert) int and float overloads
GetServoPosition(ServoPortEnum port) Last commanded
GetServoPositionRealtime(ServoPortEnum port) Reads the servo. Bi-directional servos only
SetServoVelocity(port, int) / (ServoPortEnum[], int) / GetServoVelocity(port)
SetServoAcceleration(port, int) / (ServoPortEnum[], int) / GetServoAcceleration(port)
SetServoMin / GetServoMin / ResetServoMinLimits Hard limits held on the controller. RobotConfigService pushes the robot's body limits down through these, and every SetServoPosition is clamped by them
SetServoMax / GetServoMax / ResetServoMaxLimits
SetServoFineTune / GetServoFineTune / ResetServoFineTune Per-servo trim
ReleaseServo(port) / (ServoPortEnum[]) / ReleaseAllServos()
bool IsServoReleased(port)
double GetNumberOfSecondsSinceLastMove(port)
ResetAllServoVelocities()

Events on ezb.Servo, for a skill that mirrors or logs servo traffic: OnServoMove, OnServoMoveDetailed, OnServoMoveDetailed2 (includes the EZB), OnServoVelocity, OnServoAcceleration, OnServoRelease, OnServoGetPosition.


5. Digital, ADC, PWM

Store the port in a descriptor (DigitalPortDescriptor, ADCPortDescriptor, PWMPortDescriptor in ARCx.Config.Helpers) edited by the pickers in the UI manual.

Digital - ezb.Digital

DigitalPortEnum: D0..D255, plus NA.

Method Notes
void SetDigitalPort(DigitalPortEnum port, bool status)
void SetDigitalPort(DigitalItem[] items) Many ports in one command
bool GetDigitalPort(DigitalPortEnum port) Reads the hardware
bool GetLastDigitalPortSet(DigitalPortEnum port) Last known, no traffic
int GetDigitalPortAsInt(DigitalPortEnum port)
bool Toggle(DigitalPortEnum port)
event OnDigitalPortChanged(EZB ezb, DigitalPortEnum port, bool state) Fires on an actual change only, however it arrived - a write, a toggle, or a read that came back different. Lets a skill mirror digital I/O without polling

ADC - ezb.ADC

ADCPortEnum: ADC0..ADC255, plus NA.

Method Notes
int GetADCValue(ADCPortEnum port) 0-255 (8 bit), relative voltage over 0-5V. 10 and 12 bit firmware readings are scaled down. Throws NotSupportedException when the firmware has no ADC capability
int GetADCValue12Bit(ADCPortEnum port) 0-4095
float GetADCVoltage(ADCPortEnum port) Volts
float GetADCVoltageFromValue(int adcValue) Convert a raw value you already have

PWM - ezb.PWM

PWMPortEnum: D0..D99 only (the digital ports that can output PWM). PWM.PWM_MIN is 0, PWM.PWM_MAX is 100.

Method Notes
void SetPWM(PWMPortEnum port, int dutyCycle) Clamped to min/max; 0 calls StopPWM(). Does nothing when disconnected. Throws when the firmware lacks CAP_PWM_DUTY
void SetPWM(PWMItem[] ports)
int GetPWM(PWMPortEnum port)
void StopPWM(PWMPortEnum port) / bool IsPWMStopped(port)
event OnPWMChanged(EZB ezb, PWMPortEnum port, int dutyCycle)

6. I2C, UART and ping sensors

I2C - ezb.I2C

Method Notes
void Write(byte deviceAddress, params byte[] data)
void WriteBinary(byte deviceAddress, byte b7, ... byte b0) Eight bits as separate arguments
byte ReadByte(byte deviceAddress)
byte[] Read(byte deviceAddress, byte expectedBytesReturn)
void SetClockSpeed(uint rate)

UART - ezb.Uart

Two families. Bit-banged serial out of any digital port (send only):

ezb.Uart.SendSerial(Digital.DigitalPortEnum.D5, Uart.BAUD_RATE_ENUM.Baud_9600, "hello");

Overloads take string, char[], byte, or byte[]. SetBaudClock(BAUD_RATE_ENUM, int clockSpeed) adjusts the timing.

The hardware UART expansion ports (bidirectional, buffered):

Method Notes
void UARTExpansionInit(int port, uint baudRate) Call before anything else
void UARTExpansionWrite(int port, byte[] data)
int UARTExpansionAvailableBytes(int port)
byte[] UARTExpansionRead(int port, int bytesToRead)
byte[] UARTExpansionReadAvailable(int port)
int MaxUARTReceiveBuffer Default 4000

HC-SR04 ultrasonic - ezb.HC_SR04

Member Notes
int GetValue(Digital.DigitalPortEnum triggerPort, Digital.DigitalPortEnum echoPort)
int MinPoolTimeMS Default 200. The sensor cannot be read faster than this
static readonly int MAX_VALUE 255

EZB speaker - ezb.Audio

ARCx.Core.EZBController.Audio. Streams 8 bit mono PCM to an EZ-B v4 speaker.

Member Value Notes
AUDIO_SAMPLE_BITRATE 14700 The playback sample rate
RECOMMENDED_PACKET_SIZE 256
RECOMMENDED_PREBUFFER_SIZE 20000 The EZ-B buffer is 50k, so never exceed that
PACKET_SIZE, PREBUFFER_SIZE Adjustable
OnAudioDataChanged, OnVolumeChanged, OnStopPlaying Events

ezb.MusicSynth makes synthesized music on the same speaker.


7. Serial COM ports

ARCx.Core.Scripting.Common.ComPortManager (static). A shared registry of open COM ports, so a skill and a script can share one port and the Script Manager can close them all.

Method Notes
string[] AvailablePorts()
void PortOpen(string portName, int baud) / (string, int, string description)
bool IsPortOpen(string port) / void PortClose(string port) / void CloseAllPorts()
void PortWrite(string port, byte data) / (string, byte[]) / (string, string data, bool newLine) / PortWriteLine(string, string)
int Available(string port) Bytes waiting
byte PortReadByte(string port) / string PortReadChar(string) / byte[] PortReadBytes(string, int) / string PortReadStr(string, int) / string PortReadLine(string) / string PortReadAll(string)
void PortClearInputBuffer(string port)
ConcurrentDictionary<string, ComPortDefinitionCls> GetPorts The open ports, each with its SerialPort and Description

A skill that owns a device on a port (a servo bus, a lidar) normally opens its own SerialPort in Initialize() and closes it in DisposeOverride(). Use ComPortManager when the port should be shared with scripts.

Files have an equivalent registry: ARCx.Core.Scripting.Common.FileManagerService (FileReadLine, FileReadAll, FileReadByte, SetFileReadPosition, FileClose, FileCloseAll, and an OnCollectionChanged event).


8. Video routing

ARCx.Services.VideoRouting.VideoRouteService. The meeting point between skills that produce frames (cameras) and skills that consume them (vision, tracking, recording).

Producing frames

// Initialize()
_sourceGuid = _videoRouteService.AddSource("My Camera", "USB camera on /dev/video0", 30);

// per frame, with an OpenCV Mat
_videoRouteService.NotifySubscribers(this, _sourceGuid, matImage);

// DisposeOverride()
_videoRouteService.RemoveSource(_sourceGuid);
Member Notes
Guid AddSource(string sourceName, string sourceDescription, double sourceFPS) Keep the guid
void NotifySubscribers(object sender, Guid guid, Mat matImage) Call for every frame
void RemoveSource(Guid guid) In DisposeOverride()
void UpdateSourceDetails(Guid guid, string sourceDescription = null, double FPS = 0, string sourceName = null) Leave a parameter null/0 to keep it
VideoRouteSource[] VideoSources All registered sources
VideoRouteSource GetSource(string sourceName) / (Guid)

VideoRouteSource carries SourceName, SourceDescription, FPS (as declared, not measured), FrameCount and LastUpdated.

Save the source name in a configuration, never the guid - guids change every session.

Consuming frames

Event Notes
OnNewImageBeforeProcess The raw frame. This is where detection goes. Set MarkupItems here and ARCx draws them for you
OnObjectsDetected What the detection skills found on this frame, as VideoObjectsDetectedEventArg. This is where tracking and reacting to detections goes. See below
OnNewImageAfterProcessed After markup has been drawn. For display or recording. Do not alter the images here
OnVideoSourceAdded / OnVideoSourceRemoved A source appeared or went away

Each subscriber is invoked on its own, so one skill that throws cannot stop the others from seeing the frame or take the capture thread down.

VideoRouteSourceEventArg:

Member Notes
OriginalMat The original. Never alter it. Use it as the detection source
ProcessedMat What you may draw on, from OnNewImageBeforeProcess only. When nothing subscribes to that event and no markup is pending, this is OriginalMat, not a copy
ComputerVisionResizedMat A smaller copy for detection, built lazily on first read. Size comes from the ARCxComputerVisionImageScaledWidthPixels preference (default 320)
ComputerVisionResizedScaleFactor Multiply a coordinate in the resized mat by this to get the coordinate in OriginalMat. It is 1.0 until the resized mat has been read
MarkupItems See below

The images are borrowed. The service disposes the argument after both events and the browser refresh; subscribers must not dispose it and must clone anything they need to keep beyond the handler.

VideoSourceMarkupItem - let ARCx draw the overlay rather than doing it yourself:

Member Default Notes
Text - Empty for no text
TextPosition - OpenCV Point, in ProcessedMat pixels. OpenCV places the bottom-left of the text here
TextColor / RectangleColor - OpenCV Scalar, BGR order
TextSize 1 HersheyPlain font scale
TextThickness 2
Rectangle - OpenCV Rect, in ProcessedMat pixels - scale detections made on the resized mat first
RectangleThickness 1 0 for no rectangle
FramesToDisplayFor 1 Decremented after each frame is drawn. 1 is right when detecting every frame
TreatAsObjectDetected false Tells a consuming skill this is a detection it may track or follow. Set it on the detector's primary detection (i.e. the largest face)
TrackingType "unknown" The kind of detection: "glyph", "face", "color", "object"...
TrackingTypeValue - What was detected within that kind: "Glyph 1", "red", "person". Empty when there is nothing more specific
Confidence null 0 to 1, when the detector has such a measure
Attributes null Dictionary<string, object> of details only this kind of detection has (a glyph's Rotation). Numbers, strings or booleans, PascalCase keys. A detector that knows the distance sets DistanceM (DetectedObject.ATTRIBUTE_DISTANCE_M) in metres

Following detections

A detection skill only detects and adds markup items. Following the object with servos or the movement panel, scripts that run when something appears, and the variables describing it belong to the Object Detection Script robot skill, which works for every detection skill. Do not duplicate them in a detection skill: fill in TrackingType, TrackingTypeValue and, where you have them, Confidence and Attributes, and it takes care of the rest.

A skill of your own that reacts to detections subscribes to OnObjectsDetected. It is raised for every frame of every source, after OnNewImageBeforeProcess, with an empty list when nothing was detected so you can tell an object has gone. Every markup item with an area that names its TrackingType or is flagged TreatAsObjectDetected becomes a DetectedObject:

Member Notes
TrackingType / TrackingTypeValue As set on the markup item. The type is "unknown" when not set; the value is never null
Name The value, or the type when there is no value
Rectangle In pixels of the original frame (FrameWidth x FrameHeight on the argument)
IsPrimary TreatAsObjectDetected
Confidence, Attributes, Label As set on the markup item. Attributes is never null. On a source a depth camera is aligned with, ARCx adds the measured DistanceM, ForwardM, LeftM, HeightM, RangeM, Angle and DepthSensor itself (section 26)

The argument also carries VideoSource, FrameWidth, FrameHeight, FrameNumber and Time (Environment.TickCount64). It holds no images and never changes, so keep it as long as you like. It is raised on the capture thread, so hand servo moves and scripts to a thread of your own.

Enumerating cameras

ARCx.Core.ComputerVision.VideoDeviceManager.GetDeviceList() returns the cameras attached to this computer with the modes each supports. Store the device Identity to find it again on reload, and the mode's VideoDeviceResolution.ID (which carries FPS, width and height). Enumeration is synchronous and can take a moment. Windows and Linux only; anywhere else it logs and returns an empty array rather than throwing.


9. Audio

ARCx.Services.AudioServices.AudioService. Audio in and out, mixed across every skill.

Constant Value
AudioService.OUTPUT_SAMPLE_RATE 22050 - everything you play
OUTPUT_WAVE_FORMAT 22050Hz, 16 bit, mono
AudioService.INPUT_SAMPLE_RATE 16000 - the microphone, as raised by OnAudioInput

Everything you play must match OUTPUT_WAVE_FORMAT, or it is noise. The microphone runs at a different rate from the speaker: 16kHz is what speech recognition and voice activity detection are built for, so a speech engine can usually take OnAudioInput as it is without resampling. Never assume the two rates are the same.

Where it comes out

Do not write a "play on the PC" path and a "play on the EZB" path. There is one output mix, and where it goes is a setting: this machine's preference in Options, Preferences, overridden by the project's setting on the EZB Connection robot skill. Queue your audio in the format above and it plays out of the computer's speakers or is converted, resampled and streamed to an EZB's speaker without your skill knowing which. A skill that offers its own speaker choice is asking the user the same question twice and will disagree with the rest of the project.

Member Notes
AudioOutputTargetEnum OutputTarget What the project is set to - PCSpeaker or EZBSpeaker
int OutputEZBIndex The board when OutputTarget is EZBSpeaker
bool IsPlayingThroughEZB What is actually happening. False while the chosen board is disconnected - audio then plays out of the PC rather than being lost
string GetOutputDescription() For a window or a log entry: "PC speaker", "EZB 1 speaker"
event Action OnAudioOutputDestinationChanged The setting changed
int OutputVolume Master volume, 0 to 200, applied to the mix before either speaker

Read them if you want to show the user where the sound is going. Do not branch on them to decide how to play something.

Playing a finite clip

Register a source, play through it, deregister on dispose:

// Initialize()
_audioGuid = _audioService.RegisterInputSource();

// Play
using var stream = ...;
using var waveReader = new WaveFileReader(stream);

var resampled = new WdlResamplingSampleProvider(waveReader.ToSampleProvider(), AudioService.OUTPUT_SAMPLE_RATE);

_audioService.StopAudio(_audioGuid);
_audioService.AppendPlayAudioForGUIDSource(_audioGuid, resampled);

// DisposeOverride()
_audioService.DeregisterInputSource(_audioGuid);
Member Notes
Guid RegisterInputSource() / void DeregisterInputSource(Guid) Deregister in DisposeOverride()
void AppendPlayAudioForGUIDSource(Guid, ISampleProvider) Reads all available audio into a queue. For clips with an end: a file, generated sound, speech. Not for live streams
int QueueLength(Guid sourceGuid) Samples still pending in ARCx. Note that an EZB has its own buffer behind this
void WaitForSourceToFinish(Guid, int stallTimeoutMs = 3000) Blocks until the audio has actually been heard, including an EZB's buffer. Use this rather than polling QueueLength yourself. Throws TimeoutException when nothing is draining the queue
void StopAudio(Guid source) / void StopAllAudio() Also discards what has been sent towards an EZB but not played
long GetPlayedSamples(Guid sourceGuid) How many samples of the source have gone to the speaker since it was registered. Only grows, never counts audio thrown away by StopAudio(), and steps one mix buffer (about 90ms) at a time. The sound card or EZB's own buffering comes after it. 0 for an unknown source

Streaming live audio

AddSourceToMixer(ISampleProvider) / RemoveSourceFromMixer(ISampleProvider). The mixer pulls from the provider as needed, in packets, so nothing is read into memory up front. Use this for live streams and for very large files.

Input and devices

Member Notes
event Action<float[]> OnAudioInput Every frame of input audio: mono floats, -1 to +1, at INPUT_SAMPLE_RATE, 1024 samples (64ms) a frame. This is how a speech engine gets the microphone. Subscribe only while you want the audio - the microphone stream is stopped while nothing is subscribed, which is what keeps an idle robot's CPU free
IDisposable HoldInputOpen() Keeps the microphone stream running without subscribing, for something that only reads InputLevel, such as a level meter. Dispose it when done
float InputLevel The loudest recent microphone sample, 0 to 1. Only moves while the microphone stream is running - see above
event Action OnAudioDeviceInitialized At start-up, and when an audio device preference changes
AudioDeviceItem[] GetInputDeviceList() / GetOutputDeviceList() Only the devices that will open in the format ARCx uses (mono, at INPUT_SAMPLE_RATE / OUTPUT_SAMPLE_RATE). Name includes the audio system (MME, DirectSound, ALSA...) when there is more than one. Id is PortAudio's index and changes when devices are plugged in or removed; Key does not
int GetSelectedInputDevice() / GetSelectedOutputDevice() The PortAudio index of the device in use, else the one that would be tried first, else -1 when there is none
string GetInputDevicePreference() / GetOutputDevicePreference() The chosen device's Key, or AudioService.DEFAULT_DEVICE_KEY for the system default. May name a device that is not plugged in
void SetInputDevicePreference(string key) / SetOutputDevicePreference(string key) Saves the choice and reopens the devices. When the chosen device will not open, ARCx tries the same device through another audio system, then the system default, then anything else, and the Diagnostics page says so

Saving audio files

A WAV is NAudio's WaveFileWriter. For MP3, ARCx has its own fully managed encoder, ARCx.Services.AudioServices.Encoders.Mp3Encoder. It needs no native library, so the same code works on every platform ARCx runs on; never ship LAME or call the operating system's encoder.

using ARCx.Services.AudioServices.Encoders;

// A whole clip: float samples, -1 to +1, interleaved when stereo
byte[] mp3 = Mp3Encoder.Encode(samples, 22050, channels: 1, bitrateKbps: 64);

// Or a stream, written as the audio arrives
using (var encoder = new Mp3Encoder(fileStream, AudioService.INPUT_SAMPLE_RATE, 1)) {

  encoder.Write(frame);          // float samples
  encoder.WritePcm16(pcmBytes);  // or 16 bit little endian PCM, such as a TTS result
}                                // Dispose() calls Finish(), which sends the tail

// Anything with an end, resampled when MP3 cannot have its rate
Mp3Encoder.Encode(waveReader.ToSampleProvider(), outputStream);
Member Notes
SUPPORTED_SAMPLE_RATES / IsSupportedSampleRate(int) 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000. OUTPUT_SAMPLE_RATE and INPUT_SAMPLE_RATE are both on the list. Any other rate throws from the constructor; GetClosestSampleRate() says what to resample to
GetSupportedBitrates(int sampleRate) 32 to 320kbps at 32000Hz and up, 8 to 160kbps below. A bitrate not on the list is rounded to the nearest one, and BitrateKbps says which
GetDefaultBitrate(int sampleRate, int channels) What 0 means: 128/192kbps (mono/stereo) at 32000Hz and up, 64/96 at 16000 to 24000Hz, 32/48 below
Write(ReadOnlySpan<float>) / WritePcm16(ReadOnlySpan<byte>) May be split anywhere, even between channels or between the two bytes of a sample
Finish() Sends the samples still in the filterbanks and the frames held back for the bit reservoir. Nothing can be written after it. Without it the file is cut short
SamplesWritten, BytesWritten Samples per channel in, MP3 bytes out

The result is constant bitrate MPEG-1, 2 or 2.5 layer III (whichever the sample rate belongs to) that every player and browser plays. It is a simple encoder, with no psychoacoustic model and no joint stereo: speech and robot sounds are clean at the default bitrates, but music at a low bitrate sounds worse than from LAME. The decoded audio starts about 1057 samples (a few tens of milliseconds) late and ends with a little silence, because there is no LAME tag to tell players to trim it. An instance is not thread safe.

NAudio types (ISampleProvider, WaveFileReader, WdlResamplingSampleProvider) come from the copy of NAudio that ARCx ships. Add NAudio.Core to the References item group of the skill's .csproj to use them (see rule 16); note that NAudio.Core 3.x Read() takes a Span<float>.


10. Text to speech

ARCx.Services.TextToSpeech.TextToSpeechService.

Speaking

Method Notes
void Say(string text) Returns immediately
void SayWait(string text) Blocks until the speech has finished being heard
void StopTalking()
string GetSpeakerDescription() Where speech will be heard, in words
bool IsSsml(string text) Whether the text is an SSML document rather than plain text
string GetTextToSpeechEngineWindowTitle() The project's TTS engine skill, or null when there is none and Say will throw
bool IsSpeaking Speech is queued that has not all gone to the speaker yet

There is no PC version and EZB version of these. Speech goes into the output mix like anything else, and the project's audio output setting decides which speaker it reaches - see 9. Nothing that speaks needs an EZB index, and nothing that speaks should offer the user a choice of speaker.

The project's TTS engine is whichever robot skill implements TextToSpeechEngineInterface; if there is none, the Say methods throw.

Following speech

A robot skill that moves with the voice - a talking mouth, lights that pulse while the robot speaks - subscribes to OnSpeaking. It is raised for every Say and SayWait, from any script, robot skill or AI agent, once the speech has been synthesized and queued, and it carries the speech itself, so the skill works from the real sound rather than guessing from the text.

Member Notes
event Action<TextToSpeechSpeakingEventArgs> OnSpeaking Raised on the thread that called Say, before Say returns. Copy what you need and do the work on your own thread. An exception in a handler is logged and does not stop the speech. Unsubscribe in DisposeOverride()
long GetPlaybackPosition() Where the speaker is in the speech audio, in samples at AudioService.OUTPUT_SAMPLE_RATE. Steps a mix buffer (about 90ms) at a time while speech plays, so interpolate between steps with your own clock for smooth movement. Stops where the voice stopped when speech is cut short

TextToSpeechSpeakingEventArgs: Text (as passed to Say, possibly SSML), IsSsml, Samples (mono floats, -1 to +1, shared by every subscriber - read, do not change), SampleRate, StartPosition (the GetPlaybackPosition() value at which the first sample is heard; in the future when earlier speech is still playing) and Duration.

GetPlaybackPosition() - StartPosition is how many samples of that speech have been played: negative before it starts, past Samples.Length once it has finished. When IsSpeaking is false and the position has stopped moving short of the end, the speech was stopped. What is heard trails the position by the sound card's or EZB's own buffering, so give the user an offset to tune. The Talk Servo robot skill is a complete example.

Being the engine

Implement TextToSpeechEngineInterface on the worker:

public TextToSpeechResultCls OnTextToSpeech(TextToSpeechRequestCls request) {

  // request.SampleRate, request.SampleSize (Bits8 / Bits16), request.Channels (always 1),
  // request.TextToSpeak (plain or SSML - use TextToSpeechService.IsSsml())

  return new TextToSpeechResultCls() {
    Success = true,
    Audio = pcmBytes,       // RAW PCM, no file header, in exactly the format requested
  };
}

Called synchronously on the caller's thread. Return Success = false (or null Audio) when the text could not be synthesized. Honour request.SampleRate and request.SampleSize exactly; any other format produces noise. In practice speech is always asked for as 16 bit at AudioService.OUTPUT_SAMPLE_RATE, because it goes into the output mix and the mix is converted to whatever the speaker needs - an engine has nothing to say about where its voice comes out.

Only one TTS engine skill is allowed in a project.


11. Speech to text

ARCx.Services.SpeechToText.SpeechToTextService routes between skills that provide recognition and skills that consume it. The service does no recognition itself.

An engine says what it can do, not what it is for. Whether an engine is the wake word listener, the command listener or the one behind Wait For Speech is decided by whoever wires the project together. Everything an engine cannot do natively is filled in by the service:

Gap What the service does
Engine cannot hold recognition open (CanListenContinuously false) A Continuous request is served by starting it again after every result. The consumer sees a stream of utterances
Engine must not be left listening (RequiresTrigger true) Never starts it for a Continuous request. It runs for a Once request, for the awake window after its wake word, or after TriggerWake() - and otherwise not at all
Engine should wait for a wake word Holds it asleep while another engine listens for the wake phrase, then runs it for the awake window
Two consumers, one engine A Once request suspends a Continuous one and resumes it afterwards. A second Continuous request is refused and logged

Those first two are separate, and an engine billed per phrase wants both. CanListenContinuously false alone still leaves the service re-arming it forever, which is exactly how a metered service burns through a daily allowance on an empty room.

SpeechToTextModeEnum is a property of the request, not of the engine:

Value Meaning
Once Listen for one utterance and stop. The Wait For Speech popup, WaitForSpeech() scripts, a GetText control command. Bypasses any wake word - asking is the trigger
Continuous Keep recognizing until the session is cancelled

Providing an engine

Add one subclass of SpeechToTextEngineAbstractEntity to the skill's assembly (not on the worker itself). ProjectToolsService finds it when the skill is added, creates it with dependency injection, and assigns it to the worker's SpeechToTextEngine property.

Member Notes
RobotSkillWorker Set for you, after construction. Build anything that needs it lazily
SpeechToTextCapabilitiesCls GetCapabilities() Return a fresh instance; it is read every time the service or a config dialog needs it
SpeechToTextTriggerCls GetTrigger() Optional. Return the trigger saved in the skill's configuration, or null
void OnStartSpeechRecognition(SpeechToTextRequestCls) Replace whatever is running. Mode is never Continuous for an engine that said it cannot
void OnStopSpeechRecognition() Must be safe when nothing is running
void Dispose() The host only disposes the worker, so dispose the engine from DisposeOverride()

SpeechToTextCapabilitiesCls:

Field Meaning
CanListenContinuously False for a record-upload-answer engine. The service re-arms it instead
RequiresTrigger True when the service must never start it on its own. A Continuous request leaves it quiet; only a Once request, its wake word, or TriggerWake() runs it. Set this on anything billed or rationed per phrase
SupportsPhraseGrammar False for a dictation or cloud engine that always returns free text
DetectsSpeechOnly True for a detector - a voice activity detector that knows somebody is talking but not what they said. Kept out of the engine lists consumers pick from, offered as a trigger source
ReturnsConfidence False when Confidence is a constant, so consumers know not to filter on it
Description One line for the user, beside the engine in a drop-down

Report results back through the service. There is no session to track - the service stamps each message with whichever session the engine is running for:

_speechToTextService.UpdateSpeechDetectedMessage(worker, speechResultCls);
_speechToTextService.UpdateSpeechHintMessage(worker, speechHintCls);     // "speak louder", etc
_speechToTextService.UpdateAudioStateMessage(worker, audioStateCls);      // silence / speech / stopped

Report failures as well as successes (Success = false, ErrorString). The service closes a Once session, re-arms and puts gated engines to sleep on the strength of these messages, and a consumer waiting on one utterance would otherwise wait out its whole timeout.

Triggers - the wake word

A trigger belongs to the gated engine's own robot skill, so the wiring follows that skill around the project and every consumer of the engine inherits it.

  1. Put a SpeechToTextTriggerCls on the skill's Configuration and save it as usual.
  2. Drop <UCSpeechToTextTrigger @bind-Trigger="_configuration.Trigger" EngineWindowTitle="@(CurrentRobotSkillWorker?.Properties?.WindowTitle)" /> into the skill's config dialog. The component lists only engines whose capabilities report CanListenContinuously, because a one-utterance engine cannot listen for a wake phrase all day.
  3. Return it from GetTrigger().
  4. Call SpeechToTextService.NotifyTriggerChanged(windowTitle) from OnConfigWindowClosed, so a wake word the user just set takes effect on recognition that is already running rather than at the next project load.

That is the whole feature on the engine's side. The service arms the trigger source, starts the gated engine when the phrase is heard, refreshes the awake window on every result, and stops it when StayAwakeMs lapses. StayAwakeMs of 0 means one utterance per wake, which is the setting for an engine with a daily allowance. An engine may be its own trigger source, in which case the service swaps the grammar back and forth.

An engine that reports RequiresTrigger with no usable trigger gets a gate that never wakes by itself: it stays quiet for Continuous requests, and TriggerWake() still works so a listen button or a Wake control command can run it for one utterance.

Waking on any voice. SpeechToTextTriggerCls.WakeOnAnySpeech wakes the gated engine as soon as anybody talks, with no phrase to say. The trigger listener then asks its source for dictation rather than a one-phrase grammar, and any successful result wakes the gate.

That is what makes a voice activity detector useful. Write one as an ordinary speech engine with CanListenContinuously = true and DetectsSpeechOnly = true, and signal either way:

  • publish a result with Success = true (the text may be empty), or
  • publish UpdateAudioStateMessage with AudioStateEnum.Speech.

Either wakes whatever the detector is the trigger for, so a detector that never produces text at all still works. The service forces WakeOnAnySpeech for a detector source whatever the saved trigger says, since there is nothing for a phrase to match, and GetSpeechToTextTriggerSources() is the list that includes detectors - GetSpeechToTextRobotSkills() leaves them out, because they have no text to give a consumer.

Consuming recognition

Member Notes
SpeechToTextSessionCls StartSpeechRecognition(SpeechToTextRequestCls) Does not throw. Returns null, having logged why, when the engine is missing or busy. Keep the session
void CancelSpeechRecognition(SpeechToTextSessionCls) Safe with null, and twice
event OnSpeechDetected(object, SpeechToTextResultCls) Compare e.SessionId against your session's Id. Sender is the engine's worker
event OnSpeechHint(object, SpeechToTextHintCls) Same SessionId
event OnAudioStateChanged(object, SpeechToTextAudioStateCls) Same SessionId
event OnTriggered(object, SpeechToTextTriggeredCls) A gated engine woke (Awake true) or slept. Hang a script off it; the recognition is already being driven
string[] GetSpeechToTextRobotSkills() Window titles of every engine that can produce text - populate a drop-down with these. Detectors are left out
string[] GetSpeechToTextTriggerSources() Window titles of everything that can serve as a wake source: engines that can listen continuously, detectors included
string[] GetSpeechToTextRobotSkills(bool canListenContinuouslyOnly) Pass true for only the engines that can hold recognition open
bool DoesSpeechToTextRobotSkillExist(string) Never throws
RobotSkillWorkerAbstractEntity GetSpeechToTextRobotSkill(string) Throws when not found
SpeechToTextCapabilitiesCls GetCapabilities(string) Null when the engine is not in the project
SpeechToTextTriggerCls GetTrigger(string) What wakes that engine, or null
bool TriggerWake(string) / TriggerSleep(string) Wake or sleep a gated engine by hand. False when it has no trigger
void NotifyTriggerChanged(string) Re-read that engine trigger and re-arm everything listening through it. A speech engine skill calls this when its config window closes
bool IsAwake(string) Whether that engine is listening right now

Filter on SessionId, never on OriginWindowTitle alone. Two consumers can be sharing one engine, and the window title does not tell you whose speech a result is.

Do not build a wake word state machine in a consumer. Ask for Continuous and subscribe to OnTriggered if you want to know when the robot starts and stops listening.

The engine the Wait For Speech popup and script commands use is named in ProjectToolsService.CurrentProjectProperties.WaitForSpeechEngineWindowTitle.


12. Movement

ARCx.Services.Movement.MovementManagerService. The abstraction between "go forward" and the motors.

Anything can call Go*(). The movement panel - the one skill in the project implementing MovementPanelInterface - subscribes to the events and actually drives the hardware.

MAX_SPEED is 255, MIN_SPEED is 0.

Member Notes
GoForward() / (byte speed) / (byte speedLeft, byte speedRight)
GoReverse(), GoLeft(), GoRight() Same three overloads each
GoStop()
GoUp(), GoDown(), GoRollLeft(), GoRollRight(), Takeoff(), Land() Flying robots
GoEmergency()
GoCustom(string customMovementID) / (string, byte speed) / (string, byte left, byte right) For a movement the enum does not cover
Go(MovementDirectionEnum direction)
SetSpeed(byte) / (byte left, byte right) / SetSpeedLeft(byte) / SetSpeedRight(byte)
GetSpeed, GetSpeedLeft, GetSpeedRight
GetCurrentDirection, GetLastDirection
GetCustomMovementId Read this from an event handler when the direction is Custom
LocomotionStyle Undefined, Wheels, Tank, Drone, Gait, Helicopter, Plane, Rocket, Submarine, Boat. Set by the movement panel

MovementDirectionEnum: Stop, Forward, Reverse, Left, Right, Up, Down, RollRight, RollLeft, Takeoff, Land, Emergency, Custom.

Events:

Event Signature
OnMovement (MovementDirectionEnum direction)
OnMovement2 (MovementDirectionEnum direction, byte speedLeft, byte speedRight) - what a movement panel subscribes to
OnSpeedChanged (int speedLeft, int speedRight)

13. Kinematics

ARCx.Services.Kinematics.KinematicsService. A registry and router: a provider skill publishes named chains ("Left Arm", "Camera Gimbal") and does the maths; everything else moves them by name without knowing who owns them. The service does no kinematics itself.

Consumer side

// Configuration: UCKinematicChainSelector, save the name
var result = _kinematicsService.MoveTo(_Configuration.ChainName, x, y, z);

if (!result.Accepted || !result.Reachable)
  UCLogBuffered.AddEntry(this, $"Move refused: {result.Message}");
Member Notes
KinematicChain[] Chains / string[] ChainNames Snapshot. Present by name, never save the guid
KinematicChain GetChain(string name) / (Guid) / bool ChainExists(string) Null when unknown
MoveTo(name, x, y, z, speed = -1, mode = Joint, requestedBy = "") Absolute position, provider chooses the orientation
MoveToPose(name, x, y, z, roll, pitch, yaw, ...) Absolute position and orientation
MoveRelative(name, dx, dy, dz, ...) Offset from where it is now
Move(KinematicMoveRequest request) The one path every move takes
KinematicPose GetPose(string name) Runs the provider's forward kinematics on the live servo positions, so it reflects moves made by other skills. Throws for an unknown chain
KinematicPose GetLastKnownPose(string name) Cached, no provider call. Null when unknown or never reported. Cheap - use for displays
double[] GetJointAngles(string name) Degrees. Empty array when the provider does not expose them
bool IsMoving(string name) / void Stop(string name) / void StopAll()
bool WaitForMoveComplete(string name, int timeoutMs, CancellationToken ct) True when idle, false on timeout. timeoutMs <= 0 waits forever. Polls, because a provider that moves synchronously has already finished
Events OnChainAdded, OnChainRemoved, OnChainUpdated, OnPoseChanged, OnMoveRequested, OnMoveStateChanged

An unknown chain name throws - that is a configuration error the caller needs to see. A provider that cannot reach the target does not throw; check KinematicMoveResult.Accepted and .Reachable.

KinematicMoveResult: Accepted, Reachable, Message, SolvedPose, JointAnglesDegrees, ResidualErrorMm, plus the factories Ok(...), Rejected(...), Unreachable(...).

KinematicPose: X, Y, Z, Roll, Pitch, Yaw, HasOrientation, Timestamp, and DistanceTo(), Offset(), Clone(), ToPositionArray(), ToPoseArray().

KinematicMoveModeEnum selects Joint (default) or linear interpolation.

Provider side

  • AddChain(KinematicChainRegistration) in Initialize() or after your configuration loads. Keep the returned Guid. Throws on an empty name, a duplicate name (case-insensitive) or a missing required handler - catch per chain so one bad name does not sink the others.
  • RemoveChain(guid) in DisposeOverride(). Safe with an unknown or already-removed guid.
  • UpdateChainDetails(guid, name, description, providerName, jointCount, supportsOrientation, supportsLinearMoves) - leave a parameter null (or -1 for the joint count) to keep it.
  • NotifyPoseChanged(guid, pose) whenever the end effector moves, including every step of an interpolated move and when you notice another skill moved your servos. Displays, the published variables and OnPoseChanged all hang off this.
  • NotifyMoveStateChanged(guid, bool isMoving) when a move starts and ends, or WaitForMoveComplete() never returns.

KinematicChainRegistration handlers: OnMoveRequested (Func<KinematicMoveRequest, KinematicMoveResult>), OnPoseRequested (Func<KinematicPose>), OnStopRequested (Action), OnIsMovingRequested (Func<bool>), OnJointAnglesRequested (Func<double[]>). Plus Name, Description, ProviderName, JointCount, SupportsOrientation, SupportsLinearMoves, PublishVariables (default true).

With PublishVariables, poses are published as global script variables prefixed Kinematic + the chain name with non-alphanumerics removed ("Left Arm" becomes KinematicLeftArmX, KinematicLeftArmY, ... KinematicLeftArmIsMoving).


14. Navigation messaging (NMS)

ARCx.Services.NMS.NMS2Dv1.NMSService. Routes 2D positioning and scanning data between sensor skills and a navigator (SLAM) skill.

Three levels: sensors report, the service collates, a navigator consumes.

Reporting

Method From
void UpdatePose(Pose pose) A positioning skill: wheel encoders, a tracking camera. X and Y are metres from where tracking began; Yaw is radians likewise relative
void UpdateScan(ScanPoint[] scanPoints) A distance sensor: lidar, ultrasonic. Use the array overload when reporting many points
void UpdateScan(ScanPoint scanPoint) One point. Slow for a full sweep

ScanPoint: Range (metres, 0 means nothing detected), Angle (radians relative to the robot, -PI to PI: 0 straight ahead, growing counter-clockwise, so PI/2 is left and -PI/2 is right), Confidence (0 = none, 255 = certain).

Pose: X, Y (metres), Yaw (radians, -PI to PI), Confidence, plus Frame and TimeStamp which the service assigns.

The NMS is ROS's frame and units (REP 103): metres and radians. +X is forward (the way the robot faced when tracking began), +Y is left, and Yaw grows counter-clockwise, as the robot turns left, from 0 at the start. ScanPoint.Angle grows the same way from straight ahead, so a scan point at range r lands on the map at bearing h = Yaw + Angle: X + r * cos(h), Y + r * sin(h). A ROS pose or LaserScan maps straight across with no conversion at all. Report angles normalised to -PI..PI (NMSAngles.NormalizeRadians()), as ROS does, and accept any angle you are given. NMSAngles.ToRadians()/ToDegrees() convert for display.

The old Pose.DegreesHeading, ScanPoint.Distance (cm) and ScanPoint.Degree remain as [Obsolete] members that convert, so robot skills built before this change keep loading. Do not use them in new code.

Wheel odometry in this frame, per reading, with each wheel's travel dL, dR (metres) and the distance between the wheels b: the yaw changes by (dR - dL) / b radians (positive turning left), and the robot moves (dL + dR) / 2 along the yaw half way through that change.

Every robot skill uses this frame for any angle or position around the robot, not only NMS data, and uses metres and radians for anything a script reads or passes (variables, control command parameters and results): Turn(1.5708) turns a quarter turn left, and an arc "from A to B" runs counter-clockwise. Settings a person types and readouts on screen stay in degrees (and whatever distance unit reads naturally), converted at the UI.

Use the same names as the other robot skills for the same things, so a script written for one reads naturally against another:

What Name
Position variables <prefix>PoseX, <prefix>PoseY (metres), <prefix>PoseYaw (radians)
Distance driven since the origin <prefix>OdometryDistance (metres)
Read the position control command GetPose, returning [x, y, yaw]
Make here the origin control command ResetPose
Drive and turn by an amount DriveDistance(metres), Turn(radians) (positive turns left)
A bearing to something a name ending in Angle (radians), never Degree or Heading
A sideways offset setting measured to the left, like ROS's y (e.g. "Left of centre")

Consuming

Member Notes
event OnNewPoseAndScanPoints(PoseAndScanPoints) Fired on every pose update, carrying that pose and every scan point collected since the previous one, sorted by Angle
event OnNavigationStatusChanged(NavigationStatusEnum) An obstacle sensor asking the navigator to pause or continue
void ChangeNavigationStatus(NavigationStatusEnum)
NavigationStatus Current status
X, Y, Pose, Yaw, FrameCnt Last reported values (metres, radians)
void Clear() Drop the stored scan points
MAX_PENDING_SCAN_POINTS Scan points are only held while something subscribes to OnNewPoseAndScanPoints, and only until the next pose. With no positioning skill reporting, the newest 100,000 are kept and older ones dropped

A navigator that works its position out from the scans alone still needs poses to arrive, because that is what releases the scan points. A distance sensor skill can publish a placeholder pose (the last known X, Y and Yaw) after each sweep when it notices no other skill is reporting one - the RPLidar skill does this, and compares FrameCnt before and after its own UpdatePose() to tell whether anything else is posting.


15. Auto Position

ARCx.Core.AutoPosition.AutoPositionService. The engine behind the Auto Position skill: named frames (servo poses) combined into actions (sequences).

Member Notes
AutoPositionConfig Config The frames and actions
string Name Unique name for this instance
void SetVelocity(byte value) 0 slow to 255 fast, for frames with UseVariableVelocity. Takes effect on the next step, so it can change mid-action
bool IsRunning
ExecAction(...) / MoveToFrame(...) Movements run on a background thread; only one runs at a time and starting a new one cancels the current
event OnStartAction(sender, AutoPositionAction) Raised on the worker thread
event OnStartFrame(sender, AutoPositionFrame, AutoPositionActionFrame) Each frame, including pauses and repeats
event OnComplete(sender) When the queue drains, whether it finished or was stopped

Derives from DisposableBase - dispose it when the skill closes, which stops any movement and unhooks it from the EZBManager connection events.


16. Scripting, control commands and variables

Control commands: exposing your skill to scripts

Declare them, then handle them.

readonly string cmd_setCounter = "SetCounter";

public override List<ControlCommandItem> GetSupportedControlCommands() {

  var items = new List<ControlCommandItem>();

  items.Add(
    new ControlCommandItem() {
      CommandName = cmd_setCounter,
      Description = "Set the counter to the specified value",
      ReturnType = typeof(int),
      Parameters = [
        ControlCommandParameter.AddParameter(ControlCommandParameter.ParameterTypeEnum.Number, "value"),
      ],
    });

  return items;
}

public override object ControlCommand(ControlCommandArgs args) {

  if (args.Command.Equals(cmd_setCounter, StringComparison.InvariantCultureIgnoreCase)) {

    // The user's script decides what is actually passed, so always verify
    if (args.Parameters.Length != 1)
      throw new Exception($"The {cmd_setCounter} control command expects 1 parameter (value)");

    _Cntr = Convert.ToInt32(args.Parameters[0]);

    RefreshUI();

    return _Cntr;
  }

  // The base raises the "no matching control command" exception for the calling script
  return base.ControlCommand(args);
}

ControlCommandItem:

Member Notes
CommandName ScriptStart, Enable, SetCounter
Description What the command means and when to use it. It no longer has to spell out the return value
ReturnType A real System.Type: typeof(bool), typeof(int), typeof(string[]). typeof(void) says explicitly that nothing useful comes back. Leave null only when the return type genuinely is not predictable - do not declare one that is true only some of the time. Shown in the script editor's command list, given to Athena, and used in the generated documentation
Parameters List<ControlCommandParameter>. Values arrive in args.Parameters in the same order
ReturnTypeName / ReturnTypeDescription Read-only. "Integer" / "Integer [-2,147,483,648 to 2,147,483,647]"

ControlCommandParameter.AddParameter(ParameterTypeEnum, string name) and AddDefaultValue(ParameterTypeEnum, object defaultValue).

ControlCommandArgs: Command, Parameters (object[]), CancellationToken - observe the token in a long-running command.

Running scripts

Create executors through ScriptManagerService rather than new Executor(), so the Script Manager UI can list, monitor and stop them, and so they are disposed when the project is cleared.

// Initialize()
_executor = _scriptManagerService.CreateExecutor($"{Properties.WindowTitle} script");

// Read back CompilerName - a duplicate name gets a number appended
_executor.EnableConsoleDebugLogging();

// Run
_executor.StartScriptASync(_Configuration.OnDetectedScript);

// DisposeOverride()
_scriptManagerService.RemoveAndDisposeExecutor(_executor);
Executor member Notes
StartScriptASync(ScriptDefinition) / (CancellationToken, ScriptDefinition) Background, on the engine's own worker thread. Stops whatever the executor was running or had queued first. Stopped by StopScript() or by the script calling halt(). Use the token overload when starting on behalf of another script (i.e. from ControlCommand()), so stopping the caller stops this
RunScriptAsync(ScriptDefinition) / (CancellationToken, ScriptDefinition) Same as StartScriptASync, but returns a Task<ScriptRunOutcomeEnum> that completes when the run ends (Completed, Error, Cancelled, Skipped). Never faults; errors still arrive through OnError. Holds no thread while awaited
StartScriptBlocking(ScriptDefinition) / (CancellationToken, ScriptDefinition) Runs on the calling thread. Does nothing if the executor is already running a script - it never stops or waits for one
RunScriptBlocking(CancellationToken, ScriptDefinition) Same as StartScriptBlocking, but returns a ScriptRunOutcomeEnum; Busy when it was refused
ExecuteScriptSingleLine(ScriptDefinition) / (CancellationToken, ScriptDefinition) Returns the result as a string. Refused (raising OnError) while the same engine is running a script
ExecuteScriptSingleLineAsync(ScriptDefinition) / (CancellationToken, ScriptDefinition) Same, on a thread-pool thread - for Blazor components
StopScript() Stops the running script and any script queued to start
IsScriptRunning, IsCancelRequested, Script, CompilerName, CompilerUniqueIdentifier
EnableConsoleDebugLogging() Routes output and errors to the ARCx log automatically
Events OnStart(compilerName), OnDone(compilerName, TimeSpan), OnResult(compilerName, text) (the print() output), OnError(compilerName, message), OnCombinedLog(compilerName, EventTypeEnum, text)

ScriptManagerService: CreateExecutor(string compilerName), RemoveAndDisposeExecutor(Executor) (safe with null or an already-removed executor), Executor[] GetExecutors, events OnAdded / OnRemoved.

ScriptDefinition (ARCx.Config.Helpers) holds BlocklyXML, Javascript and Python slots - only one at a time; the editors clear the others when the user switches language. IsEmpty, GetScriptEditType (checked Blockly, then JavaScript, then Python), Title, Description.

Global variables

ARCx.Core.Scripting.Common.VariableManager (static). One program-wide set of named values shared by robot skills, JavaScript, Python, Blockly, Custom UI screens and the AI agent. It is how a skill publishes a reading that anything else can use without a control command: FrontPingDistance, KinematicLeftArmX, and so on.

Names are plain (Direction, not $Direction) and case-insensitive. A leading $ - the old EZ-Script convention - is accepted and removed, so "$Direction" still finds Direction, but do not use it in new names. NormalizeName(name) gives the canonical form.

Values are stored as the raw object. Numbers, strings and booleans (real bool, not 1/0) are stored as they are; an enum is stored as its name. Arrays, lists and dictionaries are stored by reference - scripts use setVarObject() to store a copy.

Member Notes
void SetVariable(string name, object value, string description) Give it a description. It is shown wherever variables are listed and given to the AI assistants. Null or empty keeps the existing description, so it is sticky - describe the variable once and the thousands of value updates that follow (SetVariable(name, value)) will not erase it
object GetVariable(string name) Throws KeyNotFoundException when it does not exist
object GetVariable(string name, object defaultValue) defaultValue when it does not exist
T GetVariable<T>(string name, T defaultValue) Converted to T (numbers, numeric strings, booleans, enums; invariant culture). defaultValue when missing or not convertible
bool TryGetVariable(string name, out object value)
bool DoesVariableExist(string name) False for an invalid name, never throws
GlobalVariable GetEntry(string name) / TryGetEntry The whole entry, or null: Name, Value, Type (VariableType: Empty, Boolean, Number, String, Object), Description, UpdatedUtc. Immutable - a snapshot
string GetDescription(string name) Empty when none or missing
GlobalVariable[] GetVariables(string namePrefix = null) Snapshot sorted by name, optionally filtered by prefix
IReadOnlyDictionary<string, GlobalVariable> Variables Live view, case-insensitive keys. Read only - change variables through the methods so events are raised
bool ClearVariable(string name) Deletes one; false when it did not exist
void ClearVariables() Deletes all. ARCx does this when a project closes or loads
string FormatValue(object value) Display text used everywhere variables are shown: invariant numbers, true/false, JSON for collections. GlobalVariable.ToString() returns this
event OnVariableChanged(VariableChangedEventArgs e) Every set, delete and clear. e.Kind (Set, Deleted, Cleared), e.Name (null for Cleared), e.Value, e.Variable, and e.Affects(name) - true when the change is about that name or everything was cleared

OnVariableChanged is raised on the thread that made the change - often a script or another skill's worker thread - on every set, even when the value is the same. Filter first and keep the handler cheap; marshal to the UI with InvokeAsync. An exception thrown by a handler is logged and does not reach the code that set the variable.

// When the skill starts: describe it once
VariableManager.SetVariable("FrontPingDistance", 0, "Distance in cm from the front ping sensor");

// Every reading: the description is kept
VariableManager.SetVariable("FrontPingDistance", cm);

// Reading one another skill publishes, with a fallback
var tracking = VariableManager.GetVariable("CameraIsTracking", false);

// Watching one
VariableManager.OnVariableChanged += e => {

  if (!e.Affects("FrontPingDistance"))
    return;

  var cm = e.Value;   // null when it was deleted or cleared
};

Scripts see the same variables: getVar("Name"), getVar("Name", default), setVar("Name", value, "optional description"), setVarObject(...) (stores a copy), varExists("Name") and deleteVar("Name"), identically in JavaScript and Python. In a Blockly workspace, a variable whose name starts with $ is a global variable (the $ is removed in the generated code); other Blockly variables are local to the script. Custom UI labels show one with {Name}.

MCP tools and resources: exposing your skill to AI clients

ARCx is a Model Context Protocol (MCP) server (ARCx.Services.Mcp.McpServerService, at /mcp). AI clients the user connects - Claude, Cursor, VS Code Copilot, ChatGPT, agent frameworks - see the project through it. Every control command is already offered to them through the generic send_control_command tool, with the description and parameters you declared, and the configuration is readable and editable through the schema of section 19. Nothing is needed for a skill to work with an AI.

Override GetMcpTools() when a command deserves a first class tool: named and typed arguments instead of a positional list, a longer description, or a result that is not a string - an image above all, which lets the AI see what the robot sees. Override GetMcpResources() for data the AI reads by URI (a latest reading, a status document, an image).

using ARCx.Services.Mcp;

public override List<McpToolItem> GetMcpTools() {

  return [
    new McpToolItem() {
      Name = "look",
      Description = "The next camera frame as an image you can look at. Nothing is saved.",
      ReadOnly = true,
      InputSchemaJson = "{\"type\":\"object\",\"properties\":{\"maxWidth\":{\"type\":\"integer\",\"description\":\"Widest, in pixels. Defaults to 640.\"}},\"additionalProperties\":false}",
      Handler = args => Task.Run(() => {

        using var mat = waitForFrame(args.CancellationToken);

        return McpToolResult.Image(encodeJpeg(mat, args.GetInt("maxWidth", 640)), "image/jpeg", $"{mat.Width}x{mat.Height}");
      }),
    },
    new McpToolItem() {
      Name = "set_counter",
      Description = "Set the counter and optionally start or stop its timer.",
      InputSchemaJson = McpToolItem.InputSchemaFromType(typeof(SetCounterArguments)),
      Handler = args => {

        var a = args.GetArguments<SetCounterArguments>();

        _Cntr = a.Value;

        return Task.FromResult(McpToolResult.Json(new { counter = _Cntr }));
      },
    },
  ];
}

public override List<McpResourceItem> GetMcpResources() {

  return [
    new McpResourceItem() {
      Uri = "status",
      Name = "Counter status",
      MimeType = "application/json",
      Reader = ct => Task.FromResult(McpResourceContent.Json(new { counter = _Cntr })),
    },
  ];
}

McpToolItem:

Member Notes
Name snake_case, letters, digits and underscores. ARCx prefixes it with the window title as a slug, so the AI sees camera_snapshot__look. Keep it stable once published
Title Optional, for people. The AI reads Description
Description Written for the model: what it does, what the arguments mean, units, pitfalls, what comes back
InputSchemaJson JSON Schema of the arguments object. Defaults to no arguments. McpToolItem.InputSchemaFromType(typeof(T)) generates it from a class, with the /// comments as descriptions, exactly like the configuration schema; read it back with args.GetArguments<T>()
ReadOnly True when the tool changes nothing. Clients run read only tools without asking the user, and ARCx offers them even when the user has turned Allow the AI to control the robot off. Leave false for anything that moves, speaks, saves or changes a setting
Destructive True when it cannot be undone (deleting files). A servo move is not destructive
Handler Func<McpToolCallArgs, Task<McpToolResult>>. Runs on a thread pool thread and may block. Throwing returns the message to the AI as an error result it can read and act on

McpToolCallArgs: Arguments (JsonElement), CancellationToken (the client gave up, the 120 second call timeout elapsed, or the skill is being removed), ToolName, and the tolerant getters GetString, RequireString, GetInt, GetDouble, GetBool, GetArray, Has, GetArguments<T>().

McpToolResult: Text(string), Json(object) (text and structured content; anonymous types work), Image(byte[] jpegOrPng, mimeType, caption), Error(message) (the AI reads it and tries something else, which beats an exception for anything the model can fix), .WithText(...) to add a caption. McpContent.FromText/FromImage/FromAudio for anything else.

McpResourceItem: Uri (relative; ARCx prefixes arcx://skill/<slug>/), Name, Description, MimeType, Reader returning McpResourceContent.Text, .Json(object) or .Blob(bytes, mimeType).

Rules:

  • Both overrides are called whenever a client lists tools or calls one, so keep them quick and return the same tools each time. When what you offer changes at run time (a tool per trained object, say), call NotifyMcpChanged() so connected clients list again.
  • Never put an API key or anything secret in a tool result or a resource; it goes to a model.
  • Keep results small: they are tokens in somebody's conversation. Scale images down (640 pixels wide is plenty), cap lists.
  • A tool that calls a paid model or moves the robot is not ReadOnly, however harmless it seems.

McpCoreTools.GrabFrameJpegAsync(videoRouteService, sourceName, maxWidth, processed, ct) (a static on the DI singleton ARCx.Services.Mcp.McpCoreTools) returns the next frame of a video source as JPEG bytes and is what get_camera_image uses; a skill that wants the same thing can call it rather than subscribing to the video events itself.

The user controls all of this in Options → Preferences → AI connections: the server on or off, an access token, and whether a connected AI may control the robot and change the project. See Manuals\mcp.md for the user's side.


17. Talking to other robot skills

ARCx.Services.ProjectTools.ProjectToolsService.

Look up another robot skill only in OnProjectLoadCompleted(). In Initialize() or OnSetConfiguration() the other skills may not be loaded yet.

Member Notes
RobotSkillWorkerAbstractEntity[] GetRobotSkills() A snapshot, safe to enumerate from a background thread while skills are added or removed
FindRobotSkillByWindowTitle(string) Null-check it - the user may have renamed or removed the skill your configuration points at
FindRobotSkillByInstanceGuid(string) An instance guid survives a rename, so it is the better thing to remember
object SendControlCommand(string windowTitle, string command, params object[] parameters) Throws when no skill matches, or when the target throws. Case-insensitive title
object SendControlCommand(CancellationToken ct, string windowTitle, string command, params object[] parameters) Pass your token for long-running commands
List<ControlCommandCollection> GetAllControlCommands() Every command of every skill, grouped by skill - the same list the script editor shows
Task<RobotSkillWorkerAbstractEntity> AddRobotSkillAsync(string storeGuid) Installs from the store if needed, then adds it with a unique window title and raises its OnSetConfiguration() and OnProjectLoadCompleted(). Find a store guid with PluginManagerService.GetAllRobotSkills()
void RemoveRobotSkill(string windowTitle) Throws when no skill has that title
event OnRobotSkillRemoved(string) / event OnWorkspaceChanged

Reading and writing another skill's configuration programmatically (this is how Athena does it):

Member Notes
string GetRobotSkillConfigJson(string windowTitle)
string GetRobotSkillConfigSchemaJson(string windowTitle, bool indented = false) The annotated JSON Schema
string PatchRobotSkillConfigJson(string windowTitle, string patchJson) Merge a partial change
void SetRobotSkillConfigJson(string windowTitle, string configJson) Replace wholesale

18. The project

ProjectToolsService.CurrentProjectProperties (static) is ProjectProperties.

Member Notes
MaxServoPositions Default 180. This is what Servo.SERVO_MAX resolves to
ServoPortDescriptions, DigitalPortDescriptions, ADCPortDescriptions, PWMPortDescriptions Dictionary<string, string> keyed by "board.PORT". The port pickers read and write these, and RobotConfigService fills them from the robot picture
WorkspaceTitles The desktops, default ["Main"]
StartupScript, StartupScriptTimeoutSeconds
WaitForSpeechEngineWindowTitle The project's Single speech to text engine, used by the Wait For Speech popup and script commands
CustomUIs, CustomUIExitPassword The user's Custom UI screens
RobotConfig The robot picture and its port markers
Description, Notes, TagsList, TitleImage, YouTubeURL, ManualURL, IsPublic Project metadata
CreatedDateTime, LastModifiedDateTime

ARCx.Core.Constants holds the well-known folders: DEFAULT_FOLDER (My Documents\ARCx Data, or ~/ARCx Data on Linux and macOS), DEFAULT_APPLICATION_FOLDER, DEFAULT_PROJECT_FOLDER, ROBOT_SKILL_PROJECT_FOLDER, PLUGINS_FOLDER, SERVO_PROFILES_FOLDER, PYTHON_MODULES_FOLDER, DEFAULT_USER_LOGS_FOLDER, and the file extensions (.arcx, .AutoPositionX, .servoX, .blocklyX).

An .arcx project file is a zip:

projectProperties.json
robotskills/<WindowTitle>/properties.json    RobotSkillProperties
robotskills/<WindowTitle>/config.json        whatever GetConfigOnProjectSave wrote

A skill whose GetConfigOnProjectSave throws aborts the whole save, so catch what you can and log it. GetConfigOnProjectSave is also called at any time by the AI agent to inspect the configuration, so keep it cheap and free of side effects.


19. Configuration and the JSON schema

Save and load with whatever format you like - the stream is yours - but JSON is the convention:

public override void OnSetConfiguration(OnSetConfigurationParams configurationParams) {

  if (configurationParams.IncludesConfig)
    _Configuration = JsonSerializer.Deserialize<Configuration>(configurationParams.ConfigStream)!;
}

public override void GetConfigOnProjectSave(Stream configStream, JsonSerializerOptions suggestedJSONSerializerOptions) {

  JsonSerializer.Serialize(configStream, _Configuration, suggestedJSONSerializerOptions);
}

public override Type GetConfigurationType() {

  return typeof(Configuration);
}

GetConfigurationType() is what lets the Athena AI agent (and any other programmatic editor) read and change the skill's settings - "make my robot track faces" works because the agent can see what each setting means. ConfigSchemaBuilder builds an annotated JSON Schema from the type:

  • Descriptions come from, in order: a [Description] attribute, a [RobotSkillReference] attribute, or the /// summary comment on the property. That is why the template project builds with GenerateDocumentationFile on: the XML ships beside the DLL and the schema reads it. Write a /// summary on every configuration property, for someone who has never seen the skill.
  • [Range] becomes minimum / maximum.
  • Integer-backed enums list their names compactly ("D0..D255=0..255, NA=512"), because the JSON holds the number and the number alone means nothing.
  • Byte arrays and get-only properties are marked so an editor leaves them alone.

[RobotSkillReference("kind")] (ARCx.Config.Schema) marks a string property that holds another robot skill's window title:

/// <summary>
/// The camera robot skill this skill takes its video frames from.
/// </summary>
[RobotSkillReference("camera source")]
public string SourceName { get; set; } = string.Empty;

It adds "x-arcx-reference": "robotSkillWindowTitle" to the schema, so an agent knows the value must match an existing skill rather than being free text. The constructor without arguments means any robot skill.

ConfigSchemaBuilder.Build(Type) returns a JsonNode; BuildJson(Type, bool indented = false) returns a string. Schemas are cached per type. A skill can call it on any type.

Return null from GetConfigurationType() to opt out of programmatic editing.


20. Logging

Two destinations, for two audiences.

ARCLogService - errors and diagnostics

ARCx.Core.ARCLog.ARCLogService (static). Goes to the console, the Log Monitor, and the user's usage log file on disk.

try {

  doTheWork();
} catch (Exception ex) {

  ARCLogService.AddLogEntry(this, LogEntryCls.SeverityEnum.Error, MethodBase.GetCurrentMethod(), ex);
}

Overloads:

AddLogEntry(RobotSkillWorkerAbstractEntity origin, SeverityEnum type, MethodBase m, Exception ex = null)
AddLogEntry(RobotSkillWorkerAbstractEntity origin, SeverityEnum type, MethodBase m, string message, Exception ex = null)
AddLogEntry(object origin, SeverityEnum type, MethodBase m, Exception ex = null)
AddLogEntry(object origin, SeverityEnum type, MethodBase m, string message, Exception ex = null)
AddLogEntry(string origin, SeverityEnum type, MethodBase m, string message, Exception ex = null)
AddLogEntry(EZB origin, SeverityEnum type, MethodBase m, string message, Exception ex = null)

SeverityEnum: Info (0), Warning (1), Error (2).

Passing the worker as origin records the skill's type, the method name and the skill's window title, so the user can tell which instance logged it.

AddLogEntry() never blocks and never throws - entries are queued and written on a thread pool task, so they may appear slightly after the call. Do not log from a tight loop; every entry is written to disk.

UCLogBuffered - what the user reads

UCLogBuffered.AddEntry(this, "Connected to COM3 at 115200");

See the UI manual, section 8. A caught exception goes to ARCLogService; a status line the user wants to see goes to UCLogBuffered. Most user-visible actions should leave a line there.


21. Persistence and runtime state

Three stores, for three lifetimes.

Store Lives Use it for
The skill's Configuration class In the user's project file Anything the user sets. This is the default
CustomRegistryManagerService On disk, across restarts, per signed-in account A skill's own settings that are not part of a project: an API key, a remembered device
RuntimeStateService In memory, for the life of the ARCx process Values shared between skills for one session

CustomRegistryManagerService

Namespaced key/value, encrypted on disk with the signed-in account's email.

_customRegistry.SetRegistryEntry("MySkill", "ApiKey", key);
var key = _customRegistry.GetRegistryEntry("MySkill", "ApiKey", string.Empty);

SetRegistryEntry(ns, name, value) and GetRegistryEntry(ns, name, defaultValue) overload for bool, int, long, byte, decimal, double, DateTime and string (GetRegistryEntryAsLong for long). DeleteRegistryEntry(ns, name).

RuntimeStateService

_runtimeState.Set("mySkill.lastScan", scanPoints);

if (_runtimeState.ContainsKey("mySkill.lastScan"))
  var points = _runtimeState.Get<ScanPoint[]>("mySkill.lastScan");

Get<T> throws KeyNotFoundException when the key is missing and when it exists but is not a T. Check ContainsKey() first. Keys are case-sensitive. Remove(key) returns whether anything was removed.

ConfigRegistryManagerService

ARCx's own preferences (log intervals, font sizes, audio devices, computer-vision scale width, RobotSkillUIQueuedUpdateMS). A skill reads these to respect the user's preferences; it does not store its own settings here.


22. Custom UI and robot configuration services

CustomUIService

ARCx.Services.CustomUI. The screens the user lays out for their robot, which one is showing, and the breadcrumb behind it. The screens themselves live in the project properties, so they save and load with the project.

Member Notes
const string WINDOW_TITLE "Custom UI" - the pseudo robot skill scripts address with ControlCommand("Custom UI", "Show", "My screen")
List<CustomUIScreen> Screens In display order, never null
CustomUIScreen GetScreen(string guid) / FindScreen(string titleOrGuid)
event OnScreensChanged Screens added, removed, renamed, reordered or edited
event OnNavigated(CustomUINavigateEventArgs) Screen and Breadcrumb (home first, current last)

Navigation is one shared state, so a script, a control command and a phone all agree on which screen is up.

A skill contributes widgets through GetCustomUIWidgets() on the worker - see the UI manual, section 16.

RobotConfigService

ARCx.Services.RobotConfig. The picture of the robot and the ports marked on it.

It copies each marker's name into the project's port description dictionaries, so every port selector shows "V3 (Left elbow)" without knowing the picture exists, and it pushes the servo hard limits down to the controllers, which clamp every position in EZB.Servo.SetServoPosition. No skill, script or Auto Position frame can drive a servo past what the robot's body allows. The limits are re-pushed whenever a controller connects.

Member Notes
RobotConfiguration Configuration Never null
List<RobotPortMarker> Ports Every marked port, in the order added
List<RobotPortMarker> GetPorts(RobotPortTypeEnum) All the servos, say
RobotPortMarker FindPort(RobotPortTypeEnum, int boardIndex, string port) Null when not on the picture
event OnChanged A port added, removed, moved, renamed, relimited, or the picture replaced

Limits set by a script or a robot skill directly are not tracked here and are left alone.


23. Utilities

ONNX Runtime

ARCx ships Microsoft ONNX Runtime (the CPU build) for skills that run neural networks - image embeddings, detection, speech, voices. ARCx itself does not use it; it is there so every skill shares one copy. Reference Microsoft.ML.OnnxRuntime.dll from the ARCx folder with Private=false (the line is in the template's References comment). Add no Microsoft.ML.OnnxRuntime* NuGet package and copy no onnxruntime native library: the one beside ARCx.exe is already the right one for the platform.

Create sessions through OnnxAccelerationService, not with new InferenceSession directly. The user chooses the hardware once, for every skill, in Options > Preferences > Acceleration, and the service puts the session there:

Accelerator Hardware Where it comes from
WebGpu Any graphics card: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS Ships with ARCx (plugin in onnxruntime-ep/webgpu)
Cuda NVIDIA, GTX 16 / RTX 20 series and newer Downloaded from the Preferences page; needs NVIDIA CUDA 13 and cuDNN 9
CoreML Apple GPU and Neural Engine Built into the macOS runtime
Cpu The processor Always there
var acceleration = ServiceRequestor.GetRequiredService<OnnxAccelerationService>();

// Uses the user's choice (Auto picks the fastest that works), and falls back to the processor
// when the graphics card cannot start. configure runs once per attempt.
var created = acceleration.CreateSession(modelPath, (options, accelerator) => {

  options.IntraOpNumThreads = 4;
});

_session = created.Session;               // yours to dispose
log($"Running on {created.Description}"); // "WebGPU (NVIDIA GeForce RTX 4070)" or "CPU"

if (created.FallbackReason != null)
  log(created.FallbackReason);

Pass accelerator: OnnxAcceleratorEnum.Cpu to keep a model off the graphics card (small models, or a skill setting that forces the processor). For SessionOptions you build yourself, call ResolveAccelerator() and then AppendAccelerator(options, accelerator). Never call AppendExecutionProvider_DML or AppendExecutionProvider_CUDA: the runtime ARCx ships has neither built in, and graphics cards only work through the service's plugins. The choice is read when a session is created, so a skill picks up a change the next time it loads its model.

Platform Native library
Windows x64 / ARM64, Linux x64 / ARM64, macOS ARM64 Shipped
macOS x64 None - upstream dropped it. OnnxAccelerationService.RuntimeError says so and creating a session throws DllNotFoundException; catch it and tell the user
  • Keep the ONNX Runtime types in a class of their own and create it inside a try. An older ARCx without ONNX Runtime then fails there with FileNotFoundException naming Microsoft.ML.OnnxRuntime, which the skill can report, rather than failing to load at all.
  • Models are large. Download them on first use into a folder of your own under Constants.DEFAULT_FOLDER, which survives a skill update, and check a checksum before using one.
  • Leave cores for the rest of ARCx: set SessionOptions.IntraOpNumThreads to a few, not all.
  • Run inference on a thread of your own, never in a video event handler or a Blazor event. See Background threads.

Background threads

Work that loops for as long as the skill runs - reading a serial port or a HID device, polling a sensor, running a model - gets a dedicated thread with a name. Thread pool threads all show up as .NET TP Worker in a debugger's Threads window, a profiler and the OS task manager, so when ARCx is busy nobody can tell which skill is using the processor. A named thread says so.

Thread _readThread;

public override void Initialize() {

  _readThread = new Thread(readLoop) {
    IsBackground = true,
    Name = $"{Properties.WindowTitle} - Reader",
  };

  _readThread.Start();
}

void readLoop() {

  while (!IsDisposing && !IsDisposed) {

    // An exception that escapes a thread of your own ends ARCx, not just the skill
    try {

      if (AllWorkersPaused) {

        Thread.Sleep(100);

        continue;
      }

      // ... the work
    } catch (Exception ex) {

      ARCLogService.AddLogEntry(this, LogEntryCls.SeverityEnum.Error, MethodBase.GetCurrentMethod(), ex);

      Thread.Sleep(1000);
    }
  }
}
  • Name it after the window title and the job, so two copies of the same skill are told apart.
  • Always IsBackground = true, or the thread keeps ARCx running after the user closes it.
  • Catch everything inside the loop. An unhandled exception on a Thread terminates the process.
  • Task.Factory.StartNew(loop, TaskCreationOptions.LongRunning) also gets a thread of its own, named .NET Long Running Task. Set Thread.CurrentThread.Name as the loop's first line.
  • Do not loop forever in Task.Run. It holds a thread pool thread that Blazor, timers and every other skill share, and it cannot be told apart from the rest.
  • Do not rename a thread pool thread (inside Task.Run, a timer tick or after an await). The pool keeps the name after your work has finished, so it then points at the wrong skill. Short work belongs on the pool, unnamed; find it with a CPU profiler, which groups by call stack.

TimerSmart

ARCx.Core.TimerSmart.TimerSmart, a System.Timers.Timer that does not re-enter: a tick that arrives while the previous handler is still running is dropped, not overlapped.

public override void Initialize() {

  _timer = new TimerSmart();
  _timer.Elapsed += _timer_Elapsed;
  _timer.Interval = 1000;
}

protected override void DisposeOverride() {

  // Unsubscribe BEFORE disposing - an elapsed event can already be queued on a thread pool
  // thread and would otherwise run against a disposed worker
  if (_timer != null)
    _timer.Elapsed -= _timer_Elapsed;

  _timer?.Stop();
  _timer?.Dispose();
  _timer = null;
}

Elapsed hides the base event - subscribe to this one. IsEventRunning, IsDisposing, and the base Enabled. Start() is ignored while already running; both Start() and Stop() are ignored once disposing.

The handler's first two checks are always the same:

// The skill may have been removed while this event was queued
if (IsDisposing || IsDisposed)
  return;

// A configuration window is open somewhere, so hold off
if (AllWorkersPaused)
  return;

DisposableBase

ARCx.Core.Disposable.DisposableBase. IsDisposing is set before DisposeOverride() runs, IsDisposed after. Implement protected abstract void DisposeOverride().

ConcurrentList

ARCx.Core.ConcurrentList.ConcurrentList<T>. A thread-safe list, used throughout ARCx where a collection is written from skill threads and read from Blazor circuits.

Common

ARCx.Core.Common (static). Serialization (SerializeObjectString, SerializeObjectCompressed, DeserializeObjectFile, …), filename and path safety (GetSafeFilename, GetSafeSanatizedString, CombinePathSafe, CombinePath), string parsing (IsNumeric, IsInteger, GetNumbersFromString, GetDecimalFromString, TitleCase, SplitOnlyOnce, GetTextBetween), networking (GetIPAddresses, GetWebResponseV2, PostWebResponse, IsInternetConnected), and GetOperatingSystem().

ARCx.Core.Extensions adds DateTimeEx, NumericEx, StringEx, MemoryStreamEx, StreamToByteArray.


24. Gamepads, joysticks and HID devices

ARCx.Services.InputDevices.InputDeviceService. Game controllers (Xbox, PlayStation, Switch, 8BitDo, flight sticks, wheels - anything SDL recognizes, over USB or bluetooth) and raw HID devices (custom USB boards, sensors, button boxes), on Windows, Linux and macOS.

The service owns SDL3 and is the only thing in ARCx that touches it. Do not ship SDL, or any SDL binding, in a robot skill. Every skill loads into its own assembly load context, so a skill's SDL types would not match the host's, and a second SDL_Init in the process fights ARCx for the devices. The public API uses only ARCx types, so a skill needs no package reference at all and stays one AnyCPU build.

The user can turn the service off (Views, Gamepads And HID). Check IsRunning, and listen to OnRunningChanged if you show its state.

Controllers

// Initialize(): subscribe. DisposeOverride(): unsubscribe, or the plugin assembly is pinned.
_inputDeviceService.OnGamepadAdded += inputDevice_OnGamepadAdded;
_inputDeviceService.OnButtonChanged += inputDevice_OnButtonChanged;

// Save the DeviceKey in your configuration; the InstanceId changes every time it reconnects
var gamepad = _inputDeviceService.FindGamepad(_Configuration.DeviceKey);   // "" = first connected

// From a timer: a copy of the current state, never waits on the controller
var state = _inputDeviceService.GetState(gamepad.InstanceId);
float forward = -state.GetAxis(GamepadAxisEnum.LeftY);                     // up is negative
bool jump = state.IsPressed(GamepadButtonEnum.South);                      // A / Cross / B
Member Notes
GamepadInfo[] GetGamepads() Every controller connected, in plug-in order. Includes joysticks that are not gamepads - check IsGamepad
GamepadInfo GetGamepadByIndex(int) / GetGamepad(uint instanceId) / FindGamepad(string deviceKey) Null when not connected. Scripts address controllers by index; configurations should save DeviceKey
GamepadState GetState(uint instanceId) A copy. Null when disconnected. Cheap enough for a 20 ms timer
bool WaitForButtonPress(id, button, timeoutMs, token) Counts presses, so a tap between polls is not missed. A button already held does not count
Rumble(id, low, high, ms), RumbleTriggers(...), StopRumble(id) Strengths 0 to 1. False when the controller cannot
SetLed(id, r, g, b), SetPlayerIndex(id, n) Light bar and player lights, where the controller has them
SetSensorsEnabled(id, bool) Accelerometer and gyro, off by default (they report hundreds of times a second). Values appear in GamepadState.Accelerometer / Gyro
AddGamepadMapping(string) / GetGamepadMapping(id) Teach SDL an unrecognized controller, in SDL's mapping string format
Events OnGamepadAdded, OnGamepadRemoved, OnButtonChanged, OnAxisChanged, OnRawButtonChanged, OnHatChanged, OnRunningChanged

Buttons and axes are named by position, not by the letter printed on them: South is A on Xbox, Cross on PlayStation and B on Nintendo. GamepadNames.GetButtonLabel(button, type) gives the printed label for display; GamepadNames.TryParseButton() accepts both kinds of name. Sticks read -1 to 1 with up and left negative, triggers 0 to 1.

GamepadInfo: InstanceId, Name, IsGamepad, Type (XboxOne, PS5, SwitchPro, ...), JoystickType, VendorId, ProductId, Serial, DeviceKey, Connection, RawAxisCount, RawButtonCount, HatCount, HasRumble, HasTriggerRumble, HasRgbLed, HasAccelerometer, HasGyro. GamepadState: Axes, Buttons, ButtonPressCounts, RawAxes, RawButtons, Hats, Accelerometer, Gyro, BatteryPercent, PowerState, LastInputUtc.

Events are raised on the service's own dispatch thread, in order. A slow handler delays the other handlers, not the controllers, but keep handlers short: start a script or set a flag. Use RefreshUI() from them as usual. OnAxisChanged fires hundreds of times a second while a stick moves and is dropped when handlers fall behind - to drive something from a stick, poll GetState() on a timer instead. A handler that throws is logged, rate limited, and does not stop the others.

HID devices

// Look the device up by VID:PID each time; the path is not stable across ports and reboots
using var device = _inputDeviceService.OpenHidDevice(0x1234, 0xABCD);

device.Write(new byte[] { 0x00, 0x01, 0x02 });        // byte 0 is the report ID, 0 if unnumbered
var report = device.Read(64, 100);                    // empty array on timeout
Member Notes
HidDeviceInfo[] GetHidDevices(vid = 0, pid = 0) Every HID device, or those matching. Throws when the service is not running
HidDeviceConnection OpenHidDevice(string path) / (vid, pid, serial = null, usagePage = 0) Throws IOException when it cannot be opened - on Linux usually permission on /dev/hidraw* (INSTALL covers the udev rule)
Write(byte[]), Read(byte[], timeoutMs), Read(maxLength, timeoutMs) Read waits at most HidDeviceConnection.MAX_READ_TIMEOUT_MS (1000); loop for longer. On Windows the buffer must fit the device's longest input report
SendFeatureReport, GetFeatureReport, GetInputReport, GetReportDescriptor hidapi semantics: the report ID is the first byte
OnHidDevicesChanged Devices attached or removed. Only raised once something has used HID

Nothing in ARCx lists HID devices until something asks, because listing every HID device is what can make macOS ask for Input Monitoring permission. Dispose every connection in DisposeOverride(). The service closes any still open when it stops, and they report IsOpen == false from then on.

A read and a write can run on different threads at once; two reads cannot. Do not do blocking reads on a UI or event thread - give them a named thread of their own (see Background threads), and end its loop on IsDisposing.


25. Browser windows: web pages, dashboards, video calls and kiosks

ARCx.Services.BrowserPresentation.BrowserPresentationService. Chromium windows on the monitors plugged into the computer ARCx runs on - a robot face, a dashboard, a WebRTC video call, a kiosk screen - on Windows x64, Linux x64 and Linux ARM64. Not to be confused with the browser showing the ARCx interface, which can be anywhere on the network.

On Linux no desktop environment is needed: a bare X server is enough, and the window is placed and made fullscreen without a window manager. INSTALL, Browser windows, has the setup.

Do not ship CEF, CefSharp, CefGlue, WebView2 or any other Chromium embedding in a robot skill. CEF can be initialized once per process, and ARCx runs it in a browser host process of its own anyway (below). The public API uses only ARCx types, so a skill needs no package reference and stays one AnyCPU build.

How it works

The service starts a browser host process (the ARCx executable again, with --arcx-browser-host) the first time a window is opened, and talks to it over a pipe. Chromium's crashes stay in that process: the service restarts it and reopens every window, raising OnWindowClosed (Reason = HostExited, WillReopen = true) and then OnWindowOpened again. The host stops two minutes after the last window closes.

Starting the host takes a second or two the first time (several on a Raspberry Pi). Never call OpenWindow from a Blazor render or event handler without Task.Run. Every other call blocks only for the few milliseconds the host takes to answer.

Opening a window

// Initialize(): nothing to create - windows open when you need them.
// Somewhere off the UI thread:
_window = _browserPresentationService.OpenWindow(new BrowserWindowOptions {
  Name = $"{Properties.WindowTitle} Face",          // global across ARCx - make it yours
  Url = "http://127.0.0.1:5000/_dynamic/" + Properties.InstanceGuid + "/face.html",
  DisplayMode = BrowserDisplayModeEnum.Kiosk,
  Monitor = _Configuration.Monitor,                 // -1 = primary
  AllowCamera = true,
  AllowMicrophone = true,
  OwnerTag = Properties.InstanceGuid,               // ARCx closes it if the skill is removed
});

_window.OnPageMessage += window_OnPageMessage;
_window.OnClosed += window_OnClosed;

// DisposeOverride(): unsubscribe, then close.
_window.OnPageMessage -= window_OnPageMessage;
_window.OnClosed -= window_OnClosed;
_window.Close();

OpenWindow with a name that is already open re-points that window (new Url, new options) and returns the same BrowserWindow, rather than opening a second one. Window names are shared by every skill and script in ARCx, so prefix yours with the skill's window title.

A page the skill serves itself fits well with OnDynamicResourceRequest (UI manual, UCDynamicImage): serve face.html from /_dynamic/[instance guid]/... and point the window at it. ARCx binds to 127.0.0.1 by default, which the browser host can always reach.

BrowserWindowOptions Default Notes
Name required The address scripts and the Browser Windows page use. Case insensitive
Url required http, https, file and data URLs. example.com becomes https, localhost and IP addresses http, an existing file path a file URL (NormalizeUrl)
Title Name The native window title
DisplayMode Windowed Windowed, Fullscreen (borderless over the monitor) or Kiosk
Monitor -1 BrowserMonitorInfo.Index; -1 or a monitor that is gone means the primary
X, Y, Width, Height 0 Windowed only, relative to the monitor. 0 size = 1280x800 centered
AlwaysOnTop false Always true for Kiosk
HideCursor false CSS cursor: none on every page
AllowCamera, AllowMicrophone, AllowScreenCapture false getUserMedia / getDisplayMedia. There is no prompt - nobody is at a robot's screen to click Allow. A request for camera and microphone together is refused unless both are allowed
AllowOtherPermissions false Geolocation, notifications, clipboard, MIDI...
PermissionOrigins empty Restrict the permissions to these origins (https://meet.example.com, * wildcards). Empty = any
AllowedUrlPatterns empty Navigation allowlist (https://dash.example.com/*). Other main-frame navigations are cancelled and raise OnNavigationBlocked. Empty = anywhere
PopupPolicy OpenInSameWindow window.open / target=_blank: load it here, or Block
IgnoreCertificateErrors false For a device on your network with a self-signed certificate
AllowDevTools true F12 and ShowDevTools(). Never in Kiosk
ZoomPercent 100 25 to 500, kept across navigations
Muted false
ReloadOnCrash true Reload when the renderer dies; stops after 5 crashes in a minute
RetryOnLoadError, RetryIntervalSeconds false, 10 Show a retry page that reloads the address. Always on for Kiosk
ReopenWhenClosed false Reopen when closed on the screen. Always on for Kiosk
KeepDisplayAwake false Stop the monitor blanking while open. Always on for Kiosk
InjectCss, InjectScript empty Added to every document the window loads, before the page's own scripts
OwnerTag empty Set it to Properties.InstanceGuid
CloseWithProject true Closed when the project closes or another loads

Kiosk is Fullscreen plus: always on top, no context menu, no keyboard shortcuts (Alt+F4 included), no developer tools, JavaScript alert logged and dismissed and confirm answered yes, the retry page when offline, the display kept awake, and the window reopened if closed on the screen. Close a kiosk with Close() - that one is final.

Controlling a window

BrowserWindow member Notes
Name, IsOpen, Options, State, Url, Title, IsLoading Options and State return copies
Navigate(url), GoBack(), GoForward(), Reload(ignoreCache), Stop(), GoHome() GoHome returns to Options.Url
WaitForLoad(timeoutMs, token) True when the page loaded, false on failure, timeout or close
ExecuteScript(js) Fire and forget
EvaluateScriptAsync(expression, timeoutMs) / EvaluateScript(...) The value as JSON ("42", "\"text\"", "{...}", "null"). Promises are awaited. Throws BrowserPresentationException with the page's error
SendMessage(topic, data) To the page's ARCx.on(topic, ...). data is serialized to JSON; a string holding JSON object/array text is passed through as that object
WaitForMessage(topic, timeoutMs, token), ClearMessages(), GetLastMessage(topic) Unread messages queue for 60 s (max 100); WaitForMessage takes the oldest. Events suit a skill better
CaptureScreenshotAsync() PNG bytes
SetDisplayMode(mode), SetFullscreen(bool), MoveToMonitor(i), SetBounds(x, y, w, h), SetAlwaysOnTop(bool)
Show(), Hide(), Focus(), SetZoom(percent), SetMuted(bool), SetMediaPermissions(camera, mic)
UpdateOptions(o => ...) Change any options at once; a changed Url navigates
ShowDevTools(), Close(), GetConsoleHistory() Console history is the last 200 lines

BrowserWindowState: Url, Title, IsLoading, CanGoBack, CanGoForward, IsVisible, DisplayMode, Monitor, X/Y/Width/Height, ZoomPercent, IsMuted, IsCameraInUse, IsMicrophoneInUse, LastHttpStatus, LastLoadError, CrashCount, OpenedUtc, OwnerTag.

Service member Notes
OpenWindow(options) / OpenWindow(name, url) Opens or re-points. Throws BrowserPresentationException with a message for the user
GetWindow(name), GetWindows(), CloseWindow(name), CloseAllWindows(), CloseWindowsOwnedBy(tag) GetWindow is null when not open
GetMonitors(startHostIfNeeded) Only the host can see monitors: empty while it is not running unless you pass true
IsAvailable, GetUnavailableReason(), Enabled, IsHostRunning, LastError, ChromeVersion Check IsAvailable before offering a screen feature in your UI
EnsureHostRunning(), RestartHost(), StopHost(), ClearBrowsingData() Rarely needed; the service manages the host
Events OnWindowOpened, OnWindowClosed, OnWindowStateChanged, OnPageMessage, OnHostStateChanged

BrowserWindow events: OnStateChanged, OnLoadCompleted, OnLoadFailed, OnPageMessage, OnConsoleMessage, OnNavigationBlocked, OnCrashed, OnClosed.

Events are raised on the service's own dispatch thread, in order. A slow handler delays the other handlers, never the browser, but keep them short and use RefreshUI() from them as usual. A handler that throws is logged, rate limited, and does not stop the others. Handle OnClosed: a window closed with WillReopen == false is gone, and its BrowserWindow stays closed - open it again for a new one.

The page side: window.ARCx

Every document the window loads - reloads, navigations, iframes - gets a window.ARCx object before its own scripts run, so a page can use it at the top level:

<script>
  // Robot to page: BrowserWindow.SendMessage("mood", "happy") / Browser.sendMessage(...)
  ARCx.on('mood', function (data) { document.body.className = data; });

  // Any topic
  ARCx.on('*', function (data, topic) { console.log(topic, data); });

  // Page to robot: raises OnPageMessage, answers WaitForMessage / Browser.waitForMessage(...)
  document.getElementById('wave').onclick = function () { ARCx.send('command', { action: 'wave', speed: 5 }); };

  // Also delivered as a DOM event, for frameworks
  window.addEventListener('arcx-message', function (e) { console.log(e.detail.topic, e.detail.data); });
</script>

window.ARCx only exists in an ARCx browser window; guard with if (window.ARCx) so the same page still works in an ordinary browser. Data is anything JSON.stringify accepts. On the C# side, BrowserPageMessageEventArgs has Topic, DataJson, DataAsString (a JSON string unquoted) and GetData<T>(). Every frame in the window can call ARCx.send, including third-party iframes, so treat page messages as user input.

Scripts, Blockly and the Browser Windows page

Everything here is also the Browser script namespace (JavaScript Browser.open(...), Python Browser.Open(...)) and the Browser category in Blockly, built on the same service - a skill and a script can share a window by name. Views, Browser Windows lists the open windows with live controls, the monitors, and the service settings (GPU acceleration, one renderer process, low end device mode, the Linux X display, the remote debugging port). The Diagnostics page has a Browser Windows section, including the missing Linux libraries when Chromium cannot start.


26. Depth cameras

ARCx.Services.DepthSensing.DepthSensorService. The meeting point between robot skills that produce depth (an Intel RealSense, Orbbec, OAK-D, Kinect, ZED, a ROS depth topic) and everything that wants to use it, the way VideoRouteService is for pictures and KinematicsService is for arms. A depth camera skill registers once and hands over every depth frame; any skill can then ask how far away something is, follow the depth live, or build its own maps from it, without knowing which camera it is.

It also does one thing for every vision skill at no cost to them: a detection made on a video source that a depth camera is aligned with (its pixels line up with the depth) is measured from the depth before OnObjectsDetected is raised. Face, colour, glyph and object detections then carry their real distance and position, and the Object Detection Script robot skill can stop the robot at a distance or drive to the object.

Frames, units and frames of reference

Type What it is
DepthFrame One depth image: Width, Height, Data (ushort[], row by row, depth along the optical axis in units of DepthUnitM metres, 0 = no reading; the Z16 format every depth camera produces), Intrinsics, FrameNumber, TimeUtc. Immutable once handed over: give each frame its own array and never write to it again, because consumers keep frames and read them on other threads
DepthIntrinsics The pinhole model: Fx, Fy, Ppx, Ppy for a Width x Height image, the numbers every SDK reports (RealSense rs2_intrinsics, ROS CameraInfo.K). Scaled(w, h) after decimating; HorizontalFovRad, VerticalFovRad
DepthSensorMount Where the camera sits: ForwardM, LeftM, HeightM (from the floor), PitchRad (positive tilted down, ROS pitch), YawRad (counter-clockwise from the robot's front), RollRad. OpticalToRobot(point) turns a point from the camera's optical frame (+X right, +Y down, +Z out of the lens) into the robot's (+X forward, +Y left, +Z up from the floor); OpticalDirectionToRobot(v) does the same for a direction (an accelerometer reading). HeightM = 0 means unknown
DepthImuSample An inertial reading in the robot's frame: Accel (m/s², reads about +9.81 on Z when level), Gyro (rad/s, Z positive turning left), HasAccel, HasGyro, and PitchRad / RollRad from gravity (PitchFromAccel(), RollFromAccel() for your own vectors)
DepthMeasurement Where something is: DistanceM (straight line from the lens), DepthM, ForwardM, LeftM, HeightM, RangeM (along the floor from the robot's centre), Angle (radians, counter-clockwise from straight ahead)

Everything follows the NMS frame of section 14: metres and radians, +X forward, +Y left, angles counter-clockwise.

Providing depth

// Once configured (and again with UpdateSensorDetails when the mount or the aligned source changes)
_sensorGuid = _depthSensorService.AddSensor(new DepthSensorRegistration() {
  Name = Properties.WindowTitle,
  Description = "Orbbec Astra (serial 1234)",
  ProviderName = Properties.Title,
  AlignedVideoSourceName = $"{Properties.WindowTitle} Color",   // empty when the depth is not aligned
  Mount = new DepthSensorMount() { HeightM = 0.35f, PitchRad = 0.2f },
  MinRangeM = 0.3f,
  MaxRangeM = 6f,
});

// Every frame, on the capture thread
_depthSensorService.UpdateDepthFrame(_sensorGuid, new DepthFrame() {
  Width = w, Height = h, Data = copyOfTheDepth, DepthUnitM = 0.001f, Intrinsics = k, FrameNumber = n,
});

// DisposeOverride()
_depthSensorService.RemoveSensor(_sensorGuid);
Member Notes
Guid AddSensor(DepthSensorRegistration) Throws on an empty or duplicate name (case-insensitive). Keep the guid
void RemoveSensor(Guid) Safe with an unknown guid
void UpdateSensorDetails(guid, name, description, alignedVideoSourceName, mount, minRangeM, maxRangeM) Leave a parameter null (a range negative) to keep it; pass "" as the aligned source to say nothing lines up any more
void UpdateDepthFrame(Guid, DepthFrame) Stores the frame and raises OnDepthFrame
void UpdateImu(Guid, DepthImuSample) For a camera with an inertial sensor. A few tens a second is plenty

Only register an aligned video source while the depth really is aligned to it pixel for pixel (same field of view; any resolution, the rectangle is scaled across). A detection on an unaligned source would be measured at the wrong place.

Turning depth into an NMS scan

DepthScanBuilder.Build(frame, settings, treatCliffsAsObstacles) is the shared way a depth camera feeds the navigation messaging system, so every camera behaves the same. It places every sampled pixel in the robot's frame with the mount, removes the floor by height (MinObstacleHeightM), keeps overhangs up to the robot's height that a lidar would miss (MaxObstacleHeightM, a table top), and with DetectCliffs finds the floor dropping away (stairs, ledges) and reports where the floor ends. The result holds the nearest obstacle and drop per slice of the circle (ObstacleRangeM, CliffRangeM, SliceWidthRad, SliceOf(), SliceAngle(), NearestBetween(start, end, includeCliffs)), the field of view edges, and Points, ready for NMSService.UpdateScan(). Without a mount height there is no floor to measure from and everything seen is reported, like a lidar. It is pure and thread safe; about 15 ms for 100,000 pixels on a desktop, and it samples to TargetSamples (30,000) by default.

A depth camera skill still publishes a placeholder pose when nothing else reports one, exactly as section 14 describes for a lidar.

Using depth

Member Notes
DepthSensor[] Sensors / string[] SensorNames Save the name, never the guid
DepthSensor GetSensor(string name) / (Guid) / GetSensorForVideoSource(string sourceName) Null when unknown
DepthSensor.LatestFrame, LatestImu, Mount, FPS, FrameCount, GetFreshFrame(maxAgeMs) A stopped camera leaves its last frame; GetFreshFrame returns null once it is older than you allow
DepthMeasurement MeasurePixel(name, x, y, maxAgeMs) Depth image pixels; a 5 x 5 median so a single hole does not fail it
DepthMeasurement MeasureRegion(name, x, y, w, h, maxAgeMs, nearPercentile) The median of the rectangle, or a nearer percentile for something thin in its box
DepthMeasurement MeasureVideoRegion(name, x, y, w, h, videoWidth, videoHeight, ...) A rectangle on the aligned video, at its own resolution
OnSensorAdded, OnSensorRemoved, OnSensorUpdated
OnDepthFrame (DepthFrameEventArg: Sensor, Frame) On the provider's capture thread, every frame: keep it short
OnImu (DepthImuEventArg) Often tens of times a second

DepthFrame also has GetDepthM(x, y), Deproject(x, y, depthM), TryDeproject(), GetDepthInRectM(), GetValidFraction() and WritePly(stream, mount, step, colorBgr) for a point cloud MeshLab, CloudCompare, Blender and Open3D open. DepthVisualizer.Colorize(frame, minM, maxM) makes the near-red, far-blue picture to publish as a video source.

The attributes a measured detection gains (DetectedObject.ATTRIBUTE_*): DistanceM (from the lens, replacing a detector's estimate), ForwardM, LeftM, HeightM, RangeM, Angle and DepthSensor. A detection with no depth reading inside its box keeps the attributes its detector gave it.


27. Robot Bus: topics, services and frames

ARCx.Services.RobotBus.RobotBusService. A publish/subscribe message bus, named request/response services and a tree of frames, shared by robot skills, scripts (the Bus namespace), the MCP server and programs outside ARCx (WebSocket and REST at /api/bus, Python and JavaScript clients at /sdk/). The user manual is Manuals/Third-Party Integrations/robot-bus.md.

Use it when what your skill produces or offers should be reachable by things you cannot name in advance: another skill, a Python program on another computer, a ROS node through the ROS Connector, an AI assistant. The typed core services above (video, NMS, kinematics, depth) stay the way to plug into ARCx's own features; ARCx already publishes their data on /arcx/... topics, so do not publish it again.

Conventions

  • Names look like ROS names: /my_skill/battery. Put your skill's topics under / + BusNames.Segment(Properties.WindowTitle) so two copies of the skill do not collide.
  • Payloads are JSON. Publish a record or anonymous object; it is serialized once with camelCase names. Give a type (free text, such as my_skill/Battery) and advertise the topic with a description.
  • Units are the NMS's: metres, radians, +X forward, +Y left, +Z up, angles counter-clockwise (ROS REP 103).
  • The source of everything you publish or provide is your window title.

Publishing and subscribing

// Initialize()
_topic = $"/{BusNames.Segment(Properties.WindowTitle)}/battery";
_robotBusService.Advertise(_topic, "my_skill/Battery", "Pack voltage (V) and charge (0..1)", Properties.WindowTitle);

_subscription = _robotBusService.Subscribe("/arcx/speech/recognized", onHeard, new BusSubscribeOptions() {
  Owner = Properties.WindowTitle,
  QueueDepth = 16,       // oldest dropped when the callback falls behind
  MaxRateHz = 0,         // above 0: the newest message per topic at most this often
  DeliverLatest = false, // true: the current latest message straight away
});

// Whenever there is something new, on any thread
_robotBusService.Publish(_topic, new { voltage = 12.1, charge = 0.82 }, "my_skill/Battery", Properties.WindowTitle);

void onHeard(BusMessage message) {

  // On the subscription's own task, one message at a time. Never block for long.
  var text = message.GetField("text") as string;
}

// DisposeOverride()
_subscription?.Dispose();

BusMessage: Topic, Type, Sequence (per topic, from 1), StampUtc, Source, Data (a detached JsonElement), DataJson, GetData<T>(), GetPlainData(), GetField("pose.x"). Patterns: * one segment, ** any number. GetLatest(topic), GetTopic(topic) (rate, count, publishers), GetTopics(pattern) and WaitForMessageAsync(pattern, timeout, ct) read without subscribing.

Providing and calling services

_service = _robotBusService.RegisterService(
  $"/{BusNames.Segment(Properties.WindowTitle)}/calibrate",
  "Calibrate the sensor. Args: samples (int, default 50). Returns the offsets.",
  async (request, ct) => {

    var samples = request.GetInt("samples", 50);

    return await calibrateAsync(samples, ct);
  },
  Properties.WindowTitle,
  readOnly: false,
  argsSchema: "{\"type\":\"object\",\"properties\":{\"samples\":{\"type\":\"integer\"}}}");

// DisposeOverride()
_service?.Dispose();

// Calling one (never throws for a failed call)
var result = await _robotBusService.CallServiceAsync("/arcx/speech/say", new { text = "Calibrated" }, Properties.WindowTitle, TimeSpan.FromSeconds(5), ct);

if (!result.Ok)
  Log(result.Error);

The handler returns any value (serialized to JSON) and throws to fail the call; the exception message is what the caller sees. Honour the token: it is cancelled when the caller's timeout runs out. readOnly: true marks a service that changes nothing, which external programs may call even when Preferences forbid them to control the robot. A service name has one provider; registering a taken name throws.

Frames

// A camera bolted 10 cm forward, 30 cm up, tilted 0.2 rad down
_robotBusService.SetTransform("base_link", "my_camera", 0.10, 0, 0.30, 0, 0.2, 0, isStatic: true, source: Properties.WindowTitle);

// Where is it from the odometry frame?
if (_robotBusService.TryLookupTransform("odom", "my_camera", out var t, out var error)) {

  var (x, y, z) = t.Apply(1.5, 0, 0);   // a point 1.5 m in front of the camera, in odom
}

BusTransform is a translation and a unit quaternion: FromEuler, FromQuaternion, Then, Inverse, Apply, Rotate, Roll, Pitch, Yaw, Distance. ARCx keeps odom → base_link from the NMS pose and base_link → <depth sensor> from each depth camera's mount. Changes are published on /tf and /tf_static.

Recording

_robotBusService.Recorder records topics to ARCx Data\Recordings and plays them back (StartRecording(patterns, name), StopRecording(), ListRecordings(), StartPlayback(name, speed, loop, patterns, topicPrefix), StopPlayback()). A skill that consumes topics works on a replay exactly as on live data, which is the point: record once on the robot, debug at the desk.


28. Rules that will bite you

  1. Create nothing in the constructor. Timers, executors, service registrations and event subscriptions all go in Initialize().
  2. Unsubscribe from every event and dispose everything in DisposeOverride(). A leaked handler pins the plugin assembly; ARCx then cannot overwrite the installed files and the skill will not rebuild until ARCx is restarted.
  3. Unsubscribe before disposing a timer, not after. A queued elapsed event runs against a disposed worker otherwise.
  4. Check IsDisposing / IsDisposed and AllWorkersPaused at the top of every timer tick and background loop.
  5. Look up other robot skills only in OnProjectLoadCompleted(), never in Initialize() or OnSetConfiguration().
  6. Null-check TryGetEZB() and IsConnected before every hardware call. A project can reference a controller that is no longer there, and a disconnected board silently does nothing at best.
  7. Save names, not guids. A video source guid and a kinematic chain guid change every session. Names and window titles survive a save and load.
  8. Never hold a Mat past the end of a video event handler. Clone what you need.
  9. Deregister what you registered: RemoveSource(), RemoveChain(), DeregisterInputSource(), RemoveAndDisposeExecutor() - all in DisposeOverride().
  10. RefreshUI(), not StateHasChanged() on the worker. It is throttled, thread safe, and reaches the Custom UI widgets too.
  11. Give every configuration property a /// summary. It is what Athena reads.
  12. Build AnyCPU. A robot skill is platform-agnostic managed code and ARCx loads the same build on Windows, Linux and macOS. Never add a platform-specific code path or build: dotnet build "<Project>.csproj" -c Debug -p:Platform=AnyCPU. Name the .csproj: a solution file beside it (Visual Studio saves one) spells the platform "Any CPU", and dotnet build given only the folder picks the solution and fails with MSB4126.
  13. Do not log from a tight loop. Every ARCLogService entry is written to disk.
  14. Do not ship SDL. Controllers and HID devices come from InputDeviceService; a second copy of SDL in the process takes the devices away from ARCx and from every other skill.
  15. Do not ship a Chromium embedding (CEF, CefSharp, CefGlue, WebView2). Browser windows come from BrowserPresentationService, which runs Chromium out of process for you. Open windows off the UI thread, set OwnerTag to your instance guid, and close them in DisposeOverride().
  16. Reference the ARCx libraries you use, and only those. A skill compiles against the References item group in its .csproj: ARCx, MudBlazor and DeepCloner to start with. For another library ARCx ships - OpenCvSharp for camera frames, NAudio.Core for audio, Microsoft.ML.OnnxRuntime for neural networks, System.IO.Ports for serial ports, System.ServiceModel.Primitives for ARCx's web service clients and the rest listed in the template's comment - copy its line out of that comment. Each one is Private=false, so nothing is copied into the output; ARCx already has it loaded. CS0246 or CS0234 on one of its types, or CS0012 naming an assembly, means the reference is missing. A library ARCx does not ship is a NuGet PackageReference.
  17. Do not ship ONNX Runtime. ARCx ships it, native library included, for every platform - see section 23. Only one onnxruntime native library can be loaded into a process, so a skill carrying its own fights ARCx and every other skill that runs a model.
  18. Give a long-running loop its own named background thread, never a Task.Run that never returns. Name it after Properties.WindowTitle and the job so a profiler or debugger shows which skill is using the processor, and catch everything inside it - see Background threads.

Where to look next

  • ARCx.xml - beside ARCx.dll, in the folder this copy of ARCx runs from. The full XML documentation for every public class, method, property and event, generated from the platform's own source. It is around a megabyte, so search it rather than reading it: every entry is a <member name="..."> element whose name is the fully qualified member, prefixed T: for a type, M: a method, P: a property, E: an event, F: a field. Grep T:ARCx to list the types, M:ARCx.Core.EZBManager for the manager's methods, VideoRoute for everything about cameras.
  • RobotSkill-UI-Components.md - the visual side: the component base classes, layout, the UC* components, Custom UI widgets.