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 with execution_place=ExecutionPlace.SERVER (e.g. tracking, depth estimation) that augment observations before they reach the controller.
  • Applying START_SERVICE / STOP_SERVICE for the fields it computes, and running skills whose execution_place is SERVER.

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 PULL socket here.

  • Name
    action_sender_port
    Type
    int
    Description

    Port the robot pulls actions from. The relay binds a PUSH socket here.

  • Name
    state_receiver_port
    Type
    int
    Description

    Port the robot publishes observations to. The relay binds a SUB socket here.

  • Name
    state_sender_port
    Type
    int
    Description

    Port controllers subscribe to for observations. The relay binds a PUB socket 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 CalculatedField with ExecutionPlace.SERVER.
  • Project Configs — how deployments supply the connection block to all three roles.

Was this page helpful?