Server
The RelayServer is the middle node in Solarian's three-node architecture. It runs anywhere reachable from both ends and is responsible for:
- Receiving Actions from controllers and forwarding them to the robot.
- Receiving Observations from the robot and forwarding them to all subscribed controllers.
- Optionally running server-side observation processors —
CalculatedFields withexecution_place=ExecutionPlace.SERVER(e.g. tracking, depth estimation) that augment observations before they reach the controller. - Applying
START_SERVICE/STOP_SERVICEfor the fields it computes, and running skills whoseexecution_placeisSERVER.
Defined in solarian/core/server.py.
Why a relay?
Decoupling the controller from the robot lets you:
- Run them on different networks or machines without each side having to know the other's address.
- Drop, swap, or add controllers (teleop, policy, recorder) without touching the robot loop.
- Compute expensive observation enrichments (vision models, tracking) on a beefier machine than the robot itself.
- Provide a single, stable bind address that all participants connect to.
Defining a Server
The framework provides RelayServer. Most projects subclass it and leave it empty:
from solarian.core import RelayServer
class MyServer(RelayServer):
pass
Override methods only when you need to add custom behavior on top of the relay (custom event handling, logging, metrics).
Registering your Server class
Set SERVER_CLASS in your project's settings.py:
SERVER_CLASS = 'myrobot.server.MyServer'
solarian run-server reads this, imports the class, and instantiates it with the parsed ServerConfig from conf/server.yaml.
ZMQ topology
The relay opens four ZeroMQ sockets and binds all of them. The robot and the controllers connect to the relay — never to each other.
Controller (PUSH) ──────► action_receiver_port ┐
│
RelayServer ──► action_sender_port (PUSH) ─► Robot (PULL)
│
Controller (SUB) ◄────── state_sender_port ◄──┘
▲
RelayServer ◄── state_receiver_port ◄─┘ Robot (PUB)
The relay binds to tcp://*:<port> on all four ports. The robot and controllers each connect to tcp://<server_ip>:<port> using their respective port from the ConnectionConfig.
- Name
action_receiver_port- Type
- int
- Description
Port the controller pushes actions to. The relay binds a
PULLsocket here.
- Name
action_sender_port- Type
- int
- Description
Port the robot pulls actions from. The relay binds a
PUSHsocket here.
- Name
state_receiver_port- Type
- int
- Description
Port the robot publishes observations to. The relay binds a
SUBsocket here.
- Name
state_sender_port- Type
- int
- Description
Port controllers subscribe to for observations. The relay binds a
PUBsocket here.
The relay's sockets are not ZMQ_CONFLATE. Each observation message carries only the modules that advanced since the last one, and a server-hosted skill can send several actions per tick, so keeping only the newest message would drop data. Instead each hop has a small high-water mark (64 messages toward and from the robot, 8 toward each controller): a slow reader loses the oldest messages rather than building an unbounded backlog. The controller's own action socket is still CONFLATE, so a stalled relay never blocks the controller's loop.
Configuration
Default conf/server.yaml:
connection: ${deployment.connection}
img_compression_quality: 90
# Run skills whose START_SKILL names execution_place "server" on the relay.
host_skills: true
conf/robot.yaml and conf/controller.yaml say the same thing, so all three roles read one connection block from the active deployment at conf/deployment/localhost.yaml:
connection:
ip: 127.0.0.1
action_sender_port: 5555
action_receiver_port: 5556
state_sender_port: 5557
state_receiver_port: 5558
clock_sync_port: 5559
For a multi-machine setup, add conf/deployment/lab.yaml with the relay machine's IP and launch each of the three nodes with it:
solarian run-server --deployment lab # on the relay machine
solarian run-robot --deployment lab # on the robot
solarian run-controller --deployment lab # on the laptop
All three nodes need to agree on the same four port numbers and the same relay IP, which is why they share one deployment file rather than each carrying its own copy.
Server-side observation processors
CalculatedField declarations on your Observation/ModuleObs schema can specify where they run via execution_place=ExecutionPlace.SERVER, or leave it out and set an execution_place: key in the field's conf, so a deployment can move it without editing the schema. When the relay starts, it instantiates one processor per server field via Observation.create_processors(ExecutionPlace.SERVER) and runs them in the state-relay loop:
from solarian.core.observation import CalculatedField, ModuleObs, SensorField
from solarian.core.data import BoundingBoxData
from solarian_sam2 import Sam2BboxProcessor
class CameraObs(ModuleObs):
image = SensorField(MyRobot.cam.camera.rgb_image)
bbox = CalculatedField(
inputs=[MyRobot.cam.camera.rgb_image],
processor=Sam2BboxProcessor,
output=BoundingBoxData(),
conf="conf/services/bbox_tracker.yaml", # execution_place: server
)
The processor receives every incoming observation and computes its output. The relay patches only the computed fields into the message before it goes out to controllers, so the robot's JPEGs are forwarded as they arrived. This is how Solarian keeps heavy CV workloads off the robot while still exposing them as first-class observation fields.
Starting and stopping services
A processor that needs a command to begin, such as a tracker that needs a target, is started by field name from the controller:
self.start_service("right_wrist_camera", "bbox", point=(320, 240))
self.stop_service("right_wrist_camera", "bbox")
These send START_SERVICE / STOP_SERVICE actions. The relay applies one itself when it computes that field and forwards it to the robot otherwise, so the call is the same wherever the field runs. See Services.
Server-hosted skills
A skill whose execution_place is SERVER runs on the relay rather than the robot. The controller's START_SKILL names the place; the relay intercepts it, runs the skill against the observations it relays (including the fields it computes), and sends the skill's actions to the robot with the controller's. Its status rows reach the controller the same way a robot skill's do. Set host_skills: false to forward every START_SKILL to the robot instead.
Running a skill on the relay keeps it beside the server-side processors it reads; running it on the robot avoids the relay hop for every action. Which is faster depends on the link, so the placement is configuration: see Skills.
Running the server
From the project directory on whichever machine you've designated as the relay:
solarian run-server
The relay binds the four ports and starts the action-relay and state-relay threads. Logs print one line per forwarded message at debug level.
See also
- Robots — the node connecting to the action-sender and state-receiver ports.
- Controllers — the node connecting to the action-receiver and state-sender ports.
- Observations — including
CalculatedFieldwithExecutionPlace.SERVER. - Project Configs — how deployments supply the connection block to all three roles.