Module reference · Uplink
Command Mapping & Encoding
The uplink's second stage. Converts one declared operator input into one declared, device-independent command — nothing more.
What
Command Mapping & Encoding is a TeleroboticsModule that
converts one declared Input Provider contract into one declared,
device-independent command contract. It is the uplink's second stage —
it sits directly after Input Provider and before the optional
Operator-side Assistance (uplink) and Uplink Communication.
Concretely, it is
CommandMappingAndEncoding<TInputContract, TCommandContract>,
an abstract class in Unite.Kernel
(Assets/UNITE/Kernel/Uplink/CommandMappingAndEncoding.cs). A
study implements exactly one mapping by subclassing it for a
study-defined TInputContract/TCommandContract
pair and filling in a single method.
Why
UNITE's scoping review found control mappings — the formulas that turn a raw operator signal into a vehicle command — to be another recurring source of cross-study variation: exact deadzones, sensitivity curves, and velocity limits are rarely reported in full. Giving that formula its own module makes it an explicit, versioned, swappable artifact of the study, rather than logic buried inside an input handler or a vehicle controller where it's easy to change by accident and hard to report completely.
It also decouples what the operator did from
what it means for the robot. Input Provider's contract is
device-specific — a keyboard's four keys, a wheel's steering angle. The
command contract this stage produces is not: it carries no memory of
which physical device produced it. That's what lets a study support
several input devices that each publish a different input contract but
converge on the same command type, so everything downstream — Uplink
Communication, Vehicle/Robot Model — never needs to know which device
the operator used. The command's own shape is a free choice too:
differential-drive wheel velocities and a future position command are
both just TCommandContract.
How
When the Input Provider publishes a new contract, the mapping calls
TryMapAndEncode with that input and the current timestamp. If
there is no new input, it does not reprocess an older one:
protected abstract bool TryMapAndEncode(
TInputContract input,
double timestampSeconds,
out TCommandContract output);
Return false to skip publishing this tick. Return
true with a command to publish it. Before publishing, the
Kernel checks that:
- the returned command is not
null, -
the command's
SourceTimestampSecondsequals the input contract's ownTimestampSeconds— the moment the operator signal it was derived from was captured, and -
the command's
TimestampSecondsequals the timestamp the Kernel handed in — the moment this mapping step ran.
Either check failing logs an error and drops the tick rather than
publish a malformed command. Return true with a valid
command and that's the entire job done — the Kernel wraps it in a
Package and publishes it; downstream, Operator-side
Assistance (if selected) or Uplink Communication is already subscribed
and picks it up automatically.
Design the contract first — it determines exactly what
TryMapAndEncode has to construct, so deciding its shape
before writing the method is what keeps that method from turning into a
grab-bag of "might be useful" fields. Contract covers the command shape;
Examples shows how the shipped mapping and study patterns fill it in.
Boundary
Owns
- Reading exactly the input contract type the study declared, once per tick a new one arrives.
- Converting that input into its declared command contract.
- Publishing exactly one package per successfully mapped command, tagged with its output stream.
Does not own
- Reading the operator's device directly — Input Provider's job.
- Constraining or filtering the resulting command — Operator-side Assistance (uplink)'s job.
- Applying delay or transmitting anything — Uplink Communication's job.
- Interpreting the command physically, or controlling the vehicle.
Contract
Every mapping publishes a subclass of
EncodedCommandContract (Unite.Core). Where an
Input Provider's contract carries one timestamp, a command carries two —
because a command is derived, not captured: it needs to say both when
the input it came from was captured, and when the mapping itself ran:
public abstract class EncodedCommandContract
{
public double SourceTimestampSeconds { get; }
public double TimestampSeconds { get; }
protected EncodedCommandContract(
double sourceTimestampSeconds, double timestampSeconds)
{
// ...validates both are finite and non-negative...
if (timestampSeconds < sourceTimestampSeconds)
{
throw new ArgumentOutOfRangeException(
nameof(timestampSeconds),
timestampSeconds,
"The encoding timestamp cannot precede its source timestamp.");
}
SourceTimestampSeconds = sourceTimestampSeconds;
TimestampSeconds = timestampSeconds;
}
}
That last check is a second invariant an Input Provider's contract doesn't have: a command can't claim to have been encoded before its own source input was captured. A study-specific subclass adds whatever command fields the robot needs, and must forward both timestamps to the base constructor unchanged. The examples below show how the shipped mapping fills that contract.
Type-checked on one side only.
Upstream, this module resolves the agent's selected Input
Provider against the exact TInputContract it
declares — a mismatch fails fast at startup, the same mechanism
covered on the Input Provider page. Downstream is different:
packages travel as an untyped payload, so nothing enforces
TCommandContract automatically. Whatever reads the
package next checks the type itself — typically by pattern-matching
the payload and logging an error if it doesn't match, rather than
failing at startup. Pick the command contract carefully: a
mismatch here isn't caught until something downstream tries to
read it.
Examples
These examples follow the same input → command order. Differential drive and Position delta use the same keyboard input but produce different command contracts; Skid-steer uses a command suited to different kinematics.
The demo drives a differential-drive rover, so its command is a pair of wheel velocities — a shape built for the robot's actual actuators, with no memory of which keys produced it:
public sealed class WheelVelocityCommand : EncodedCommandContract
{
public float RequestedLeft { get; }
public float RequestedRight { get; }
public float DeltaTime { get; }
public ArrowInputPackage Input { get; }
public WheelVelocityCommand(ArrowInputPackage input, double encodedAt,
float left, float right)
: base(input.TimestampSeconds, encodedAt)
{
Input = input;
DeltaTime = input.DeltaTime;
RequestedLeft = left;
RequestedRight = right;
}
}
With the contract decided, the mapping does exactly three things:
read the pending input, construct
WheelVelocityCommand, and return true:
public sealed class ArrowToWheelVelocityMapping
: CommandMappingAndEncoding<ArrowInputPackage, WheelVelocityCommand>
{
[SerializeField] private float requestedForwardVelocity = 0.26f;
[SerializeField] private float requestedTurnWheelVelocity = 0.0861f;
[SerializeField, Range(0f, 1f)] private float innerWheelScaleWhileDriving = 0.6f;
protected override bool TryMapAndEncode(
ArrowInputPackage input, double timestamp, out WheelVelocityCommand output)
{
int forward = input.Up ? 1 : input.Down ? -1 : 0;
int turn = input.Left ? -1 : input.Right ? 1 : 0;
float vl = forward * requestedForwardVelocity;
float vr = forward * requestedForwardVelocity;
if (forward != 0 && turn < 0) vr *= innerWheelScaleWhileDriving;
if (forward != 0 && turn > 0) vl *= innerWheelScaleWhileDriving;
if (forward == 0 && turn != 0)
{
vl = -turn * requestedTurnWheelVelocity;
vr = turn * requestedTurnWheelVelocity;
}
output = new WheelVelocityCommand(input, timestamp, vl, vr);
return true;
}
}
Skid-steer platforms — four wheels or tracks per side, common on
outdoor and planetary rovers — turn by deliberately skidding rather
than pivoting cleanly. A simple wheelbase formula over- or
under-turns the robot, because it ignores how much of the
differential wheel speed the skid itself absorbs. The command shape
is the same pair of wheel velocities, reusing the continuous
RacingRigInputPackage input from Input Provider:
public sealed class SkidSteerCommand : EncodedCommandContract
{
public float LeftWheelVelocity { get; }
public float RightWheelVelocity { get; }
public SkidSteerCommand(double sourceTimestamp, double encodedAt,
float leftWheelVelocity, float rightWheelVelocity)
: base(sourceTimestamp, encodedAt)
{
LeftWheelVelocity = leftWheelVelocity;
RightWheelVelocity = rightWheelVelocity;
}
}
With the contract decided, the mapping does exactly three things:
read the pending input, construct SkidSteerCommand, and
return true. The formula is differential drive's, with
one addition — an empirical correction for how much wheel speed the
skid absorbs, tuned from calibration runs rather than derived from
geometry alone:
public sealed class RacingRigToSkidSteerMapping
: CommandMappingAndEncoding<RacingRigInputPackage, SkidSteerCommand>
{
[SerializeField] private float maxLinearVelocity = 0.3f; // m/s
[SerializeField] private float maxAngularVelocity = 1.2f; // rad/s
[SerializeField] private float trackWidth = 0.42f; // m, physical wheel separation
[SerializeField] private float slipCompensation = 1.6f; // empirical, from calibration
protected override bool TryMapAndEncode(
RacingRigInputPackage input, double timestamp,
out SkidSteerCommand output)
{
float linear = (input.Throttle - input.Brake) * maxLinearVelocity;
float angular = input.SteeringAngle * maxAngularVelocity;
float effectiveTrackWidth = trackWidth * slipCompensation;
float left = linear - angular * effectiveTrackWidth / 2f;
float right = linear + angular * effectiveTrackWidth / 2f;
output = new SkidSteerCommand(
input.TimestampSeconds, timestamp, left, right);
return true;
}
}
A study could just as well choose a completely different command shape — a requested position and heading delta instead of wheel velocities — as long as Vehicle/Robot Model is built to consume it. Same input contract, different command:
public sealed class PositionDeltaCommand : EncodedCommandContract
{
public Vector2 Delta { get; } // requested planar displacement this tick
public float HeadingDelta { get; } // requested heading change, in radians
public PositionDeltaCommand(double sourceTimestamp, double encodedAt,
Vector2 delta, float headingDelta)
: base(sourceTimestamp, encodedAt)
{
Delta = delta;
HeadingDelta = headingDelta;
}
}
With the contract decided, the mapping does exactly three things:
read the pending input, construct
PositionDeltaCommand, and return true:
public sealed class ArrowToPositionDeltaMapping
: CommandMappingAndEncoding<ArrowInputPackage, PositionDeltaCommand>
{
[SerializeField] private float stepDistance = 0.05f;
[SerializeField] private float stepHeading = 0.1f;
protected override bool TryMapAndEncode(
ArrowInputPackage input, double timestamp, out PositionDeltaCommand output)
{
float forward = input.Up ? 1f : input.Down ? -1f : 0f;
float turn = input.Left ? -1f : input.Right ? 1f : 0f;
output = new PositionDeltaCommand(
input.TimestampSeconds,
timestamp,
new Vector2(0f, forward * stepDistance),
turn * stepHeading);
return true;
}
}
Same input, different command.
Differential drive and Position delta both consume
ArrowInputPackage — the input device didn't change,
the study's choice of command representation did. Skid-steer
changes both the input and the kinematics, for a physically
different robot. Whichever type a study picks, everything
downstream has to be built for it: Vehicle/Robot Model declares
its own command type as a generic parameter and checks every
package against it, logging an error and skipping any package
that doesn't match rather than crashing.
Config
Command Mapping & Encoding is a Unity component, so it's configured like the rest of a study: in the Inspector, not in code.
-
Add a concrete mapping to the agent.
Add a component such as
ArrowToWheelVelocityMappingto the same GameObject asTeleroboticsAgent. Every selected module must live on that one GameObject. - Assign it to the agent's Command Mapping And Encoding field. It's required — the agent refuses to start (logs an error and disables itself) if this field, Input Provider, Uplink Communication, or the Vehicle/Robot Model is left empty.
- Set the Output Stream Id, and the mapping's own settings. The base class defines one configuration field of its own — every concrete mapping gets it for free — plus whatever the mapping itself needs (velocity limits, sensitivity curves) as its own serialized fields.
| Field | Type | Default | Description |
|---|---|---|---|
| Output Stream Id | string |
"vehicle-control" |
The base class's own field, not the mapping's. It's the routing key Uplink Communication uses to pick which configured channel carries this command — it must match a channel name set up there, or every package this stage produces is logged and dropped. |
| Requested Forward Velocity | float |
0.26 |
Demo-specific: forward wheel speed while driving straight. |
| Requested Turn Wheel Velocity | float |
0.0861 |
Demo-specific: wheel speed while turning in place. |
| Inner Wheel Scale While Driving | float |
0.6 |
Demo-specific: how much the inner wheel slows during a driving turn. |
Same GameObject, one instance.
The agent's Awake validates that every selected
module — including Command Mapping & Encoding — is attached
to its own GameObject; a reference to a component elsewhere in
the scene fails this check and disables the agent.