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.

The current schema generator preserves full named call signatures: a call can have multiple arguments, optional defaults, and keyword-only controls. It also supports dictionaries, None, nested NumPy values, a custom generated class name, and advanced declarative client-side value or proxy objects.

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 "adalm_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("adalm_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})
dev.capture(4096, channel=1, timeout=2.0) My_device:capture:CALLN dispatch("call_capture", {"count": 4096, "channel": 1, "timeout": 2.0})

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 can use None, scalar values, dictionaries, lists, tuples, and real or complex NumPy arrays. These shapes can be nested when every contained value is serializable.

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. Calls can accept zero, one, or several named parameters, including optional defaults and keyword-only controls.

@idl_expose(kind="call")
def call_capture(
    self,
    count: int = 4096,
    *,
    channel: int = 0,
    timeout: float = 1.0,
):
    return self.device.capture(
        count=int(count),
        channel=int(channel),
        timeout=float(timeout),
    )

Generated client API: dev.capture(8192, channel=1, timeout=2.0)

Naming rule: call_capture becomes the client method capture().

Calls with Multiple Arguments, Defaults, and Keyword-Only Controls

RemoteRF reads the Python signature on every exposed call and publishes each parameter’s name, required/optional state, annotation, default, and parameter kind in the IDL. The generated client keeps that call shape and sends named values with the CALLN transport whenever a method has multiple or optional parameters.

schema.py full call signature
@idl_expose(kind="call", doc="Configure one receive channel.")
def call_configure(
    self,
    frequency: float,
    gain: float = 0.0,
    options: dict = None,
    *,
    channel: int = 0,
):
    options = {} if options is None else dict(options)
    return self.device.configure(
        frequency=float(frequency),
        gain=float(gain),
        options=options,
        channel=int(channel),
    )

The generated client can then be used naturally:

dev.configure(915e6)
dev.configure(915e6, gain=12.5)
result = dev.configure(
    915e6,
    gain=12.5,
    options={"agc": False, "filter": "wide"},
    channel=1,
)

If an optional argument is omitted, the client does not send it and Python applies the default in the server-side schema method. Keyword-only parameters remain keyword-only in the generated wrapper.

Signature Feature Supported? Authoring Rule
Required named arguments Yes Write ordinary positional-or-keyword parameters, such as frequency.
Optional arguments Yes Use a JSON-serializable default such as 0, 1.0, False, "mode", None, a list, or a dictionary.
Keyword-only arguments Yes Put * before the keyword-only parameters in the server method signature.
Type annotations Published as metadata Use annotations to describe the API, but still validate and convert values inside the schema before calling the vendor library.
Positional-only parameters No Do not use the / marker in an exposed method.
*args or **kwargs No Give every remote argument a stable explicit name so it can be represented in the IDL.

Avoid the argument names a, g, token, device_id, and device_name in multi-argument calls; they are reserved transport fields. A setter remains a one-value Python property operation, so use a call when one operation needs several inputs.

Supported Data Shapes

RemoteRF can map scalar values, None, dictionaries, lists, tuples, and NumPy values across the normal schema RPC path. Nested dictionaries and lists can contain NumPy scalars, complex values, and non-object NumPy arrays.

schema.py structured return value
@idl_expose(kind="call")
def call_measure(self, count: int, *, include_spectrum: bool = False):
    samples = self.device.capture(int(count))
    result = {
        "samples": samples,
        "peak": float(abs(samples).max()),
        "metadata": {
            "count": int(samples.size),
            "channel": 0,
        },
    }
    if include_spectrum:
        result["spectrum"] = np.fft.fft(samples)
    return result

Do not return live vendor objects, sockets, file handles, iterators, object-dtype arrays, or arbitrary class instances. Convert ordinary structured results into supported values. For long-lived server-owned objects, use the advanced handle-proxy pattern described in Client Object Adapters.

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. idl_register registers the device type, and idl_expose marks the methods clients can call. 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, count: int = 4096, *, channel: int = 0):
    return self.device.capture(count=int(count), channel=int(channel))

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, count=4096, *, channel=0) becomes callable as dev.capture(8192, channel=1)
  • 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_expose, and idl_register 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.DummySdr(TOKEN)
print(dev.frequency)

The important mapping is: device_type: dummy_sdr becomes remoteRF.drivers.dummy_sdr, and the default generated class is DummySdr. Set a valid Python identifier in the optional class attribute client_class, such as client_class = "SpectrumMeter", when you want an exact public class name.

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_expose, idl_register

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

DeviceSchema, idl_expose, and idl_register 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("adalm_pluto")
class PlutoSchema(DeviceSchema):
    device_type = "adalm_pluto"
    driver_version = "0.0.1"
3 Register the type string.

@idl_register("adalm_pluto") is what lets devices.yml use device_type: adalm_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"
    client_class = "MyDevice"
    driver_version = "0.1.0"

    @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 one channel.")
    def call_capture(
        self,
        count: int = 4096,
        *,
        channel: int = 0,
        timeout: float = 1.0,
    ):
        return self.device.capture(
            count=int(count),
            channel=int(channel),
            timeout=float(timeout),
        )

    @idl_expose(kind="call", doc="Configure the device and return its state.")
    def call_configure(
        self,
        frequency: float,
        gain: float = 0.0,
        options: dict = None,
    ):
        options = {} if options is None else dict(options)
        return self.device.configure(
            frequency=float(frequency),
            gain=float(gain),
            options=options,
        )

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

The dummy counter requires no hardware or vendor package, so it is the quickest way to inspect a generated driver and try an optional positional argument plus the keyword-only repeat control.

The schema above generates this client-facing usage:

client script use the generated API
from remoteRF.drivers import ensure_driver

TOKEN = "reservation-token-from-remoterf"
ensure_driver(token=TOKEN)

from remoteRF.drivers.my_device import MyDevice

dev = MyDevice(TOKEN)
dev.frequency = 915e6

samples = dev.capture(8192, channel=1, timeout=2.0)
state = dev.configure(
    915e6,
    gain=12.5,
    options={"agc": False},
)

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.

Validate the Schema Before Restarting

First validate registration, method discovery, argument metadata, defaults, and client-object declarations without opening hardware:

server or host inspect generated IDL
cd ~/.config/remoterf/drivers
python - <<'PY'
from my_device_schema import MyDeviceSchema

schema = MyDeviceSchema()
print(schema.list_exposed())
print(schema.get_idl_json(pretty=True))
PY

get_idl_json() fails early for unsupported signatures, non-serializable defaults, invalid generated names, or invalid client-object metadata. After that passes, test the wrapper directly against the local hardware before involving RPC:

local Python test bind and dispatch
from my_device_schema import MyDeviceSchema

schema = MyDeviceSchema()
device = schema.make_device(serial="abc123", address="192.168.1.80")
if device is None:
    raise RuntimeError("device did not open")

schema.bind(device)
samples = schema.dispatch(
    "call_capture",
    {"count": 16, "channel": 0, "timeout": 1.0},
)
print(type(samples), getattr(samples, "shape", None))

Once local dispatch works, restart serverrf or hostrf, reserve the device, call ensure_driver(token=TOKEN) on the client, and run the generated-client example above. That sequence separates vendor-library or USB problems from schema generation and network problems.

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_.
  • Every call parameter has a stable explicit name; exposed calls do not use positional-only parameters, *args, or **kwargs.
  • Optional defaults are JSON-serializable, and multi-argument calls avoid the reserved names a, g, token, device_id, and device_name.
  • Arguments and return values use RemoteRF-supported scalar, dictionary, sequence, or NumPy shapes.
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.

Client Object Adapters

Client object adapters define generated client-side helper APIs from schema metadata. They are intended for hardware libraries whose normal local API is object-oriented, such as UHD/USRP, where user code expects classes and namespaces like uhd.types.TimeSpec, uhd.usrp.StreamArgs, uhd.types.StreamCMD, uhd.types.RXMetadata, uhd.RXStreamer, and uhd.TXStreamer.

Most custom devices do not need this section. Start with getters, setters, and calls; those now cover multi-argument methods and structured data without any client-object declaration. Use adapters only when preserving a familiar vendor-style namespace or representing a server-owned resource with a client proxy materially improves the public API.

The generated objects are client-side compatibility objects. They do not move live vendor objects over the network. The wire contract stays JSON-safe: helper objects passed as arguments serialize into payload dictionaries, RPC return values can be wrapped back into generated helper objects, and long-lived hardware resources stay server-owned behind handle proxies.

Boundary: client_objects defines classes, namespaces, enums, functions, and proxies generated into the client driver package. It does not embed arbitrary Python source from the schema. Each generated behavior must use one of the supported safe templates listed below; the current behavior templates are intentionally UHD/USRP-specific, so do not invent a new template name in an ordinary custom schema.

This page’s DeviceSchema decorator workflow publishes the general custom-device IDL (schema_version: "1.0"), including the newer call-signature and client-object metadata. RemoteRF’s Dynamic v2 native-object and raw sample-stream protocol is currently the specialized USRP path; it is not yet the default extension surface for arbitrary custom hardware.

Import Surface

Keep the existing decorator imports, and use idl_client only as a shorthand for the new client-object declarations:

schema.py client object helpers
from remoteRF_server.common.idl import (
    DeviceSchema,
    idl_client as c,
    idl_expose,
    idl_register,
)

The existing decorator names stay the same: use @idl_register and @idl_expose as before. The idl_client as c alias is only a shorthand for the new client-object declarations. The explicit helper names, such as idl_client_payload_class and idl_client_ctor, also remain supported.

Schema Fields

Field Where It Is Used Client-Side Result
client_class = "MultiUSRP" Class attribute on a DeviceSchema. Names the generated client device class. This lets device_type = "usrp" generate MultiUSRP instead of deriving a class name from the device type.
client_objects = {...} Class attribute on a DeviceSchema. Publishes JSON-safe metadata that the client generator turns into helper namespaces, classes, enums, functions, proxy classes, and package exports.
client_return=c.ctor(...) Option on @idl_expose(...). Wraps one method's raw RPC result with a generated constructor before returning it to user code.
client_modules = {...} Legacy class attribute on a DeviceSchema. Imports a prewritten support module into the generated driver. Existing schemas can keep using it, but new generated helper APIs should use client_objects.

Helper Declarations

Each c.* helper returns a JSON-safe metadata dictionary. The server validates that metadata before publishing IDL, and the client validates it again before generating the driver package.

Declaration Purpose Generated Client Output
c.module(members, exports=None, bind_client_class_to=None) Defines a root generated module alias, such as "uhd", in the client_objects dictionary. Creates a root namespace object in the generated driver, assigns generated members onto it, records __all__ from exports, and can bind the generated device class into a nested namespace such as uhd.usrp.MultiUSRP.
c.namespace(members) Defines a nested namespace under a module or another namespace. Creates namespace objects such as uhd.types, uhd.usrp, and uhd.libpyuhd.types.
c.payload_class(payload_type, fields, init=None, methods=None, operators=None) Declares a generated value/helper class that can serialize through payload metadata. Creates classes such as TimeSpec, StreamArgs, and StreamCMD. The selected init, methods, and operators templates determine the generated constructor, helper methods, numeric behavior, and as_payload() behavior.
c.strict_class(fields, defaults=None, init=None, methods=None) Declares a fixed-attribute generated class for structured state that should not behave like a payload dictionary. Creates slotted classes such as RXMetadata and TXMetadata. Unsupported attributes raise AttributeError. Method templates can add metadata update or payload serialization behavior.
c.proxy_class(fields, init=None, methods=None) Declares a generated class that holds a server-side handle instead of a full vendor object. Creates proxy classes such as RXStreamer and TXStreamer. Proxy methods call generated device methods with the hidden handle.
c.enum(values) Declares enum-like symbolic values. Creates a class whose attributes are comparable/stringable enum values, such as uhd.types.StreamMode.start_cont and uhd.types.RXMetadataErrorCode.none.
c.function(template, **options) Declares a generated helper function from a supported function template. Creates functions such as uhd.payload(...), uhd.get_rx_stream(...), uhd.libpyuhd.types.tune_request(...), and uhd.usrp.SubdevSpec(...).
c.method(template, **options) Attaches a supported generated method template to a class declaration. Adds class methods such as TimeSpec.get_real_secs(), RXMetadata.update(...), RXStreamer.recv(...), and TXStreamer.send(...).
c.ctor(target, *args) Declares return-side wrapping for an exposed method. Generates a constructor call such as uhd.types.TimeSpec(result) or uhd.RXStreamer(self, result) after the RPC response is decoded.

Field Metadata

fields can be written as a dictionary or as a list. A dictionary is useful when a field needs metadata; a list is useful for fixed field names.

schema.py field declaration forms
c.payload_class(
    "StreamArgs",
    {
        "cpu_format": {"coerce": "str"},
        "otw_format": {"coerce": "str"},
        "args": {"default": {}, "payload": True},
        "channels": {"default": [], "coerce": "list"},
    },
    init="stream_args",
)

c.proxy_class(
    ["usrp", "handle"],
    init="streamer_handle",
)

Field names, member names, enum values, exports, aliases, and dotted bind paths must be valid Python identifiers. Field metadata and defaults must be JSON-safe. The current UHD templates use field metadata for validation and for documenting payload shape; the selected template owns the exact generated Python body.

Generated Package Output

When a schema publishes client_objects, the generated driver package includes the declared aliases and exports them from the package. For USRP, this means user code can import the generated device class and generated helper namespace together:

from remoteRF.drivers.usrp import MultiUSRP, uhd

usrp = MultiUSRP(token)
when = uhd.types.TimeSpec(0.0)
stream_args = uhd.usrp.StreamArgs("fc32", "sc16")

The same generated namespace can also expose the generated device class when the module uses bind_client_class_to:

usrp = uhd.usrp.MultiUSRP(token)

Object Declaration Example

This abbreviated USRP declaration shows the shape of a complete generated helper module:

usrp_schema.py client_objects declaration
def _uhd_client_objects():
    time_spec = c.payload_class(
        "TimeSpec",
        {"secs": {"default": 0.0, "coerce": "float"}},
        init="timespec",
        methods=[
            c.method("timespec_get_real_secs"),
            c.method("timespec_get_full_secs"),
            c.method("timespec_get_frac_secs"),
            c.method("timespec_to_ticks"),
            c.method("timespec_get_tick_count"),
            c.method("timespec_float"),
            c.method("payload_as_payload"),
        ],
        operators=["timespec_numeric"],
    )

    stream_args = c.payload_class(
        "StreamArgs",
        {
            "cpu_format": {"coerce": "str"},
            "otw_format": {"coerce": "str"},
            "args": {"default": {}, "payload": True},
            "channels": {"default": [], "coerce": "list"},
        },
        init="stream_args",
        methods=[c.method("payload_as_payload")],
    )

    streamer_methods = [
        c.method("streamer_get_max_num_samps"),
        c.method("streamer_issue_stream_cmd"),
        c.method("streamer_close"),
    ]

    return {
        "uhd": c.module(
            {
                "payload": c.function("uhd_payload"),
                "get_rx_stream": c.function("uhd_get_rx_stream"),
                "get_tx_stream": c.function("uhd_get_tx_stream"),
                "close_all_streams": c.function("uhd_close_all_streams"),
                "streamer": c.function("uhd_streamer"),
                "RXStreamer": c.proxy_class(
                    ["usrp", "handle"],
                    init="streamer_handle",
                    methods=[*streamer_methods, c.method("rx_streamer_recv")],
                ),
                "TXStreamer": c.proxy_class(
                    ["usrp", "handle"],
                    init="streamer_handle",
                    methods=[*streamer_methods, c.method("tx_streamer_send")],
                ),
                "usrp": c.namespace({
                    "SubdevSpec": c.function("uhd_subdev_spec"),
                    "StreamArgs": stream_args,
                }),
                "types": c.namespace({
                    "TimeSpec": time_spec,
                    "StreamCMD": c.payload_class(
                        "StreamCMD",
                        {
                            "mode": {"coerce": "enum_value"},
                            "stream_now": {"default": True, "coerce": "bool"},
                            "time_spec": {"default": None, "payload": True},
                            "num_samps": {"default": None},
                        },
                        init="stream_cmd",
                        methods=[c.method("payload_as_payload")],
                    ),
                    "StreamMode": c.enum(
                        ["num_done", "num_more", "stop_cont", "start_cont"]
                    ),
                    "RXMetadata": c.strict_class(
                        [
                            "error_code",
                            "error_code_repr",
                            "time_spec",
                            "out_of_sequence",
                            "fragment_offset",
                            "more_fragments",
                        ],
                        init="rx_metadata",
                        methods=[
                            c.method("metadata_update"),
                            c.method("metadata_strerror"),
                        ],
                    ),
                    "TXMetadata": c.strict_class(
                        ["has_time_spec", "time_spec", "end_of_burst"],
                        init="tx_metadata",
                        methods=[c.method("payload_as_payload")],
                    ),
                    "RXMetadataErrorCode": c.enum(
                        [
                            "none",
                            "timeout",
                            "overflow",
                            "late_command",
                            "broken_chain",
                            "alignment",
                            "bad_packet",
                        ]
                    ),
                }),
                "libpyuhd": c.namespace({
                    "types": c.namespace({
                        "tune_request": c.function("uhd_tune_request"),
                    }),
                }),
            },
            exports=[
                "usrp",
                "types",
                "libpyuhd",
                "payload",
                "get_rx_stream",
                "get_tx_stream",
                "close_all_streams",
                "streamer",
                "RXStreamer",
                "TXStreamer",
            ],
            bind_client_class_to="usrp",
        )
    }


@idl_register("usrp")
class UsrpSchema(DeviceSchema):
    device_type = "usrp"
    client_class = "MultiUSRP"
    client_objects = _uhd_client_objects()

That declaration generates the object surface. For example, uhd.types.TimeSpec is not hand-written in the client support package when declared this way; it is emitted by the client generator from client_objects.

Argument Payload Flow

Argument objects move from user code to the server. The generated object serializes itself, and the server schema reconstructs the real vendor argument before calling the hardware library.

The generated client-side payload helper has an as_payload() method when the class declaration includes c.method("payload_as_payload") or the selected template provides payload behavior:

generated client argument serialization
class TimeSpec:
    def as_payload(self):
        return {
            "__uhd_type__": "TimeSpec",
            "secs": self.get_real_secs(),
        }

User code passes the generated object directly:

when = uhd.types.TimeSpec(0.0)
usrp.set_time_now(when)

The generated client maps the argument to a JSON-safe payload before the RPC call:

{"__uhd_type__": "TimeSpec", "secs": 0.0}

The server-side schema still owns vendor reconstruction. client_objects generates the client helper; the schema method or wrapper must turn the payload into the real vendor object:

usrp_schema.py argument reconstruction
def _construct(value):
    if value.get("__uhd_type__") == "TimeSpec":
        return uhd.types.TimeSpec(value["secs"])
    if value.get("__uhd_type__") == "StreamArgs":
        obj = uhd.usrp.StreamArgs(value["cpu_format"], value["otw_format"])
        obj.channels = value.get("channels", [])
        obj.args = value.get("args", {})
        return obj
    return value

There is no separate client_arg declaration for this common case. Argument support comes from generated as_payload() behavior on the client and explicit payload reconstruction on the server.

Return Value Flow

Return values move from the server back to user code. The server returns serializable data, and client_return tells the generated client which constructor should receive that data.

For get_time_now, the server returns a serializable TimeSpec payload:

usrp_schema.py returned payload
def time_spec_payload(value):
    return {
        "__uhd_type__": "TimeSpec",
        "secs": value.get_real_secs(),
    }


@idl_expose(
    kind="call",
    client_return=c.ctor("uhd.types.TimeSpec", "$result"),
)
def call_get_time_now(self):
    return time_spec_payload(self.device.get_time_now())

The generated client behaves as if it ran:

uhd.types.TimeSpec(result)

So user code can stay close to local UHD:

current_time = usrp.get_time_now().get_real_secs()

Handle Proxy Flow

Use a proxy class when the real object cannot leave the server because it owns hardware state, buffers, file descriptors, sockets, or vendor SDK state. USRP streamers work this way.

The schema defines the generated client proxy:

usrp_schema.py generated proxy
client_objects = {
    "uhd": c.module({
        "RXStreamer": c.proxy_class(
            ["usrp", "handle"],
            init="streamer_handle",
            methods=[
                c.method("streamer_get_max_num_samps"),
                c.method("rx_streamer_recv"),
                c.method("streamer_close"),
            ],
        ),
    }),
}

The server method creates the real streamer, stores it on the server, and returns a handle payload:

usrp_schema.py server-owned streamer
@idl_expose(
    kind="call",
    client_return=c.ctor("uhd.RXStreamer", "$self", "$result"),
)
def call_get_rx_stream(self, stream_args):
    real_stream_args = construct_uhd_object(stream_args)
    real_streamer = self.device.get_rx_stream(real_stream_args)
    return self.put_streamer("rx", real_streamer)

The generated client wraps the handle with uhd.RXStreamer(self, result). Proxy methods then call generated MultiUSRP methods with the hidden handle:

streamer = usrp.get_rx_stream(st_args)
num_samps = streamer.get_max_num_samps()
streamer.recv(recv_buffer, metadata)
streamer.close()

Constructor Targets

Read c.ctor(...) as “call this client-side constructor after the RPC result comes back.”

Schema Argument Meaning Generated Client Code
"$result" The value returned by the server method. uhd.types.TimeSpec($result)
"$self" The generated client device object. uhd.RXStreamer($self, $result)
{"const": value} A fixed JSON-safe value. A constructor that needs a constant mode or label.

The constructor target can point at a generated client_objects root or a legacy imported client_modules root:

client_return=c.ctor("uhd.types.TimeSpec", "$result")
# Generated return shape:
# return uhd.types.TimeSpec(result)

client_return=c.ctor("uhd.RXStreamer", "$self", "$result")
# Generated return shape:
# return uhd.RXStreamer(self, result)

Supported Init Templates

Init templates are selected with init="..." on c.payload_class(...), c.strict_class(...), or c.proxy_class(...).

Template Used With Generated Behavior
timespec c.payload_class("TimeSpec", ...) Generates a numeric time helper with secs, from_ticks(...), real/full/fractional second helpers, tick helpers, float conversion, arithmetic, and payload serialization.
stream_args c.payload_class("StreamArgs", ...) Generates cpu_format, otw_format, args, and channels fields with payload-aware assignment.
stream_cmd c.payload_class("StreamCMD", ...) Generates stream command state for mode, stream_now, time_spec, and num_samps, including enum and nested payload handling.
rx_metadata c.strict_class(...) Generates an RX metadata object with UHD-style error code, time spec, fragment, and sequence fields.
tx_metadata c.strict_class(...) Generates a TX metadata object with has_time_spec, time_spec, end_of_burst, and payload serialization.
streamer_handle c.proxy_class(...) Generates a proxy initializer that stores the generated device object and the server-side streamer handle.

Supported Method and Operator Templates

Method templates are attached with c.method("..."). Operator templates are attached through the operators=[...] argument on payload classes.

Template Generated Member Purpose
payload_as_payload as_payload() Returns a JSON-safe dictionary representation of the helper object, recursively converting nested helper values.
timespec_get_real_secs get_real_secs() Returns the floating-point seconds value from a generated TimeSpec.
timespec_get_full_secs get_full_secs() Returns the integer/full seconds component.
timespec_get_frac_secs get_frac_secs() Returns the fractional seconds component.
timespec_to_ticks to_ticks(rate) Converts seconds to ticks at the provided sample rate.
timespec_get_tick_count get_tick_count(rate) Alias-style tick helper for UHD-compatible code.
timespec_float float(time_spec) Allows generated time specs to be converted with Python's float(...).
timespec_numeric + and - Adds numeric arithmetic between generated TimeSpec objects, dictionaries containing seconds, and numeric seconds.
metadata_update update(value) Updates generated metadata objects from server-returned metadata payloads.
metadata_strerror strerror() Returns the metadata error string representation.
streamer_get_max_num_samps get_max_num_samps() Calls the generated device method that queries the server-side streamer handle.
streamer_issue_stream_cmd issue_stream_cmd(stream_cmd) Serializes a generated stream command and sends it to the server-side streamer handle.
streamer_close close() Releases the server-side streamer handle.
rx_streamer_recv recv(recv_buffer, metadata, timeout=0.1, one_packet=False) Receives samples through the server-side RX streamer, writes the returned samples into the provided buffer, updates metadata, and returns the sample count.
tx_streamer_send send(samples, metadata, timeout=0.1) Serializes TX metadata and sends samples through the server-side TX streamer handle.

Supported Function Templates

Function templates are selected with c.function("...") and assigned as module or namespace members.

Template Generated Function Purpose
uhd_payload payload(value) Exposes the generated recursive payload converter as a public helper.
uhd_get_rx_stream get_rx_stream(usrp_obj, stream_args) Serializes stream args, calls usrp_obj.get_rx_stream(...), and wraps the returned handle with streamer(...).
uhd_get_tx_stream get_tx_stream(usrp_obj, stream_args) Serializes stream args, calls usrp_obj.get_tx_stream(...), and wraps the returned handle with streamer(...).
uhd_close_all_streams close_all_streams(usrp_obj) Calls the generated device method that releases server-side streamer handles.
uhd_streamer streamer(usrp_obj, value) Converts a server-returned streamer handle payload into RXStreamer or TXStreamer.
uhd_tune_request tune_request(*args, **kwargs) Creates a payload dictionary for UHD tune requests.
uhd_subdev_spec SubdevSpec(spec) Creates a payload dictionary for UHD subdevice specs.

Runtime Flow Reference

Flow Schema Declaration Runtime Behavior
Define generated helper classes client_objects = {"uhd": c.module(...)} The generated driver exposes uhd, nested namespaces, generated classes, enums, functions, and package exports.
Pass helper objects as arguments c.payload_class(..., methods=[c.method("payload_as_payload")]) User code passes the generated object. The generated client converts it through as_payload(). The server schema reconstructs the real vendor object from the payload.
Return helper value objects @idl_expose(..., client_return=c.ctor("uhd.types.TimeSpec", "$result")) The server returns JSON-safe data. The generated client calls the generated constructor before returning to user code.
Return server-owned handle proxies @idl_expose(..., client_return=c.ctor("uhd.RXStreamer", "$self", "$result")) The server stores the real object and returns a handle. The generated client creates a proxy object whose methods call back into generated device methods with that handle.

Validation Rules

RemoteRF rejects invalid client object metadata before publishing IDL or generating a client wrapper:

  • client_objects must be a dictionary whose root aliases are valid Python identifiers.
  • Root aliases cannot duplicate legacy client_modules aliases.
  • Module members, namespace members, enum values, exports, field names, and bind paths must be valid Python identifiers or dotted identifiers where appropriate.
  • c.ctor(...) targets must be valid dotted identifiers, and constructor arguments must be "$self", "$result", or {"const": json_safe_value}.
  • Function, method, init, and operator templates must be in the supported template sets above.
  • Defaults, field metadata, function options, method options, and constructor constants must be JSON-serializable.
  • The schema must still return JSON-safe values or server-side handles from exposed methods. Vendor objects should be reconstructed or stored on the server, not returned directly.

For USRP support, this system generates the MultiUSRP client class, the uhd namespace, value helpers such as TimeSpec, StreamArgs, and StreamCMD, metadata helpers such as RXMetadata and TXMetadata, enums such as StreamMode and RXMetadataErrorCode, and handle-backed proxies such as RXStreamer and TXStreamer.