Skip to content

gRPC & WebSocket APIs

galois-edge exposes one gRPC service (galois.edge.v1.EdgeDaemonService) and one WebSocket endpoint. The gRPC service is the canonical contract — everything else (Python SDK, PyVISA backend, cloud control plane) is a wrapper around it. Use these APIs when you want bare-metal access from C++, Rust, Go, JavaScript, or anything that can speak Protobuf-over-HTTP/2.

PortBound onPurpose
:500510.0.0.0 and the tailnet IPExternal gRPC — what cloud clients dial
:50052127.0.0.1Internal gRPC — Python engine binds here
:87650.0.0.0 and the tailnet IPExternal WebSocket
:8766127.0.0.1Internal WebSocket

The Go supervisor proxies 50051 → 50052 and 8765 → 8766. External clients should always target the external ports.

The schema lives at proto/edge/v1/edge.proto. Generate stubs with buf or your language’s grpc tool.

Terminal window
# List the service's RPCs
grpcurl -plaintext localhost:50051 list galois.edge.v1.EdgeDaemonService
# Ping
grpcurl -plaintext localhost:50051 \
galois.edge.v1.EdgeDaemonService/Ping
# List instruments
grpcurl -plaintext localhost:50051 \
galois.edge.v1.EdgeDaemonService/ListInstruments
# Send a SCPI command
grpcurl -plaintext \
-d '{"instrument_id":"GPIB0::24::INSTR","scpi_command":"*IDN?"}' \
localhost:50051 \
galois.edge.v1.EdgeDaemonService/SendCommand

The service groups 23 RPCs into ten categories:

CategoryRPCsPurpose
Core SCPISendCommand, StreamCommandsRaw command pipeline
DiscoveryListInstruments, GetInstrument, ScanInstrumentsInventory
Profile-basedGetCapabilities, ExecuteCommand, ExecuteSequenceTyped commands & multi-step sequences
StreamingStreamMeasurement, StopStreamPeriodic polling
StatusGetStatus, PingHealth
RegistrationRegisterEdge, HeartbeatCloud lifecycle (used by the supervisor)
SweepsStartSweep, GetSweepStatus, StopSweepLong-running ramps with safety
ProfilesDeployProfile, RemoveProfile, ListProfiles, ConnectModbusInstrument, DisconnectInstrumentHot-load drivers
SDK relayProxySDKCallVendor SDK invocation on the edge
UtilityGetWebcamSnapshotLXI cameras / lab webcams

See the Daemon API reference for every method’s request/response shape.

Two RPCs return server streams:

  • StreamCommands — bidirectional, for batched SCPI throughput
  • StreamMeasurement — server-streaming MeasurementDataPoints at a fixed cadence
req = edge_pb2.StreamMeasurementRequest(
stream_id="dmm-volt-1",
instrument_id="USB0::0x2A8D::0x0101::MY54505555::INSTR",
command_name="measure_voltage",
interval_ms=200,
timeout_ms=10_000,
)
for point in stub.StreamMeasurement(req):
print(point.timestamp_ms, point.value, point.unit)

The minimum interval is 100 ms. Stop a running stream early with StopStream(stream_id).

For waveform-type captures (oscilloscope traces, network-analyzer traces), the stream returns MeasurementDataPoint.vector_data — raw bytes plus dtype, x-axis start/increment, and units. Decode with NumPy:

import numpy as np
v = point.vector_data
y = np.frombuffer(v.y_data, dtype=v.y_dtype)
x = v.x_start + np.arange(v.y_length) * v.x_increment

The WebSocket endpoint at ws://<edge>:8765/ws is intended for browser clients that don’t have a gRPC stack. It exposes two modes — a polled stream of named SCPI signals, and a curve-buffer acquisition for waveform downloads.

Each socket supports up to 32 concurrent named streams, each identified by a caller-supplied stream_id. Frames are JSON in both directions; binary curve payloads are base64-encoded inside JSON curve frames.

// Poll mode — periodically query named signals at interval_ms.
{
"action": "subscribe",
"stream_id": "volt-poll-1",
"instrument_id": "TCPIP::192.168.1.42::INSTR",
"mode": "poll",
"interval_ms": 100,
"signals": ["measure_voltage", "measure_current"]
}
// Or send a custom SCPI query each tick.
{
"action": "subscribe",
"stream_id": "custom-scpi-1",
"instrument_id": "...",
"mode": "poll",
"interval_ms": 200,
"scpi_command": "MEAS:VOLT:DC?"
}
// Acquisition mode — drive a curve buffer and download N curves.
{
"action": "subscribe",
"stream_id": "acq-ch0",
"instrument_id": "...",
"mode": "acquisition",
"interval": 1000,
"length": 4096,
"channels": 1,
"curves": [0, 1]
}
// Drop a specific named stream.
{ "action": "unsubscribe", "stream_id": "volt-poll-1" }
// Single-shot SCPI command (no subscription, no stream_id needed).
{ "action": "command", "instrument_id": "...", "scpi_command": "*IDN?" }
// Lifecycle status — each status frame carries the stream_id it applies to.
{ "type": "status", "stream_id": "volt-poll-1", "state": "subscribed" }
{ "type": "status", "stream_id": "acq-ch0", "state": "acquiring" }
{ "type": "status", "stream_id": "acq-ch0", "state": "complete" }
// Poll-mode data — one frame per tick, tagged with stream_id.
{
"type": "data",
"stream_id": "volt-poll-1",
"timestamp": 1715000000.123,
"values": { "measure_voltage": 1.234, "measure_current": 0.05 }
}
// Acquisition-mode curve — base64-encoded int16 samples.
{
"type": "curve",
"stream_id": "acq-ch0",
"curve_id": 0,
"format": "base64",
"dtype": "int16",
"points": 4096,
"data": "AABg/wAA..."
}
// Single-shot command result (response to `action: command`).
{ "type": "command_result", "data": "...", "error": "" }
// Error — stream_id is present when the error is tied to a known stream;
// omitted only for parse-level errors where no valid stream_id is available.
{ "type": "error", "stream_id": "volt-poll-1", "message": "Unknown mode: foo" }
{ "type": "error", "message": "Invalid JSON" }

timestamp is float seconds since the Unix epoch (not milliseconds).

const ws = new WebSocket("ws://lab-pi-01:8765/ws");
ws.onopen = () => {
ws.send(JSON.stringify({
action: "subscribe",
stream_id: "volt-poll-1",
instrument_id: "GPIB0::24::INSTR",
mode: "poll",
interval_ms: 250,
signals: ["measure_current"],
}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "data") {
if (msg.stream_id === "volt-poll-1") plot(msg.timestamp * 1000, msg.values.measure_current);
} else if (msg.type === "status") {
console.log("status:", msg.stream_id, msg.state);
} else if (msg.type === "error") {
console.warn("error:", msg.stream_id, msg.message);
}
};

To decode a curve frame:

const bytes = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0));
const samples = new Int16Array(bytes.buffer); // dtype: "int16"

Local connections (loopback or tailnet) are unauthenticated by default — the network boundary is the security boundary. When you put the daemon behind a non-tailnet ingress, terminate TLS and check API keys at the proxy layer; the daemon itself does not perform key validation on SendCommand.

The cloud control plane authenticates with X-API-Key: glc_… on /api/v1/edges/register and the heartbeat endpoint, but those are talking to the cloud backend, not the daemon.

The proto package is galois.edge.v1. Breaking changes will move to v2 and run alongside v1 for at least one minor release. Field additions inside v1 follow standard proto3 rules — old clients ignore unknown fields.