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
- The worker: lifecycle and overrides
- Getting hold of a service
- EZBManager: the robot controllers
- Servos
- Digital, ADC, PWM
- I2C, UART and ping sensors
- Serial COM ports
- Video routing
- Audio
- Text to speech
- Speech to text
- Movement
- Kinematics
- Navigation messaging (NMS)
- Auto Position
- Scripting, control commands and variables
- Talking to other robot skills
- The project
- Configuration and the JSON schema
- Logging
- Persistence and runtime state
- Custom UI and robot configuration services
- Utilities
- Gamepads, joysticks and HID devices
- Browser windows: web pages, dashboards, video calls and kiosks
- Depth cameras
- Robot Bus: topics, services and frames
- 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.
- Put a
SpeechToTextTriggerClson the skill'sConfigurationand save it as usual. - Drop
<UCSpeechToTextTrigger @bind-Trigger="_configuration.Trigger" EngineWindowTitle="@(CurrentRobotSkillWorker?.Properties?.WindowTitle)" />into the skill's config dialog. The component lists only engines whose capabilities reportCanListenContinuously, because a one-utterance engine cannot listen for a wake phrase all day. - Return it from
GetTrigger(). - Call
SpeechToTextService.NotifyTriggerChanged(windowTitle)fromOnConfigWindowClosed, 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
UpdateAudioStateMessagewithAudioStateEnum.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)inInitialize()or after your configuration loads. Keep the returnedGuid. 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)inDisposeOverride(). Safe with an unknown or already-removed guid.UpdateChainDetails(guid, name, description, providerName, jointCount, supportsOrientation, supportsLinearMoves)- leave a parameter null (or-1for 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 andOnPoseChangedall hang off this.NotifyMoveStateChanged(guid, bool isMoving)when a move starts and ends, orWaitForMoveComplete()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 withGenerateDocumentationFileon: 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]becomesminimum/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 withFileNotFoundExceptionnaming 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.IntraOpNumThreadsto 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
Threadterminates the process. Task.Factory.StartNew(loop, TaskCreationOptions.LongRunning)also gets a thread of its own, named.NET Long Running Task. SetThread.CurrentThread.Nameas 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 anawait). 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 asmy_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
- Create nothing in the constructor. Timers, executors, service registrations and event
subscriptions all go in
Initialize(). - 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. - Unsubscribe before disposing a timer, not after. A queued elapsed event runs against a disposed worker otherwise.
- Check
IsDisposing/IsDisposedandAllWorkersPausedat the top of every timer tick and background loop. - Look up other robot skills only in
OnProjectLoadCompleted(), never inInitialize()orOnSetConfiguration(). - Null-check
TryGetEZB()andIsConnectedbefore every hardware call. A project can reference a controller that is no longer there, and a disconnected board silently does nothing at best. - 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.
- Never hold a
Matpast the end of a video event handler. Clone what you need. - Deregister what you registered:
RemoveSource(),RemoveChain(),DeregisterInputSource(),RemoveAndDisposeExecutor()- all inDisposeOverride(). RefreshUI(), notStateHasChanged()on the worker. It is throttled, thread safe, and reaches the Custom UI widgets too.- Give every configuration property a
///summary. It is what Athena reads. - 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", anddotnet buildgiven only the folder picks the solution and fails with MSB4126. - Do not log from a tight loop. Every
ARCLogServiceentry is written to disk. - 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. - 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, setOwnerTagto your instance guid, and close them inDisposeOverride(). - 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 -OpenCvSharpfor camera frames,NAudio.Corefor audio,Microsoft.ML.OnnxRuntimefor neural networks,System.IO.Portsfor serial ports,System.ServiceModel.Primitivesfor ARCx's web service clients and the rest listed in the template's comment - copy its line out of that comment. Each one isPrivate=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 NuGetPackageReference. - 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.
- Give a long-running loop its own named background thread, never a
Task.Runthat never returns. Name it afterProperties.WindowTitleand 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- besideARCx.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, prefixedT:for a type,M:a method,P:a property,E:an event,F:a field. GrepT:ARCxto list the types,M:ARCx.Core.EZBManagerfor the manager's methods,VideoRoutefor everything about cameras.RobotSkill-UI-Components.md- the visual side: the component base classes, layout, theUC*components, Custom UI widgets.