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.
Listening sockets
Section titled “Listening sockets”| Port | Bound on | Purpose |
|---|---|---|
:50051 | 0.0.0.0 and the tailnet IP | External gRPC — what cloud clients dial |
:50052 | 127.0.0.1 | Internal gRPC — Python engine binds here |
:8765 | 0.0.0.0 and the tailnet IP | External WebSocket |
:8766 | 127.0.0.1 | Internal WebSocket |
The Go supervisor proxies 50051 → 50052 and 8765 → 8766. External clients should always target the external ports.
gRPC: connecting
Section titled “gRPC: connecting”The schema lives at proto/edge/v1/edge.proto. Generate stubs with buf or your language’s grpc tool.
# List the service's RPCsgrpcurl -plaintext localhost:50051 list galois.edge.v1.EdgeDaemonService
# Pinggrpcurl -plaintext localhost:50051 \ galois.edge.v1.EdgeDaemonService/Ping
# List instrumentsgrpcurl -plaintext localhost:50051 \ galois.edge.v1.EdgeDaemonService/ListInstruments
# Send a SCPI commandgrpcurl -plaintext \ -d '{"instrument_id":"GPIB0::24::INSTR","scpi_command":"*IDN?"}' \ localhost:50051 \ galois.edge.v1.EdgeDaemonService/SendCommandimport grpcfrom galois_edge import edge_pb2, edge_pb2_grpc
channel = grpc.insecure_channel("localhost:50051")stub = edge_pb2_grpc.EdgeDaemonServiceStub(channel)
reply = stub.Ping(edge_pb2.PingRequest())print(reply.timestamp.ToDatetime())
resp = stub.SendCommand(edge_pb2.SendCommandRequest( instrument_id="GPIB0::24::INSTR", scpi_command="*IDN?",))print(resp.response)package main
import ( "context" "fmt" "log"
"google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" edgev1 "github.com/galois-labs/daemon/proto/gen/go/edge/v1")
func main() { conn, err := grpc.NewClient( "localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatal(err) } defer conn.Close()
client := edgev1.NewEdgeDaemonServiceClient(conn) resp, err := client.SendCommand(context.Background(), &edgev1.SendCommandRequest{ InstrumentId: "GPIB0::24::INSTR", ScpiCommand: "*IDN?", }) if err != nil { log.Fatal(err) } fmt.Println(resp.GetResponse())}RPC categories
Section titled “RPC categories”The service groups 23 RPCs into ten categories:
| Category | RPCs | Purpose |
|---|---|---|
| Core SCPI | SendCommand, StreamCommands | Raw command pipeline |
| Discovery | ListInstruments, GetInstrument, ScanInstruments | Inventory |
| Profile-based | GetCapabilities, ExecuteCommand, ExecuteSequence | Typed commands & multi-step sequences |
| Streaming | StreamMeasurement, StopStream | Periodic polling |
| Status | GetStatus, Ping | Health |
| Registration | RegisterEdge, Heartbeat | Cloud lifecycle (used by the supervisor) |
| Sweeps | StartSweep, GetSweepStatus, StopSweep | Long-running ramps with safety |
| Profiles | DeployProfile, RemoveProfile, ListProfiles, ConnectModbusInstrument, DisconnectInstrument | Hot-load drivers |
| SDK relay | ProxySDKCall | Vendor SDK invocation on the edge |
| Utility | GetWebcamSnapshot | LXI cameras / lab webcams |
See the Daemon API reference for every method’s request/response shape.
Streaming patterns
Section titled “Streaming patterns”Two RPCs return server streams:
StreamCommands— bidirectional, for batched SCPI throughputStreamMeasurement— server-streamingMeasurementDataPoints 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 npv = point.vector_datay = np.frombuffer(v.y_data, dtype=v.y_dtype)x = v.x_start + np.arange(v.y_length) * v.x_incrementWebSocket API
Section titled “WebSocket API”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.
Client → server
Section titled “Client → server”// 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?" }Server → client
Section titled “Server → client”// 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).
Browser example
Section titled “Browser example”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"Authentication
Section titled “Authentication”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.
Versioning & compatibility
Section titled “Versioning & compatibility”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.
What’s next
Section titled “What’s next”- Daemon API reference — full RPC catalogue
- Python SDK — higher-level wrapper if you don’t need raw protobuf
- Instrument Profiles — what
ExecuteCommandactually executes