Custom Device Schema Guide
Use this guide when you want RemoteRF to support a device that does not already have built-in support.
The point of this page is to show how to add RemoteRF support for a device that is not already supported. If the hardware can be controlled from Python on the server or host machine, you can usually support it by writing a small schema wrapper.
The important idea is that you do not ship raw server code to users. The server turns your schema into IDL JSON, which stands for Interface Description Language, and the client uses that interface description to generate a local remote driver that looks like a normal Python device class from the end user's perspective.
Overview
RemoteRF has two different wrappers in the device pipeline:
Lives in ~/.config/remoterf/drivers/. It imports the vendor library, opens the physical device in make_device(**kwargs), and exposes selected methods with @idl_expose.
Lives under the client package after generation. It converts ordinary-looking property access and method calls into RemoteRF RPC calls such as Pluto:rx_lo:GET or Pluto:rx:CALL0.
For a new device type, the usual workflow is:
- Make sure the server or host machine can control the hardware locally from Python.
- Create one schema file in
~/.config/remoterf/drivers/. - Add one or more matching entries to
~/.config/remoterf/devices.yml. - Restart
serverrforhostrfso the schema and device inventory are reloaded. - Reserve the device from a client. The client fetches the IDL, meaning the device’s interface description, and generates the matching remote driver.
devices.yml says which physical device instances exist.
The schema says what that device type can do remotely.
What IDL Means
IDL means Interface Description Language. In RemoteRF, it is not a separate language you have to learn. It is a small JSON description that the server generates from your decorated Python schema.
The IDL answers a few simple questions:
| Question | IDL field | Example |
|---|---|---|
| What device type is this? | device_type |
"pluto" |
| Which values can clients read? | getters |
get_rx_lo becomes sdr.rx_lo |
| Which values can clients change? | setters |
set_rx_lo becomes sdr.rx_lo = value |
| Which actions can clients call? | calls |
call_rx becomes sdr.rx() |
| Has the interface changed? | schema_hash |
The client refreshes its generated wrapper when this hash changes. |
So when this page says “IDL”, read it as “the generated description of the remote device API.” Your job is still just to write a Python schema with decorators. RemoteRF turns that into IDL automatically.
Pipeline
The schema wrapper is the bridge between local hardware control and remote client code. Only the machine that owns the hardware needs manual changes: the RemoteRF server for server-attached devices, or a RemoteRF host for host-attached devices. Client machines do not need hand-written driver changes.
pluto_schema.py
Do this on the RemoteRF server or host that owns the hardware. The schema imports the vendor API and marks safe operations with decorators.
The server or host loader imports every schema file from its config. @idl_register("pluto") adds the class to the registry.
The server or host devices.yml selects the schema by device_type. That same machine calls make_device(**init).
The schema introspects exposed getters, setters, and calls, then builds an Interface Description Language document with a schema_hash.
No manual client code changes are needed. The client calls IDL:get_drivers, receives the interface description, and generates the local remote wrapper automatically.
sdr.rx_lo, sdr.rx(), and similar calls become RPCs that dispatch back into the schema.
The runtime translation looks like this:
| Client code | Generated RPC | Server dispatch |
|---|---|---|
sdr.rx_lo |
Pluto:rx_lo:GET |
dispatch("get_rx_lo", {}) |
sdr.rx_lo = 2_400_000_000 |
Pluto:rx_lo:SET |
dispatch("set_rx_lo", {"value": 2400000000}) |
samples = sdr.rx() |
Pluto:rx:CALL0 |
dispatch("call_rx", {}) |
sdr.tx(samples) |
Pluto:tx:CALL1 |
dispatch("call_tx", {"value": samples}) |
Why the names matter:
the client generator strips get_, set_, and call_ from your exposed method names. That is how get_rx_lo becomes the client property rx_lo, and call_rx becomes the client method rx().
Supporting New Devices
A device is a good fit for this schema path when:
The device already has a Python library, command wrapper, or local control object you can call from the machine that owns the hardware.
The device can be opened from stable init data such as serial, device_index, ip, or uri.
The operations fit a request/response model: read a value, set a value, capture samples, transmit samples, reset, calibrate, enumerate, and similar actions.
Arguments and return values are simple values or arrays: int, float, str, bool, lists, tuples, and real or complex NumPy arrays.
The current generated client wrapper supports three exported operation shapes. Pick the shape based on how you want the client API to feel:
Expose a Readable Setting or Status Value
Use a getter when the client should be able to inspect device state without changing it. Good examples are frequency, sample rate, gain, buffer size, serial number, firmware version, and current mode.
@idl_expose(kind="get")
def get_frequency(self):
return self.device.frequencyGenerated client API: dev.frequency
Naming rule: get_frequency becomes the readable client property frequency.
Expose a Writable Runtime Control
Use a setter when the client should be able to change one device setting during a reservation. The schema method should accept one value, validate or convert it if needed, then write it to the vendor device object.
@idl_expose(kind="set")
def set_frequency(self, value):
self.device.frequency = int(value)Generated client API: dev.frequency = value
Naming rule: pair set_frequency with get_frequency when the property should be both readable and writable.
Expose an Action
Use a call when the operation is more like a command than a property. Good examples are capture samples, transmit samples, reset, calibrate, enumerate devices, clear a buffer, or run a short measurement.
@idl_expose(kind="call")
def call_capture(self):
return self.device.capture()Generated client API: dev.capture()
Naming rule: call_capture becomes the client method capture().
Prefer exposed calls with no client arguments. If a call truly needs input, the generated client currently supports one argument, passed through the schema as value. Multi-argument call methods are not a good fit for the current generated wrapper. Use setters for persistent configuration, or pass one small configuration object when a single call must carry several values.
Imports and Decorators
Every schema must start with the required RemoteRF import line, followed by whatever vendor libraries your device needs:
# Required for every RemoteRF schema wrapper.
from remoteRF_server.common.idl import DeviceSchema, idl_expose, idl_register
# Device-specific imports. Replace these with whatever controls your hardware.
# import vendor_device_api
# import numpy as npThe first line is the RemoteRF schema toolkit. The other imports are device-specific. A dummy test device may not need any vendor imports. A HackRF schema may import pyhackrf2 and numpy, while a power meter may import pyvisa.
DeviceSchema
Subclass this once per device type. It gives RemoteRF a standard place to bind the live hardware object, inspect exposed methods, build the interface description, compute schema_hash, and dispatch remote calls.
class DummySdrSchema(DeviceSchema):
device_type = "dummy_sdr"
driver_version = "0.0.1"@idl_register(...)
Use this on the schema class. It registers the device type when the server or host imports the schema file. The string must match device_type in devices.yml.
@idl_register("dummy_sdr")
class DummySdrSchema(DeviceSchema):
device_type = "dummy_sdr"@idl_expose(...)
Use this on each wrapper method that clients should access. The kind tells the client generator whether to create a readable property, writable property, or callable method.
@idl_expose(kind="get")
def get_frequency(self):
return self.device.frequencyAvoid opening hardware at import time. The server or host imports every schema file during startup, so imports should only load code. Device discovery and connection should happen inside make_device(**kwargs).
Register the Device Type
@idl_register("dummy_sdr")
class DummySdrSchema(DeviceSchema):
device_type = "dummy_sdr"
driver_version = "0.0.1"device_type field in devices.yml.
devices:
- device_id: 1
device_type: dummy_sdr
name: Dummy SDR A
init:
label: "bench-sdr-1"For new devices, prefer a lowercase Python-friendly type name such as spectrum_meter, hackrf, or dummy_sdr. Avoid spaces, hyphens, and punctuation, because the client generator uses the type as a Python package directory.
Open the Device
make_device(**kwargs)
make_device is the factory method that turns the init mapping from devices.yml into a live device object. The init block can contain any number of arguments, as long as it contains at least one key. RemoteRF unpacks that mapping into make_device(**kwargs).
The method name must be exactly make_device. RemoteRF looks for that name when it loads a schema. If you want a device-specific helper name such as make_dummy_sdr or connect_dummy_sdr, define it separately and call it from make_device.
Each key under init becomes one entry in kwargs. Your make_device implementation decides how those values map into the vendor library, constructor, local helper, or command wrapper.
@staticmethod
def make_device(**kwargs):
return connect_dummy_sdr(
serial=kwargs["serial"],
sample_rate=kwargs["sample_rate"],
center_frequency=kwargs["center_frequency"],
)If devices.yml says:
init:
serial: "sdr-001"
sample_rate: 1000000
center_frequency: 2400000000then RemoteRF calls:
DummySdrSchema.make_device(
serial="sdr-001",
sample_rate=1000000,
center_frequency=2400000000,
)Return the live vendor object when the connection works. Return None when the device cannot be opened, so the device is marked offline instead of exposing a broken remote API.
Expose Client-Facing Operations
Use @idl_expose(...) only on wrapper methods that clients should be allowed to use remotely. Methods without this decorator remain server-side implementation details.
@idl_expose(kind="get", doc="Center frequency in Hz.")
def get_frequency(self):
return self.device.frequency
@idl_expose(kind="set")
def set_frequency(self, value):
self.device.frequency = int(value)
@idl_expose(kind="call")
def call_capture(self):
return self.device.capture()The naming convention controls the generated client API:
get_frequency(self)becomes readable asdev.frequencyset_frequency(self, value)becomes writable asdev.frequency = valuecall_capture(self)becomes callable asdev.capture()call_reset(self)becomes callable asdev.reset()
Bare @idl_expose is also allowed and means kind="call":
@idl_expose
def call_reset(self):
"""Reset the device state."""
self.device.reset()
return 0The optional doc="..." string becomes part of the generated interface description. If you omit it, the function docstring is used instead.
Minimum Required Schema
At minimum, a custom schema needs the RemoteRF imports, one registered DeviceSchema subclass, matching type metadata, and a make_device(**kwargs) factory. Add @idl_expose(...) methods for each operation the generated client should be able to use.
from remoteRF_server.common.idl import DeviceSchema, idl_expose, idl_register
@idl_register("dummy_sdr")
class DummySdrSchema(DeviceSchema):
device_type = "dummy_sdr"
driver_version = "0.0.1"
@staticmethod
def make_device(**kwargs):
return connect_dummy_sdr(serial=kwargs["serial"])
@idl_expose(kind="get")
def get_frequency(self):
return self.device.frequencyImport DeviceSchema, idl_register, and idl_expose from remoteRF_server.common.idl.
Add @idl_register("...") above a class that inherits from DeviceSchema.
Set device_type to the same value used by @idl_register(...) and devices.yml.
Set driver_version so the generated client can report which wrapper version it came from.
Define make_device(**kwargs), named exactly that, returning the live device object or None.
Add one or more @idl_expose(...) methods for the operations clients should be able to use.
Client-Side Import
Clients do not copy the server schema. After the device is reserved, RemoteRF fetches the IDL and writes a generated driver under the installed client package:
remoteRF/drivers/<device_type>/<device_type>_remote.pyFor a schema registered as dummy_sdr, client code imports the generated package by the same device_type name:
TOKEN = "reservation-token-from-remoterf"
# Usually created automatically when the reservation is made.
# Use this if the driver is missing or this script runs on another client machine.
from remoteRF.drivers import ensure_driver
ensure_driver(token=TOKEN)
from remoteRF.drivers.dummy_sdr import *
dev = adi.Dummy_sdr(TOKEN)
print(dev.frequency)The important mapping is: device_type: dummy_sdr becomes remoteRF.drivers.dummy_sdr. The generated class name is based on the device type.
Example Schema Rundown
The ADALM-Pluto schema is a compact example because it wraps an existing Python object from pyadi-iio. Locally, you would use adi.Pluto(...) directly. In RemoteRF, the schema opens adi.Pluto(...) on the server and exposes selected attributes and methods to clients.
from remoteRF_server.common.idl import DeviceSchema, idl_register, idl_expose
import subprocess
import re
import adiDeviceSchema, idl_register, and idl_expose are generic RemoteRF pieces. adi, subprocess, and re are Pluto-specific support code.
def connect_pluto(*, serial: str):
out = subprocess.check_output(["iio_info", "-s"], text=True)
usb = find_usb_uri_for_serial(out, serial)
return adi.Pluto(f"usb:{usb}")The helper scans iio_info -s, finds the USB URI for the requested serial, and returns a real adi.Pluto object. If it fails, the full sample returns None.
@idl_register("pluto")
class PlutoSchema(DeviceSchema):
device_type = "pluto"
driver_version = "0.0.1"@idl_register("pluto") is what lets devices.yml use device_type: pluto. The class attributes become driver metadata in the IDL response.
@staticmethod
def make_device(**kwargs):
serial = kwargs.get("serial")
return connect_pluto(serial=serial)For every Pluto record in devices.yml, RemoteRF passes that record's init mapping into make_device(**kwargs). Different Pluto entries can share this same schema but use different serials.
@idl_expose(kind="get")
def get_rx_lo(self):
return self.device.rx_lo
@idl_expose(kind="set")
def set_rx_lo(self, value):
self.device.rx_lo = value
@idl_expose(kind="call")
def call_rx(self):
return self.device.rx()These methods are the only Pluto operations the generated client sees. get_rx_lo and set_rx_lo become the rx_lo property. call_rx becomes rx().
Wrapper Template
Use this as the skeleton for a new device type:
from remoteRF_server.common.idl import DeviceSchema, idl_expose, idl_register
# Replace this with the vendor package, SDK, or local wrapper your device uses.
# import vendor_device_api
def connect_my_device(*, serial=None, address=None):
"""Open the physical device and return the vendor device object."""
try:
# dev = vendor_device_api.open(serial=serial, address=address)
# return dev
raise NotImplementedError("replace with real connection code")
except Exception as exc:
print(f"Failed to open my_device: {exc}")
return None
@idl_register("my_device")
class MyDeviceSchema(DeviceSchema):
device_type = "my_device"
driver_version = "0.0.1"
@staticmethod
def make_device(**kwargs):
return connect_my_device(
serial=kwargs.get("serial"),
address=kwargs.get("address"),
)
@idl_expose(kind="get", doc="Center frequency in Hz.")
def get_frequency(self):
return self.device.frequency
@idl_expose(kind="set")
def set_frequency(self, value):
self.device.frequency = int(value)
@idl_expose(kind="call", doc="Capture samples from the device.")
def call_capture(self):
return self.device.capture()
@idl_expose
def call_reset(self):
self.device.reset()
return 0And the matching devices.yml entry:
devices:
- device_id: 42
device_type: my_device
name: Bench Device
init:
serial: "abc123"
address: "192.168.1.80"Where to Add device_schema.py
Put the schema file on the machine that physically owns the hardware: the RemoteRF server for server-attached devices, or the RemoteRF host for host-attached devices. Client machines do not need this file.
~/.config/remoterf/drivers/my_device_schema.pyThe file can have any clear Python filename, such as my_device_schema.py, as long as it ends in .py and does not start with _. You can place as many device schema files in this folder as you need. RemoteRF imports each schema file during startup. After adding or changing schemas, restart serverrf or hostrf so RemoteRF imports them again.
Checklist
Before restarting the server or host, check these details:
Schema and Inventory Match
- The schema file is in
~/.config/remoterf/drivers/and does not start with_. - The schema has
@idl_register("my_device"). - The schema class has
device_type = "my_device". - Every relevant
devices.ymlrecord usesdevice_type: my_device.
Generated Client Will Be Clean
- Getter names start with
get_. - Setter names start with
set_and accept onevalue. - Callable names start with
call_. - Calls use zero or one client argument when possible.
- Arguments and return values are values RemoteRF can serialize.
What to Look for After Restart
On startup, RemoteRF imports schema files and then attempts to open each device. Use the schema's print(...) messages in make_device and helper functions to make connection failures obvious.
- If the schema does not load, check import errors and vendor package installation.
- If the device is offline, check the
initvalues and the local hardware detection command. - If the client API looks wrong, check the
get_,set_, andcall_method names. - If a client keeps an old API, reserve or initialize again so the schema hash check can refresh the generated wrapper.