gRPC control plane¶
VHI hosts an in-process gRPC server that MyoGestic (or any gRPC client) uses to command the control hand. It runs alongside the LSL streams, not instead of them.
Why two transports¶
VHI deliberately splits communication by the kind of data:
| Continuous time-series | Discrete commands | |
|---|---|---|
| Examples | hand poses, model predictions | "play Fist", "freeze", "switch mode" |
| Transport | LSL | gRPC |
| Why | one shared clock, lands in the recording, lossy-tolerant | typed contract, per-call ack/reject, knows if the peer is reachable |
LSL is the wrong tool for a discrete command - it is fire-and-forget, untyped, with no acknowledgement. gRPC is the wrong tool for a 60 Hz pose stream - it has no shared clock and doesn't land in the recording. So VHI uses each for what it is good at.
The server¶
GrpcControlServer is a Godot Node that owns a minimal Kestrel
WebApplication hosting the one VhiControl service on that port —
control and recording-session coordination alike. It:
- starts in
_Ready()on127.0.0.1:<GrpcPort>(default 50051, HTTP/2 cleartext), - stops in
_ExitTree(), - marshals every RPC onto Godot's main thread (see Architecture).
VHI is the server; MyoGestic is the client. The client opens unary calls; the return value is the acknowledgement.
The contract¶
The contract is proto/remote_control.proto in this repo — the authoritative
source. It names no hand: myogestic.remote.RemoteControl is what any remote target
serves, and VHI is one implementation. MyoGestic vendors a copy and regenerates its
Python stubs from it.
GetControlManifest is the whole contract¶
DOFs are addressed by name, and GetControlManifest publishes every address VHI
exports along with what it can render for each — the kind, the range, the states. Neither
side hard-codes a stream layout, and neither side keeps a table the other has to be kept
in sync with.
Each streamed control is a stream of its own, named for that control's own address and
one float32 channel wide. So the address is the stream name, and the manifest says
nothing else about the wire — there is nothing else to say. It used to carry a
stream_name and a channel beside every address; both are gone, because stream_name
always equalled address and channel was always 0, and a field that can only repeat
its neighbour is a field two codebases can disagree about for no gain. A client publishes
under the address it read; there is no positional layout left to get wrong, and nothing
links one DOF's stream to another's.
A client calls it once, unconditionally, before it sends anything. There is no per-client negotiation, no declared subset, and nothing to declare: the manifest is the same for every client, and VHI keeps no session state about who is talking to it. The sequence is:
GetControlManifest— one call. Check thevocabulary_versionit reports, then map your own model-output names onto the addresses it lists.- Then either publish a single-channel stream named for that address, one per DOF you
drive, or send
SetControlfor held states and low-rate updates. Both work immediately; nothing has to be opened first, and you publish only the DOFs you actually drive.
SweepControl, SetPresentation and the recording RPCs are optional extras on top.
The vocabulary version is a gate, not a label¶
ControlManifest.vocabulary_version is a decimal integer, compared numerically, and this
build reports "2". A client declares the oldest vocabulary it can drive and
refuses anything below it, loudly, at bind — MyoGestic declares a minimum of 2 and
will not drive a target reporting less.
That refusal exists because VHI and its clients are separately installed applications. Upgrading one does not upgrade the other, and a version-skewed pair otherwise fails in the quietest way this system has: an old target sits waiting for a wide pose stream nobody publishes any more, logs nothing at all, and the hand simply never moves. There is no symptom to read, so the check has to happen at the one moment both versions are on the table.
| Vocabulary | The transport it describes |
|---|---|
1 |
a manifest carrying stream_name and channel per capability; several controls could share one wider stream. Retired. |
2 |
one stream per DOF, named for the address, one float32 channel wide. |
Field numbers 10 and 11 are reserved in the .proto, and so are the names
stream_name and channel — a later field reusing either spelling would read as the old
one in JSON or text format to anything still carrying the v1 schema, which is the mistake
reserving the numbers alone does not prevent.
VHI enforces the same contract from the receiving end: a resolved LSL stream whose channel count is not exactly 1 is logged as an error and its inlet is never opened. See the LSL reference.
The legacy myogestic.vhi.v1.VhiControl service has been removed
The v1 proto and its service are gone. They spoke in movement names and a nine-float pose whose channel meaning lived nowhere — channels 6-8 were dead on both ends for years without anything noticing, and MyoGestic's own tables documented channel 1 wrongly.
A client that still speaks v1 now gets UNIMPLEMENTED. That is also what a
current client sees when it calls GetControlManifest against a build too old to
answer, which is how it recognises a target it cannot drive. Nothing degrades
silently.
Its capabilities went to three different places, because they were three
different kinds of thing: SetMovement became a standard discrete DOF
(a held state), SetSessionActive and movement cycling became the
recording-session RPCs, and SetSmoothing became SetPresentation — a
target presentation setting. Freeze, SetSpeed, SetChirality and
SetControlMode were transport concepts with no consumer and were not
replaced. GetState's only real job, discovering movement names, is
GetRecordingSessionState.
Smoothing is three layers, not one¶
SetPresentation (on VhiControl) configures VHI's own visual
blending. It is deliberately the third of three separate mechanisms, and treating
any two as interchangeable is a bug:
| Layer | Where | Applies to | Authoritative? |
|---|---|---|---|
| 1. Continuous smoothing | MyoGestic's ControlBus, before any target |
continuous DOFs | yes — sets the commanded value |
| 2. Debounce + hysteresis | MyoGestic, declared on the DOF | discrete DOFs | yes — sets when a state changes |
| 3. Presentation blending | here, in VHI | how a value looks | no — appearance only |
A discrete control is never numerically low-pass filtered as though it were an axis: averaging "rest" and "fist" interpolates through states nobody selected. A noisy classifier needs a stability gate, which is layer 2 and lives on the MyoGestic side.
Layer 3 is worth having — a hand that snaps between poses looks wrong — but it cannot make an unstable prediction stable. A build with blending on and no debounce still jumps between states; it just does so smoothly, which is arguably worse because it looks deliberate. Blending is a target setting a client writes and never reads back — there is no field anywhere reporting whether it is on, precisely so nothing can be built on top of it.
Recording-session RPCs — coordination, not control¶
Not control, and grouped apart within VhiControl on purpose. A standard discrete DOF
is a held state: an application asks for a grip and the hand holds a grip.
Collecting regression training data wants the opposite — a control hand that keeps
moving, so the recorded VHI_Control stream sweeps a continuous kinematic range for
EMG windows to be aligned against. Folding that into the discrete vocabulary would have
made "grip" mean "grip, unless someone is recording", which is how a control standard
rots. They live in the same service as control because both drive the same control
hand through the same state machine — splitting them into a second service implied an
independence the target does not have.
So these RPCs carry the two things that belong to a recording session rather than to the thing being controlled:
| RPC | Does |
|---|---|
SetRecordingSession |
Gate VHI's local keyboard so a session has one movement source. |
StartRecordingTrajectory |
Cycle the control hand through a movement, producing a trajectory. |
StopRecordingTrajectory |
Stop it, resting the hand only if one was running. Idempotent. |
GetRecordingSessionState |
State, plus the movement names a trajectory may use. |
A trajectory names a VHI movement, which is fine because this is not standard: a recording trajectory is allowed to be application-specific, so the standard vocabulary never grows a concept ("sweep me for training") that no application controls.
While a trajectory runs it owns the control hand: SetControl's discrete DOFs are
refused with a reason rather than being allowed to interrupt the trajectory a recording
is being aligned against. Continuous DOFs are unaffected — they drive the predicted
hand.
A live vhi.control.pose.* stream owns the hand the same way — any one of the nine is
enough — and refuses the same commands for the same reason. See
What drives the control hand. Both are command-time
refusals carried in ControlAck.rejected: there is no setup call left at which a
client could be told in advance, and none would help, because the answer depends on
what is arriving at the moment of the command.
A negotiation that settled names but not units was not a negotiation
An earlier draft of this service had a Declare RPC, and its reply carried a
continuous_encoding field so a client could ask which convention was in force.
The first end-to-end run agreed on channel names while VHI's continuous path
still decoded legacy units, so a standard +1 arrived as a legacy +1 and the
hand extended when it was told to flex — exactly the class of bug an
encoding-negotiation field invites. There is one encoding now: standard,
unconditionally, on every continuous stream. Nothing is left to negotiate, and
nothing is left to misread — which is why Declare itself is gone too.
VhiControl's control-facing RPCs, at a glance:
| RPC | Purpose |
|---|---|
GetControlManifest |
every address VHI exports with its kind, range and states, plus the vocabulary version to gate on. Call it first |
SetControl |
command one standard frame — continuous values and discrete states |
SweepControl |
drive one DOF across its range and report which bones moved, in signed degrees |
SetPresentation |
target-side blending (appearance only — layer 3 of three) |
SetControl returns a ControlAck { applied, rejected }, where rejected maps a
DOF name to the reason it was refused. A refusal is always named: a DOF that
cannot be rendered is reported, never silently dropped, because a dropped joint
looks exactly like a joint that is working and holding still.
See the gRPC API reference for every message and
field, including the full .proto.