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:

server schema wrapper
you write
required schema.py

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.

client remote wrapper
generated
do not edit client side

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:

  1. Make sure the server or host machine can control the hardware locally from Python.
  2. Create one schema file in ~/.config/remoterf/drivers/.
  3. Add one or more matching entries to ~/.config/remoterf/devices.yml.
  4. Restart serverrf or hostrf so the schema and device inventory are reloaded.
  5. Reserve the device from a client. The client fetches the IDL, meaning the device’s interface description, and generates the matching remote driver.
Best mental model: 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.

Admin Write 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.

Server or Host Startup Load and Register

The server or host loader imports every schema file from its config. @idl_register("pluto") adds the class to the registry.

Device Inventory Open Hardware

The server or host devices.yml selects the schema by device_type. That same machine calls make_device(**init).

IDL JSON Publish API Shape

The schema introspects exposed getters, setters, and calls, then builds an Interface Description Language document with a schema_hash.

Client Generate Remote Driver

No manual client code changes are needed. The client calls IDL:get_drivers, receives the interface description, and generates the local remote wrapper automatically.

Runtime Use It Like Python

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:

Python Control

The device already has a Python library, command wrapper, or local control object you can call from the machine that owns the hardware.

Stable Identity

The device can be opened from stable init data such as serial, device_index, ip, or uri.

RPC-Sized Calls

The operations fit a request/response model: read a value, set a value, capture samples, transmit samples, reset, calibrate, enumerate, and similar actions.

Serializable Data

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:

Getter

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.frequency

Generated client API: dev.frequency

Naming rule: get_frequency becomes the readable client property frequency.

Setter

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.

Call

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.

Good wrapper boundary: Keep hardware discovery, type conversion, default handling, and vendor-library quirks inside the schema. Keep the generated client API small and unsurprising.

Imports and Decorators

Every schema must start with the required RemoteRF import line, followed by whatever vendor libraries your device needs:

schema.py required + device-specific imports
# 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 np

The 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.

Base Class

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"
Class Decorator

@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"
Method Decorator

@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.frequency

Avoid 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

schema.py register device type
@idl_register("dummy_sdr")
class DummySdrSchema(DeviceSchema):
    device_type = "dummy_sdr"
    driver_version = "0.0.1"
Required: The registered string must match the device_type field in devices.yml.
devices.yml matching device type
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.

schema.py map kwargs to hardware
@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:

devices.yml init data
init:
  serial: "sdr-001"
  sample_rate: 1000000
  center_frequency: 2400000000

then RemoteRF calls:

server/host runtime generated call
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.

schema.py client-facing API
@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 as dev.frequency
  • set_frequency(self, value) becomes writable as dev.frequency = value
  • call_capture(self) becomes callable as dev.capture()
  • call_reset(self) becomes callable as dev.reset()

Bare @idl_expose is also allowed and means kind="call":

schema.py default call decorator
@idl_expose
def call_reset(self):
    """Reset the device state."""
    self.device.reset()
    return 0

The 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.

schema.py minimum required shape
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.frequency
Strictly Required Pieces
RemoteRF imports

Import DeviceSchema, idl_register, and idl_expose from remoteRF_server.common.idl.

Registered schema class

Add @idl_register("...") above a class that inherits from DeviceSchema.

Matching type metadata

Set device_type to the same value used by @idl_register(...) and devices.yml.

Driver version

Set driver_version so the generated client can report which wrapper version it came from.

Device factory

Define make_device(**kwargs), named exactly that, returning the live device object or None.

Exposed client API

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:

generated client package where the wrapper appears
remoteRF/drivers/<device_type>/<device_type>_remote.py

For a schema registered as dummy_sdr, client code imports the generated package by the same device_type name:

client script import generated driver
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.

1. Imports
from remoteRF_server.common.idl import DeviceSchema, idl_register, idl_expose

import subprocess
import re
import adi
1 Import RemoteRF IDL tools and the vendor API.

DeviceSchema, idl_register, and idl_expose are generic RemoteRF pieces. adi, subprocess, and re are Pluto-specific support code.

2. Local Connection Helper
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}")
2 Keep hardware discovery outside the schema class.

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.

3. Register the Device Type
@idl_register("pluto")
class PlutoSchema(DeviceSchema):
    device_type = "pluto"
    driver_version = "0.0.1"
3 Register the type string.

@idl_register("pluto") is what lets devices.yml use device_type: pluto. The class attributes become driver metadata in the IDL response.

4. Open One Physical Device
    @staticmethod
    def make_device(**kwargs):
        serial = kwargs.get("serial")
        return connect_pluto(serial=serial)
4 Convert per-device config into a live object.

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.

5. Expose Properties and Calls
    @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()
5 Choose the remote API surface.

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:

my_device_schema.py
python
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 0

And the matching devices.yml entry:

devices.yml matching inventory 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.

server or host custom schema location
~/.config/remoterf/drivers/my_device_schema.py

The 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:

Files

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.yml record uses device_type: my_device.
API Shape

Generated Client Will Be Clean

  • Getter names start with get_.
  • Setter names start with set_ and accept one value.
  • Callable names start with call_.
  • Calls use zero or one client argument when possible.
  • Arguments and return values are values RemoteRF can serialize.
Debugging

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.

Custom schemas can introduce new failure modes. Many bugs may come from the schema code itself, the third-party libraries it imports, missing system packages, hardware permissions, or assumptions made inside helper functions. Be prepared to understand and debug your wrapper code with those dependencies in mind.
  1. If the schema does not load, check import errors and vendor package installation.
  2. If the device is offline, check the init values and the local hardware detection command.
  3. If the client API looks wrong, check the get_, set_, and call_ method names.
  4. If a client keeps an old API, reserve or initialize again so the schema hash check can refresh the generated wrapper.
Rule of thumb: if you can write a small local Python script that opens the hardware and calls the operations you care about, you can usually turn that same logic into a RemoteRF schema without touching the server core.