Skip to content

Integrate the daemon (seydd)

seydd is the Seyd robot agent as a daemon. It is the right form factor when the camera already publishes video on a socket and the robot’s own software talks over UDP. You write a TOML file, not code. If your process already holds encoded frames, use the Python, C or Rust SDK instead; see Choose a form factor.

seydd is a host of seyd_core::Agent. The agent owns discovery, the certificate, the QUIC endpoint, sessions, signaling and everything that keeps them alive. The daemon adds what a daemon needs and nothing else:

  • Video in over rtsp:// or rtp://. It depacketizes RTP and splits access units into chunks. It never decodes, re-encodes or inspects the picture.
  • Sensor in over udp://: each datagram becomes one message to every connected pilot.
  • Commands out as UDP datagrams: each driver message on a command channel is written to the channel’s output address, one datagram per message, with nothing prepended. Commands from observers are dropped.
  • Publisher control out as JSON datagrams: what Seyd asks of your encoder, and when sessions start and end.
ROBOTCamerathe publisherSensor processany languageControl softwareactuators · PTZseyddthe agentseydd.tomlkind = videortsp:// or rtp://[publisher_control] udp=video-config · layerrecovery-requestsessionkind = sensorinput = udp://kind = commandoutput = udp://Seyd cloudintroductionsWSSsignallingBrowser<seyd-video>QUICdirect
The daemon on the robot: camera streams in, generic UDP interfaces to your own code, publisher control out to whatever configures the encoder.

Nothing in the daemon knows any vendor. The Hikvision code for the demo robot lives in examples/demo-robot/, outside Seyd, and consumes these interfaces like any customer program would.

No packages are published yet. Build from the repository with the Rust toolchain:

Terminal window
cargo build -p seydd --release
./target/release/seydd --help

The binary is target/release/seydd. Every crate is pure Rust, which is what keeps cross-builds for ARM boards simple.

seydd [--config /etc/seyd/seydd.toml] [--probe-input SECONDS] [enrol --token TOKEN [--signal-url URL]]

--config defaults to /etc/seyd/seydd.toml. --probe-input N opens the video inputs, logs frame statistics for N seconds and exits; it is the fastest way to check a camera URL before involving the cloud.

This is the demo robot’s real configuration, rendered from the repository. Every key is documented in the seydd.toml reference.

examples/demo-robot/seydd.toml
# Copyright 2026 Anton Gravestam
# SPDX-License-Identifier: Apache-2.0
# Seyd demo robot — Hikvision PTZ camera (DEMO.md). Nothing in this file is
# vendor-specific; the Hikvision driver is examples/demo-robot/bridge.py,
# which consumes the generic UDP interfaces declared here.
[agent]
robot_id = "seyd-demo"
signal_url = "wss://seyd-signal-flj7s44j4a-ew.a.run.app/ws"
credential_path = "examples/demo-robot/.robot.key"
qos_profile = "balanced"
max_sessions = 4
[[channel]]
kind = "video"
name = "main"
# Baseline level 3.1 admits 1280x720, the highest rung below. Credentials for
# every layer come from SEYD_RTSP_USER / SEYD_RTSP_PASSWORD.
codec = "avc1.42001f"
fps = 25
# Simulcast (ADR 0008): the camera already encodes both streams, so the agent
# picks which one to relay instead of reconfiguring one and reconnecting.
# Provision the camera to match with examples/demo-robot/setup-simulcast.py.
# Same frame rate on both rungs on purpose — resolution is what degrades.
[[channel.layer]]
name = "low" # 640x480, sub stream
input = "rtsp://192.168.86.237:554/Streaming/Channels/102"
[[channel.layer]]
name = "high" # 1280x720, main stream
input = "rtsp://192.168.86.237:554/Streaming/Channels/101"
activate_above_kbps = 1800
[[channel]]
kind = "command"
name = "ptz"
output = "udp://127.0.0.1:5004"
codec = "json"
[publisher_control]
udp = "127.0.0.1:5003"

Section by section:

  • [agent]: the robot’s identity and policy. robot_id and signal_url are required. credential_path holds the Ed25519 seed, created on first run, and defaults to /var/lib/seyd/robot.key. qos_profile is the ceiling (latency, balanced or quality; see QoS profiles). quic_port (4433) is the UDP port a router forward or pinhole must open.
  • [[channel]]: one per channel, numbered from 1 in file order. That numbering is what the pilot sees. A video channel names its stream with input, or with [[channel.layer]] entries for simulcast as the demo does. A sensor channel names a udp:// input. A command channel names a udp:// output.
  • [publisher_control]: where the daemon posts its JSON control messages.

The section is spelled [[channel]], not [[channels]]. The typo parses as an empty list, and the daemon then warns that the robot will announce nothing.

Credentials in an RTSP URL can come from the environment instead of the file: SEYD_RTSP_USER and SEYD_RTSP_PASSWORD are substituted into an rtsp:// URL that has no userinfo. URLs are redacted in every log line.

A robot must be enrolled before the cloud lets it connect. Enrolment redeems a one-time token, created in the console (Fleet, then Create enrolment token), and registers the public half of the robot’s key. The private seed never leaves the robot, and no user credential is involved: a fleet keeps working while the identity provider is down. See Enrolment and access.

Two ways to redeem it. As a separate step, for an interactive install:

Terminal window
seydd --config /etc/seyd/seydd.toml enrol --token seyd_enr_…

--signal-url overrides the server from the config file. Or hand the token to the daemon and let it enrol on first start, which is the shape a provisioning script wants:

Terminal window
SEYD_ENROLMENT_TOKEN=seyd_enr_… seydd --config /etc/seyd/seydd.toml

The same token can sit in the file as enrolment_token under [agent]; the environment wins when both are set. Prefer the environment, so the secret is never written to disk.

What to expect:

  • Either form creates credential_path if it does not exist, then posts the public key and the token to POST /api/v1/enrol at the address derived from signal_url (wss://host/ws becomes https://host).
  • A token is single-use. A second robot presenting it gets 410 and needs a new one.
  • On a first run, a failed enrolment is fatal. With a credential already present it is only a warning, and the daemon carries on: a spent token left in the environment must not stop an enrolled robot from starting.
  • Enrolment registers whatever key the robot already holds, so a robot that predates enrolment keeps its identity and simply becomes known.

The repository ships no unit file yet. This is a template; adjust the binary path and the user.

# /etc/systemd/system/seydd.service (template)
[Unit]
Description=Seyd robot daemon
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/seydd --config /etc/seyd/seydd.toml
Restart=always
RestartSec=2
Environment=RUST_LOG=info
# SEYD_RTSP_USER, SEYD_RTSP_PASSWORD and, for the first start, SEYD_ENROLMENT_TOKEN
EnvironmentFile=-/etc/seyd/seydd.env
# /var/lib/seyd, the default parent of credential_path = /var/lib/seyd/robot.key
StateDirectory=seyd
[Install]
WantedBy=multi-user.target

The credential at /var/lib/seyd/robot.key is what stays enrolled. Back it up with the machine; lose it and you need a new token. RUST_LOG is the log filter (info, or seyd_core=debug).

Three generic UDP interfaces connect the daemon to your own code. None of them knows a vendor.

Command output (kind = "command", output = "udp://…"). Each command the driver sends is one datagram with the raw payload; for codec = "json" that is the JSON text. Your program binds the port and acts on it. The demo’s PTZ bridge below is one such program.

Sensor input (kind = "sensor", input = "udp://…"). Bind nothing; send datagrams to the address and each one reaches every connected pilot as one sensor message.

Publisher control ([publisher_control] udp = "host:port"). Best-effort JSON, one object per datagram, fire-and-forget. Four message types, quoted from the protocol contract:

{"type": "video-config", "channel": 1, "profile": "balanced",
"maxBitrateKbps": 3000, "latencyBudgetMs": 100, "maxGopMs": 10000,
"preferIntraRefresh": true, "suggestedFps": 0, "reason": "profile"}
{"type": "video-config", "channel": 1, "profile": "balanced",
"maxBitrateKbps": 2250, "latencyBudgetMs": 100, "maxGopMs": 10000,
"preferIntraRefresh": true, "suggestedFps": 0, "reason": "abr-down"}
{"type": "recovery-request", "channel": 1, "kind": "idr", "reason": "pilot-loss"}
{"type": "layer", "channel": 1, "layer": 0, "name": "low", "reason": "down"}
{"type": "session", "state": "started"|"ended", "session_id": "…", "role": "driver"|"observer", "sessions": 1}
  • video-config is Seyd’s targets for the encoder. It is sent once at start (reason: "profile"), when the profile changes ("pilot-request"), and whenever the closed-loop controller moves the request inside the ceiling ("abr-down", "abr-up"). The publisher decides resolution, preset and VBV; Seyd states only what the transport can observe.
  • recovery-request means a pilot needs a recovery point now. Answer it. Keyframes are on demand (ADR 0009): with nobody answering, a joining pilot waits up to maxGopMs for a first picture.
  • layer says the relayed simulcast layer changed. Force a keyframe on the named stream if you can; Seyd switches on that layer’s next keyframe either way.
  • session lets robot-side code park actuators when the last driver leaves.

The full semantics, including the recovery ladder and the rate limit, are on The publisher contract.

The demo robot is a Hikvision PTZ camera. examples/demo-robot/bridge.py is the customer-side program: it binds the command port and the publisher-control port, and speaks the camera’s ISAPI. It is what answering publisher control looks like in practice.

examples/demo-robot/bridge.py
examples/demo-robot/bridge.py
#!/usr/bin/env python3
# Copyright 2026 Anton Gravestam
# SPDX-License-Identifier: Apache-2.0
"""
Demo robot bridge: the robot-side program that makes the Hikvision PTZ camera a
Seyd robot. It consumes seydd's two generic UDP interfaces
(docs/protocol/seydd.md) and speaks ISAPI to the camera:
* command channel `ptz` (seydd → udp://127.0.0.1:5004): JSON
{"pan": -100..100, "tilt": -100..100, "zoom": -100..100, "ts": ms}
or {"home": true}. Velocity, latest-value-wins, momentary windows.
* publisher control (seydd → udp://127.0.0.1:5003): JSON
{"type": "recovery-request", ...} → ask the encoder for an IDR;
{"type": "session", "state": "ended", "sessions": 0} → stop and park;
{"type": "layer", "name": "low"|"high", "reason": "up"|"down", ...} → ask
that layer's stream for an immediate IDR, so Seyd's switch lands at once;
{"type": "video-config", "maxBitrateKbps": N, "maxGopMs": G,
"suggestedFps": F, ...} → the camera's VBR upper cap for stream 101 is
set to N (seydd's ABR lowers it under loss or latency and raises it back),
its GOP to G converted to frames at the stream's frame rate (ADR 0009: a
long GOP, with IDRs on demand through recovery-request — this camera has
no intra refresh, so `preferIntraRefresh` is noted and ignored), and its
frame-rate cap to F when the ABR is pinned at its bitrate floor, back to
the configured baseline when F is 0; at most one change per 2 s per
setting.
Nothing here is Seyd; it is what a customer writes for their own actuators.
Credentials come from CAMERA_USER / CAMERA_PASSWORD in the environment.
"""
import argparse
import asyncio
import json
import logging
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from hikvision import CameraControl # noqa: E402
log = logging.getLogger('bridge')
class _Udp(asyncio.DatagramProtocol):
def __init__(self, on_msg):
self.on_msg = on_msg
def datagram_received(self, data, addr):
try:
self.on_msg(json.loads(data.decode('utf-8')))
except (ValueError, UnicodeDecodeError):
log.debug('non-JSON datagram from %s', addr)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument('--camera-ip', default=os.getenv('CAMERA_IP', '192.168.86.237'))
ap.add_argument('--camera-channel', type=int, default=1)
ap.add_argument('--stream-channel', type=int, default=101)
ap.add_argument('--layer-channels', default='low=102,high=101',
help='simulcast layer name -> ISAPI stream channel, for forcing an IDR on a switch')
ap.add_argument('--ptz-home', default='0,1800,10', help='elevation,azimuth,zoom in ISAPI units')
ap.add_argument('--command-port', type=int, default=5004)
ap.add_argument('--control-port', type=int, default=5003)
ap.add_argument('--max-command-age-ms', type=int, default=1500)
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
user, password = os.getenv('CAMERA_USER', 'admin'), os.getenv('CAMERA_PASSWORD', '')
if not password:
log.error('CAMERA_PASSWORD not set (see .env.local)')
return 2
home = tuple(int(x) for x in args.ptz_home.split(','))
cam = CameraControl(args.camera_ip, user, password, channel=args.camera_channel, home=home)
await cam.start()
loop = asyncio.get_running_loop()
stats = {'ptz': 0, 'stale': 0, 'keyframes': 0, 'bitrate_changes': 0, 'gop_changes': 0, 'fps_changes': 0, 'layer_switches': 0}
def coalescer(apply, stat_key):
"""
At most one ISAPI write per 2 s, latest value wins. The camera is far
slower than the ABR's once-a-second decisions, so submitting every one
of them would queue writes faster than they drain.
"""
st = {'last': None, 'pending': None, 'at': 0.0}
async def drain():
while st['pending'] is not None:
wait = 2.0 - (time.time() - st['at'])
if wait > 0:
await asyncio.sleep(wait)
value, st['pending'] = st['pending'], None
if value == st['last']:
continue
if await apply(value):
st['last'] = value
stats[stat_key] += 1
st['at'] = time.time()
def submit(value):
idle = st['pending'] is None
st['pending'] = value
if idle:
asyncio.ensure_future(drain())
return submit
submit_bitrate = coalescer(
lambda kbps: cam.set_bitrate_cap(kbps, args.stream_channel), 'bitrate_changes')
submit_fps = coalescer(
lambda fps: cam.set_max_frame_rate(fps, args.stream_channel), 'fps_changes')
submit_gop = coalescer(
lambda frames: cam.set_gop(frames, args.stream_channel), 'gop_changes')
# The frame rate to come back to once the link recovers. Read from the
# camera rather than assumed, so the demo restores whatever the operator
# actually configured.
baseline_fps = await cam.get_max_frame_rate(args.stream_channel) or 25
log.info('baseline frame rate: %d fps (channel %d)', baseline_fps, args.stream_channel)
layer_channels = {}
for pair in filter(None, (p.strip() for p in args.layer_channels.split(','))):
name, _, ch = pair.partition('=')
layer_channels[name] = int(ch)
def on_command(m):
if m.get('home'):
asyncio.ensure_future(cam.go_home())
return
ts = m.get('ts')
if isinstance(ts, (int, float)) and abs(time.time() * 1000 - ts) > args.max_command_age_ms:
# A command that sat in a queue somewhere is not the operator's
# current intent; moving on it is how a camera ends up at its stop.
stats['stale'] += 1
return
cam.move(int(m.get('pan', 0)), int(m.get('tilt', 0)), int(m.get('zoom', 0)))
stats['ptz'] += 1
def on_control(m):
t = m.get('type')
if t == 'recovery-request':
stats['keyframes'] += 1
asyncio.ensure_future(cam.request_keyframe(args.stream_channel))
elif t == 'session':
log.info('session %s (%s) — %s active', m.get('state'), m.get('role', '?'), m.get('sessions'))
if m.get('state') == 'ended' and m.get('sessions', 0) == 0:
asyncio.ensure_future(park(cam))
elif t == 'layer':
# Seyd switches on the new layer's next keyframe. Asking the camera
# for one now turns "next keyframe" from up to a GOP into
# immediately, which is the whole point of simulcast over a
# reconnect: the switch costs one frame, not a reconnection.
name = m.get('name')
ch = layer_channels.get(name)
log.info('layer -> %s (%s)%s', name, m.get('reason'),
'' if ch else ' [no ISAPI channel mapped, switching on its own keyframe]')
if ch:
stats['layer_switches'] += 1
asyncio.ensure_future(cam.request_keyframe(ch))
elif t == 'video-config':
kbps = m.get('maxBitrateKbps')
fps = m.get('suggestedFps')
gop_ms = m.get('maxGopMs')
log.info('video-config: %s kbps, gop %s ms, fps %s (%s)%s', kbps, gop_ms, fps or '-', m.get('reason'),
' [intra refresh preferred; not available on this camera, using a long GOP]'
if m.get('preferIntraRefresh') and m.get('reason') == 'profile' else '')
if isinstance(kbps, (int, float)) and kbps > 0:
submit_bitrate(int(kbps))
# The GOP ceiling is the publisher's to meet however it can. This
# encoder has no intra refresh, so it takes the other route ADR 0009
# names: the longest GOP the profile allows, with IDRs supplied on
# demand through recovery-request above. The baseline frame rate,
# not the current cap, converts ms to frames, so a temporary fps cut
# does not also shorten the GOP.
if isinstance(gop_ms, (int, float)) and gop_ms > 0:
submit_gop(max(1, round(gop_ms * baseline_fps / 1000)))
# suggestedFps is 0 whenever the controller is not pinned at its
# bitrate floor, which is the signal to restore full cadence. Frame
# rate is the last rung of the ladder precisely because it is a
# latency term for the pilot: 25 -> 15 fps stretches the interval
# between frames from 40 ms to 67 ms, so the operator waits that
# much longer to see the result of their own input.
if isinstance(fps, (int, float)):
submit_fps(int(fps) if fps > 0 else baseline_fps)
await loop.create_datagram_endpoint(lambda: _Udp(on_command), local_addr=('127.0.0.1', args.command_port))
await loop.create_datagram_endpoint(lambda: _Udp(on_control), local_addr=('127.0.0.1', args.control_port))
log.info('bridge up: commands on :%d, publisher control on :%d, camera %s', args.command_port, args.control_port, args.camera_ip)
try:
while True:
await asyncio.sleep(30)
log.info('stats %s camera=%s', stats, cam.stats)
finally:
await cam.stop()
async def park(cam):
cam.move(0, 0, 0)
await asyncio.sleep(0.1)
await cam.go_home()
if __name__ == '__main__':
sys.exit(asyncio.run(main()) or 0)

What it does, in the order the messages arrive:

  • Commands (on_command): a JSON velocity for pan, tilt and zoom, latest value wins. A command older than --max-command-age-ms is dropped as stale: a command that sat in a queue is not the operator’s current intent, and moving on it is how a camera ends up at its stop.
  • recovery-request: asks the camera for an IDR on the stream channel with requestKeyFrame. This camera has no intra refresh, so every kind is answered with an IDR, which is the allowed fallback.
  • session with state: "ended" and sessions: 0: stops movement and returns the camera to its home position.
  • layer: maps the layer name to an ISAPI stream channel (--layer-channels low=102,high=101) and asks that stream for an IDR, so the switch lands on the next frame rather than up to a GOP later.
  • video-config: writes maxBitrateKbps to the stream’s VBR upper cap, converts maxGopMs to frames at the camera’s baseline frame rate and writes it as the GOP, and applies suggestedFps as a frame-rate cap when it is non-zero, restoring the baseline when it is 0. preferIntraRefresh is logged and ignored, because the camera cannot do it.
  • Coalescing: at most one ISAPI write per two seconds per setting, latest value wins. The controller decides once a second and the camera is slower than that, so every decision applied verbatim would queue writes faster than they drain.

Nothing in the bridge is Seyd. It is what you write for your own actuators and encoder.

Start the daemon and read the log. In order you should see seydd starting, one candidate line per discovered address, certificate, robot identity, and then for each input rtsp input playing, rtp input listening or sensor input listening. When a pilot connects you get session started with the role and the path, and session ended with a reason when it leaves. abr lines report the controller’s decisions and simulcast lines a layer change. signal denied means the cloud refused the robot; see below.

Then drive it from the pilot page at /pilot/?robot=<id> and press S for the HUD, which shows the path, the profile, loss and the layer. Latency and the HUD explains every field.

For an assertion rather than a look, tools/seyd-smoke.py drives the real pilot page in headless Chrome and checks that the session connects, frames keep decoding, sensor messages arrive and a PTZ key press reaches the command output:

Terminal window
tools/.venv/bin/python3 tools/seyd-smoke.py --robot <id> --page <pilot url> --signal <ws url> --command-port 5004

--no-sensor for a robot without a sensor channel; --camera-ip <ip> verifies that PTZ moved the real demo camera; --query loss=0.05 injects loss.

codec is a WebCodecs string and does double duty: the daemon uses it to pick the RTP framing, and it travels unchanged to the browser’s decoder.

Codec codec value Ingest Browser
H.264 avc1.… RTSP and RTP Everywhere
H.265 hev1.… / hvc1.… RTSP and RTP Only where the machine has a hardware HEVC decoder
Motion JPEG mjpeg RTSP only Everywhere

Two caveats worth testing before promising them to a customer. H.265 decodes on a hardware-accelerated desktop Chrome and not in the same build headless (measured on Chrome 152); the pilot now says so on screen when it cannot decode. MJPEG costs about an order of magnitude more bandwidth than H.264 for the same picture and the daemon warns when a channel uses it; it is RTSP only, because a bare rtp:// MJPEG stream has no JPEG headers to rebuild from, and the daemon refuses the combination with an explanation. The encoder side of all this is on Encoder setup.

relay = true under [agent] (the default) lets the robot serve a session through the cloud relay when the pilot could not connect directly (ADR 0010). The pilot always races its direct candidates first and asks for the relay only after that fails, and its HUD, status line and guidance say the session is relayed and why. Set relay = false to refuse relayed sessions altogether; such pilots get a diagnosis instead. Never expect the relay to be the first choice, and never hide it: it is metered and priced separately. What the direct race needs from the network is on Reachability.

signal denied in the log, or SEYD_SIGNAL_DENIED in an SDK, carries a reason:

  • unknown-robot: the id was never enrolled. Redeem a token as above.
  • key-mismatch: the id is enrolled with a different key. Another agent is using the id, or the credential file was replaced. Delete the robot in the console and enrol again.

In development, SEYD_DEV_OPEN_ENROLMENT=1 on the signal server trusts an unknown robot on first connection instead. Such a robot has no org and is visible only to an unauthenticated console.