Module reference · Uplink
Operator-side Assistance (uplink)
The uplink's optional third stage. Reshapes or gates the mapped command before it's transmitted — and when a study leaves it out, the command passes straight through untouched.
What
Operator-side Assistance (uplink) is a TeleroboticsModule that
sits between Command Mapping & Encoding and Uplink
Communication, with the chance to inspect, transform, or drop each
mapped command before it's transmitted toward the robot. Unlike
the two stages upstream of it, it's optional — a study that has
nothing to add here simply leaves the field empty.
Concretely, it is OperatorSideAssistanceModule, an
abstract class in Unite.Kernel
(Assets/UNITE/Kernel/Uplink/OperatorSideAssistance.cs).
Unlike InputProvider<TOutputContract> or
CommandMappingAndEncoding<TInputContract,
TCommandContract>, it isn't generic over a declared
contract type — it works on the untyped Package
envelope those stages already produced, because its job is to act
on whatever command the study already defined, not to define a
new one.
Why
UNITE's scoping review found shared control and operator-side assistance techniques — rate limiting, deadman gating, envelope constraints — to be another recurring but inconsistently instantiated part of a delayed-teleoperation apparatus, alongside input devices and control mappings. Giving assistance its own slot in the pipeline makes "did this study apply operator-side assistance, and exactly what did it do" an explicit, reportable configuration choice, rather than logic quietly folded into a mapping or a vehicle controller.
Making the slot optional, rather than requiring a pass-through implementation, keeps a baseline condition honest: a study with no assistance leaves the field empty and the Kernel wires Command Mapping & Encoding straight to Uplink Communication — there is no stub component silently doing nothing, and nothing downstream needs to know whether this stage ran. That absence is itself part of what a study reports.
How
Every tick, the Kernel drains whatever mapped-command packages
arrived and calls TryProcessPackage once per package,
in order, with that tick's timestamp:
protected abstract bool TryProcessPackage(
Package package,
double timestampSeconds,
out Package output);
Return false to drop the package for this tick — no
command reaches Uplink Communication at all. This is the mechanism
a deadman switch or a gating technique uses. Return
true with a package to publish it downstream. Before
publishing, the Kernel only checks that:
- the returned package is not
null.
That's the entire check — unlike Input Provider and Command
Mapping & Encoding, there's no timestamp-equality validation
here. Those two stages validate against a declared contract type
with known timestamp fields; this stage works on an untyped
Package, so it's the implementation's own
responsibility to preserve or update
SourceTimestampSeconds sensibly when it constructs a
new one. Failing the null check logs an error and drops the tick.
Return true with a valid package and that's the
entire job done — Uplink Communication is already subscribed and
picks it up automatically, exactly as if this stage weren't
there.
Boundary
Owns
- Receiving every package Command Mapping & Encoding publishes, queued in arrival order.
- Deciding, per package, whether to drop it, pass it through unchanged, or publish a transformed replacement.
- Publishing at most one output package per package it received.
Does not own
- Producing the original command — Command Mapping & Encoding's job.
- Applying delay or transmitting anything — Uplink Communication's job.
- Reading live robot-side observations — this stage runs before the delay, with no view of remote state.
- Interpreting the command physically, or controlling the vehicle.
Contract
Unlike Input Provider and Command Mapping & Encoding, this
stage has no dedicated contract type to subclass. It reads and
writes the same minimal envelope those two stages already use —
Package (Unite.Core):
public sealed class Package
{
public object Payload { get; set; }
public double SourceTimestampSeconds { get; set; }
public string StreamId { get; set; }
public Package(object payload, double sourceTimestampSeconds)
: this(payload, sourceTimestampSeconds, null)
{
}
public Package(
object payload,
double sourceTimestampSeconds,
string streamId)
{
Payload = payload;
SourceTimestampSeconds = sourceTimestampSeconds;
StreamId = streamId;
}
}
Payload is untyped, so an implementation has to
recover the concrete command type itself — typically by
pattern-matching package.Payload against the command
contract it expects (for instance
WheelVelocityCommand from the Command Mapping &
Encoding page) and passing the package through unchanged if it
doesn't match, rather than crashing. That's the same
"type-checked on one side only" situation Command Mapping &
Encoding's own output is in: nothing enforces the payload type
automatically once it's travelling as object, so a
mismatch here isn't caught until a matching check fails at
runtime, not at startup.
Preserve StreamId, and watch the timestamps.
The base class only checks that the package you return isn't
null — everything else about it is on you. Uplink
Communication routes by StreamId and rejects
(logs an error, drops) any package with a missing or unknown
one, so a replacement package must carry the original
package.StreamId forward, not a default or
omitted value. Timestamps get no such check either: neither
Package.SourceTimestampSeconds nor any timestamp
fields on the typed payload inside it (an
EncodedCommandContract's own
SourceTimestampSeconds/TimestampSeconds,
for instance) are validated at this stage the way they are on
the way into Command Mapping & Encoding. Construct a
replacement package carelessly and the package-level
timestamp can silently drift out of sync with the payload's
own — nothing here will catch it.
Examples
The tabs show two common TryProcessPackage behaviors:
transforming a payload and dropping a tick.
Clamps how much WheelVelocityCommand's requested
wheel velocities can change from one tick to the next, so a
sudden full-deflection input can't snap the robot's commanded
speed instantly. It always publishes — a rewritten package on
a change that exceeds the limit, or the original package
unchanged otherwise:
public sealed class WheelVelocitySlewLimiter : OperatorSideAssistanceModule
{
[SerializeField] private float maxChangePerSecond = 1.5f;
private float previousLeft;
private float previousRight;
protected override bool TryProcessPackage(
Package package, double timestamp, out Package output)
{
output = package;
if (!(package.Payload is WheelVelocityCommand command))
{
return true;
}
float maxDelta = maxChangePerSecond * Mathf.Max(command.DeltaTime, 0f);
float left = Mathf.MoveTowards(previousLeft, command.RequestedLeft, maxDelta);
float right = Mathf.MoveTowards(previousRight, command.RequestedRight, maxDelta);
previousLeft = left;
previousRight = right;
if (left == command.RequestedLeft && right == command.RequestedRight)
{
return true;
}
output = new Package(
new WheelVelocityCommand(command.Input, timestamp, left, right),
package.SourceTimestampSeconds,
package.StreamId);
return true;
}
}
Only lets a command through while a held key is down — release it and every subsequent command is dropped until it's held again, so the robot never keeps executing a stale command past the moment the operator let go:
public sealed class DeadmanGateAssistance : OperatorSideAssistanceModule
{
[SerializeField] private Key deadmanKey = Key.Space;
protected override bool TryProcessPackage(
Package package, double timestamp, out Package output)
{
output = package;
bool held = Keyboard.current != null &&
Keyboard.current[deadmanKey].isPressed;
return held;
}
}
Dropping is silent downstream.
Returning false means Uplink Communication
never sees a package for that tick — there's no "held at
last value" fallback published on its behalf. If a study
wants the robot to hold its last command while gated
rather than receive nothing, that has to be built into
the assistance implementation itself, since the Kernel
doesn't retain state across dropped ticks for this stage.
Config
Operator-side Assistance (uplink) is a Unity component, so it's configured like the rest of a study: in the Inspector, not in code.
-
Add a concrete assistance component to the agent, if the study uses one.
Add a component such as
WheelVelocitySlewLimiterto the same GameObject asTeleroboticsAgent. Every selected module must live on that one GameObject. - Assign it to the agent's Operator Side Assistance field, or leave it empty. Unlike Input Provider, Command Mapping & Encoding, Uplink Communication, and the Vehicle/Robot Model, this field isn't required — leaving it empty is a valid, deliberate configuration. The agent wires Command Mapping & Encoding straight to Uplink Communication in that case.
- Configure the implementation's own settings. The base class defines no configuration surface of its own — whatever the assistance technique needs (rate limits, gate keys, envelope bounds) is the concrete implementation's own serialized fields.
Disabled is not the same as empty.
Leaving the field empty and assigning a component but
disabling it are not equivalent. The agent wires the
subscription off the field being non-null, not
off whether the component is enabled — so a disabled-but-assigned
component still receives and queues every package Command
Mapping & Encoding publishes. Its Step just
never runs while disabled, so that queue never drains: no
command reaches Uplink Communication at all, silently, for as
long as it stays disabled. To get real pass-through behavior,
clear the field itself rather than disabling the component.
Same GameObject, one instance.
The agent's Awake validates that every
assigned module — including Operator-side
Assistance, if the field isn't left empty — is attached to
its own GameObject; a reference to a component elsewhere in
the scene fails this check and disables the agent.