Write a driver module
You want to use hardware that Viam doesn’t support out of the box, whether it’s a sensor, camera, motor, or any other component. A driver module bridges that gap: it implements a standard Viam resource API so that data capture, the Test section, the SDKs, and other platform features work with your hardware automatically.
Driver modules run as separate processes alongside viam-server, so they carry their own dependencies and can crash without bringing viam-server down. You package and distribute them through the Viam registry.
This page walks through seven steps for writing a driver module, using a temperature-and-humidity sensor as the worked example. For background on choosing a resource API, module lifecycle, and dependencies, see the overview.
Steps
When writing a module, follow the steps outlined below. To illustrate each step we’ll use a sensor module as a worked example. The same patterns apply to any resource type – substitute the appropriate API and methods for your use case.
1. Generate the module
Before you run the generator, install the Viam CLI and log in with viam login.
The generator prompts for your organization’s public namespace. If you have not set one yet, click the organization dropdown at the upper right of the Viam app, select Settings, then Set a public namespace. You can also enter your Org ID at the prompt instead.
Run the Viam CLI generator:
viam module generate
The generator creates a new directory named after your module (for example, my-sensor-module) in your current working directory. cd into that directory for the rest of the steps.
When run without flags, the generator prompts for each value below. If you pass these as --name, --language, --visibility, --public-namespace, --resource-subtype, --model-name, and --register flags instead, use the flag forms noted in the table (where different from the interactive labels).
| Prompt | What to enter | Why |
|---|---|---|
| Set a module name: | my-sensor-module | A short, descriptive name |
| Specify the language for the module: | python or go | Your implementation language |
| Visibility: | private | private: visible only within your org. public: visible to everyone. public_unlisted: usable by anyone who knows the module ID, but hidden from the registry page. You can change visibility later. |
| Namespace/Organization ID | Your organization namespace | Scopes the module to your org |
| Select a resource to be added to the module: | Sensor Component | The resource API to implement |
| Set a model name of the resource: | my-sensor | The model name for your sensor |
| Register module | yes | Registers the module with Viam |
The generator creates a complete project with the following files:
| File | Purpose |
|---|---|
src/main.py | Entry point – starts the module server |
src/models/my_sensor.py | Resource class skeleton – you will edit this |
requirements.txt | Python dependencies |
meta.json | Module metadata for the registry |
setup.sh | Installs dependencies into a virtualenv |
build.sh | Packages the module for upload |
.github/workflows/deploy.yml | CI workflow for cloud builds |
| File | Purpose |
|---|---|
cmd/module/main.go | Entry point – starts the module server |
module.go | Resource implementation skeleton – you will edit this |
go.mod | Go module definition |
Makefile | Build targets |
meta.json | Module metadata for the registry |
.github/workflows/deploy.yml | CI workflow for cloud builds |
2. Implement the resource API
Open the generated resource file: src/models/my_sensor.py (Python) or module.go (Go). The generator creates a class (Python) or struct (Go) with stub methods. You need to make four changes to the resource file, then review the entry point the generator created:
- Define your config attributes.
- Add validation logic.
- Populate your resource from config in the constructor.
- Implement the API methods for your resource type.
The following example builds a sensor that reads temperature and humidity from a custom HTTP API endpoint. Replace the HTTP call with whatever data source your sensor uses.
Define your config attributes
Config attributes are the fields a user sets when they configure your component in the Viam app. The generator creates an empty config; add a field for each attribute your module needs.
In src/models/my_sensor.py, declare your config attributes as type-annotated
class variables. They will be populated from the config in new:
class MySensor(Sensor, EasyResource):
# To enable debug-level logging, either run viam-server with the --debug option,
# or configure your resource/machine to display debug logs.
MODEL: ClassVar[Model] = Model(
ModelFamily("my-org", "my-sensor-module"), "my-sensor"
)
# Add your config attributes as instance variables
source_url: str
poll_interval: float
In module.go, find the empty Config struct and add fields.
Each field needs a json tag that matches the attribute name users will set in
their config JSON:
type Config struct {
SourceURL string `json:"source_url"`
PollInterval float64 `json:"poll_interval"`
}
Add validation logic
The generator creates an empty validation method. Add checks for required fields and return any dependencies your module needs.
Find validate_config in your class and add validation:
@classmethod
def validate_config(
cls, config: ComponentConfig
) -> Tuple[Sequence[str], Sequence[str]]:
fields = config.attributes.fields
if "source_url" not in fields:
raise Exception("source_url is required")
if not fields["source_url"].string_value.startswith("http"):
raise Exception("source_url must be an HTTP or HTTPS URL")
return [], [] # No required or optional dependencies
Find the Validate method on your Config struct and add validation:
func (cfg *Config) Validate(path string) ([]string, []string, error) {
if cfg.SourceURL == "" {
return nil, nil, fmt.Errorf("source_url is required")
}
return nil, nil, nil // No required or optional dependencies
}
The validation method returns two lists: required dependencies and optional
dependencies. In Go, the method also returns an error as a third value; in
Python, raise an exception instead. For a standalone sensor with no
dependencies, return empty lists and no error.
See Step 5 for modules that depend on other
components.
Populate your resource from config
Your constructor runs on both initial creation and every config change, so read config fields and initialize state there. When a user changes the configuration, viam-server stops the existing resource instance and creates a fresh one with the new config.
Override new to read your config and set the fields on the instance before
returning it:
@classmethod
def new(cls, config: ComponentConfig,
dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
sensor = super().new(config, dependencies)
fields = config.attributes.fields
sensor.source_url = fields["source_url"].string_value
sensor.poll_interval = (
fields["poll_interval"].number_value
if "poll_interval" in fields
else 10.0
)
return sensor
The generator also emits do_command, get_status, and get_geometries
stubs that raise NotImplementedError. Leave them as-is unless your module
needs custom handling.
The generator produces two constructor functions: a private
newMySensorModuleMySensor that unpacks the raw config and delegates to a
public NewMySensor that takes a typed *Config. The split lets tests call
NewMySensor directly with a typed config. Leave
newMySensorModuleMySensor alone and update NewMySensor to initialize any
state your module needs:
func NewMySensor(ctx context.Context, deps resource.Dependencies, name resource.Name, conf *Config, logger logging.Logger) (sensor.Sensor, error) {
cancelCtx, cancelFunc := context.WithCancel(context.Background())
timeout := time.Duration(conf.PollInterval) * time.Second
if timeout == 0 {
timeout = 10 * time.Second
}
s := &mySensorModuleMySensor{
name: name,
logger: logger,
cfg: conf,
cancelCtx: cancelCtx,
cancelFunc: cancelFunc,
client: &http.Client{Timeout: timeout},
}
return s, nil
}
Add fields to the generated struct for any state your module needs at runtime:
type mySensorModuleMySensor struct {
resource.AlwaysRebuild
name resource.Name
logger logging.Logger
cfg *Config
cancelCtx context.Context
cancelFunc func()
client *http.Client
}
Leave the generated resource.AlwaysRebuild embed in place. The generated
Name(), Close(), DoCommand(), and Status() methods also stay. Close
calls cancelFunc() to stop any background work. DoCommand and Status
are stubs returning fmt.Errorf("not implemented"). Leave them as-is unless
your module needs custom handling.
Implement the API method
For a sensor, the key method is GetReadings, which returns a map of reading
names to values. This is the method that data capture calls and your application
code queries.
The generator creates a stub that returns an error. Replace it with your implementation:
Add a get_readings method to your class. The return type is
Mapping[str, SensorReading] (import SensorReading from viam.utils):
async def get_readings(
self,
*,
extra: Optional[Mapping[str, Any]] = None,
timeout: Optional[float] = None,
**kwargs,
) -> Mapping[str, SensorReading]:
try:
response = requests.get(self.source_url, timeout=5)
response.raise_for_status()
data = response.json()
return {
"temperature": data["temp"],
"humidity": data["humidity"],
}
except requests.RequestException as e:
self.logger.error(f"Failed to read from {self.source_url}: {e}")
raise
Find the Readings method stub and replace it:
type sensorResponse struct {
Temp float64 `json:"temp"`
Humidity float64 `json:"humidity"`
}
func (s *mySensorModuleMySensor) Readings(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.SourceURL, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
s.logger.CErrorw(ctx, "failed to read from source",
"url", s.cfg.SourceURL, "error", err)
return nil, fmt.Errorf("failed to read from %s: %w", s.cfg.SourceURL, err)
}
defer resp.Body.Close()
var data sensorResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return map[string]interface{}{
"temperature": data.Temp,
"humidity": data.Humidity,
}, nil
}
Understanding the entry point
The generator also creates the entry point file that viam-server launches.
You typically do not need to modify it.
src/main.py:
import asyncio
from viam.module.module import Module
from models.my_sensor import MySensor as MySensorModel
if __name__ == '__main__':
asyncio.run(Module.run_from_registry())
run_from_registry() automatically discovers all imported resource classes and
registers them with viam-server. If you add more models to your module, import
them here.
cmd/module/main.go:
package main
import (
"mysensormodule"
"go.viam.com/rdk/module"
"go.viam.com/rdk/resource"
sensor "go.viam.com/rdk/components/sensor"
)
func main() {
// ModularMain can take multiple APIModel arguments, if your module implements multiple models.
module.ModularMain(resource.APIModel{sensor.API, mysensormodule.MySensor})
}
ModularMain handles socket parsing, signal handling, and graceful shutdown.
The import of the resource package triggers its init() function, which calls
resource.RegisterComponent to register the model. If you add more models,
add more resource.APIModel entries to the ModularMain call.
3. Test locally
Use the CLI to build and deploy your module to a machine, then verify it works. Two commands cover the common development loop: viam module reload (cloud build) for cross-architecture work, and viam module reload-local (local build) for same-architecture iteration. Use reload when developing on a different architecture than your target, for example on macOS deploying to a Raspberry Pi. Use reload-local when architectures match for faster iteration.
Deploy with hot reloading:
First, find your machine’s part ID. At the top of the machine’s page, click the Live / Offline status dropdown, then click Part ID to copy it. In the commands below, --model-name adds an instance of your model to the machine config so you don’t have to create it by hand, and --name names that instance. Run these commands from your module’s root directory (where meta.json lives). If you need to invoke them from elsewhere, pass --module <path/to/meta.json>.
# Build in the cloud, deploy, and add a component named `my-sensor-1`
viam module reload --part-id <machine-part-id> \
--model-name my-org:my-sensor-module:my-sensor --name my-sensor-1
# Build locally (same-architecture only), transfer, and add the component
viam module reload-local --part-id <machine-part-id> \
--model-name my-org:my-sensor-module:my-sensor --name my-sensor-1
After the first reload succeeds, open the machine’s CONFIGURE tab and set your new sensor’s attributes. Replace source_url with a real endpoint that returns JSON matching the shape your Readings implementation expects (the example reads temp and humidity keys from the response body):
{
"source_url": "https://your-endpoint.example.com/readings"
}
Click Save.
Test using the Test section:
- Find your sensor component and expand the Test section.
- Your temperature and humidity values appear automatically under GetReadings.
Get a ready-to-run code sample:
The CONNECT tab on your machine’s page in the Viam app provides generated code samples in Python and Go that connect to your machine and access all configured components. Use this as a starting point for application code that interacts with your module.
Rebuild and redeploy during development:
Each time you make changes, run viam module reload (or reload-local) again. Use --no-build to skip the build step if you already built manually. Use viam module restart to restart without rebuilding (for example, after editing Python source).
4. Add logging
Both the Python and Go SDKs provide a logger that writes to viam-server’s log
stream, visible in the LOGS tab.
self.logger.info("Sensor initialized with source URL: %s", self.source_url)
self.logger.debug("Raw response from source: %s", data)
self.logger.warning("Source returned unexpected field: %s", field_name)
self.logger.error("Failed to connect to source: %s", error)
s.logger.CInfof(ctx, "Sensor initialized with source URL: %s", s.cfg.SourceURL)
s.logger.CDebugf(ctx, "Raw response from source: %v", data)
s.logger.CWarnw(ctx, "Source returned unexpected field", "field", fieldName)
s.logger.CErrorw(ctx, "Failed to connect to source", "error", err)
Use info for significant events, debug for detailed data, warning for
recoverable problems, and error for failures.
5. Handle dependencies
Many modules need access to other resources on the same machine. To use another resource, you need to do three things:
- Declare the dependency in your config validation method by returning the resource name in the required (or optional) dependencies list.
- Resolve the dependency in your constructor by looking it up from the
dependenciesmap thatviam-serverpasses in. - Call methods on it in your API implementation, just like any other typed resource.
The following example shows all three. It implements a sensor that depends on another sensor – it reads Celsius temperature readings from the source sensor and converts them to Fahrenheit. Watch for the numbered comments in the code.
In Go, the example uses sensor.FromProvider(deps, name) to resolve the dependency. It returns a typed sensor.Sensor handle, so your code does not need a type assertion. Every component and service package in the SDK exposes its own FromProvider helper. For a motor dependency, use motor.FromProvider(deps, name). For a camera, use camera.FromProvider(deps, name). And so on.
The Python SDK has no equivalent helper. Iterate the dependencies map and match by ResourceName.name, as the example below shows.
Place TempConverter alongside MySensor. In Python, add it as a new file under src/models/ (for example, src/models/temp_converter.py). In Go, put it in the same package as MySensor, either by extending module.go or adding another .go file in the same directory. For registration, see Step 7.
class TempConverter(Sensor, EasyResource):
MODEL: ClassVar[Model] = Model(
ModelFamily("my-org", "my-sensor-module"), "temp-converter"
)
source_sensor: Sensor
@classmethod
def validate_config(
cls, config: ComponentConfig
) -> Tuple[Sequence[str], Sequence[str]]:
fields = config.attributes.fields
if "source_sensor" not in fields:
raise Exception("source_sensor is required")
source = fields["source_sensor"].string_value
# 1. Declare: return the source sensor name as a required dependency
return [source], []
@classmethod
def new(cls, config: ComponentConfig,
dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
instance = super().new(config, dependencies)
source_name = config.attributes.fields["source_sensor"].string_value
# 2. Resolve: find the dependency in the map viam-server passes in
for name, dep in dependencies.items():
if name.name == source_name:
instance.source_sensor = dep
break
return instance
async def get_readings(self, *, extra=None, timeout=None,
**kwargs) -> Mapping[str, SensorReading]:
# 3. Use: call methods on the dependency like any typed resource
readings = await self.source_sensor.get_readings()
celsius = readings["temperature"]
return {"temperature_f": celsius * 9.0 / 5.0 + 32.0}
var TempConverter = resource.NewModel("my-org", "my-sensor-module", "temp-converter")
func init() {
resource.RegisterComponent(sensor.API, TempConverter,
resource.Registration[sensor.Sensor, *ConverterConfig]{
Constructor: newMySensorModuleTempConverter,
},
)
}
type ConverterConfig struct {
SourceSensor string `json:"source_sensor"`
}
func (cfg *ConverterConfig) Validate(path string) ([]string, []string, error) {
if cfg.SourceSensor == "" {
return nil, nil, fmt.Errorf("source_sensor is required")
}
// 1. Declare: return the source sensor name as a required dependency
return []string{cfg.SourceSensor}, nil, nil
}
type mySensorModuleTempConverter struct {
resource.AlwaysRebuild
name resource.Name
logger logging.Logger
cfg *ConverterConfig
cancelCtx context.Context
cancelFunc func()
source sensor.Sensor
}
func newMySensorModuleTempConverter(
ctx context.Context,
deps resource.Dependencies,
rawConf resource.Config,
logger logging.Logger,
) (sensor.Sensor, error) {
conf, err := resource.NativeConfig[*ConverterConfig](rawConf)
if err != nil {
return nil, err
}
return NewTempConverter(ctx, deps, rawConf.ResourceName(), conf, logger)
}
func NewTempConverter(
ctx context.Context,
deps resource.Dependencies,
name resource.Name,
conf *ConverterConfig,
logger logging.Logger,
) (sensor.Sensor, error) {
cancelCtx, cancelFunc := context.WithCancel(context.Background())
// 2. Resolve: look up the dependency by name from the map viam-server passes in
src, err := sensor.FromProvider(deps, conf.SourceSensor)
if err != nil {
cancelFunc()
return nil, fmt.Errorf("source sensor %q not found: %w",
conf.SourceSensor, err)
}
return &mySensorModuleTempConverter{
name: name,
logger: logger,
cfg: conf,
cancelCtx: cancelCtx,
cancelFunc: cancelFunc,
source: src,
}, nil
}
func (s *mySensorModuleTempConverter) Name() resource.Name {
return s.name
}
func (s *mySensorModuleTempConverter) Readings(
ctx context.Context,
extra map[string]interface{},
) (map[string]interface{}, error) {
// 3. Use: call methods on the dependency like any typed resource
readings, err := s.source.Readings(ctx, extra)
if err != nil {
return nil, fmt.Errorf("failed to read source sensor: %w", err)
}
celsius, ok := readings["temperature"].(float64)
if !ok {
return nil, fmt.Errorf("source sensor did not return a temperature reading")
}
return map[string]interface{}{
"temperature_f": celsius*9.0/5.0 + 32.0,
}, nil
}
func (s *mySensorModuleTempConverter) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *mySensorModuleTempConverter) Status(ctx context.Context) (map[string]interface{}, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *mySensorModuleTempConverter) Close(ctx context.Context) error {
s.cancelFunc()
return nil
}
TempConverter is a second model in the same module. To register it alongside your first model, see Step 7.
6. Use the module data directory
Every module gets a persistent data directory at the path specified by the
VIAM_MODULE_DATA environment variable. Use this for caches, databases, or
any state that should survive module restarts.
import os
data_dir = os.environ.get("VIAM_MODULE_DATA", "/tmp")
cache_path = os.path.join(data_dir, "readings_cache.json")
dataDir := os.Getenv("VIAM_MODULE_DATA")
cachePath := filepath.Join(dataDir, "readings_cache.json")
The directory is created automatically by viam-server at
$VIAM_HOME/module-data/<machine-id>/<module-name>/ (where VIAM_HOME
defaults to ~/.viam) and persists across module
restarts and reconfigurations.
7. Add multiple models to one module (optional)
A single module can provide multiple models, even across different APIs (for example, a sensor and a camera). There is no limit on the number of models per module.
To add a second model:
- Run
viam module generateagain in a separate directory. You only need the generated resource file, so skip the registration prompt. - Copy that file into your existing module’s source directory. In Go, rename three things before the file compiles: the compound struct to
<yourModuleName><Model>, the config struct to something unique likeConverterConfig, andresource.NewModel’s middle argument to your existing module name. The Step 5TempConverterexample shows the result. - Update the entry point to register both models.
Import the new model in src/main.py. run_from_registry() automatically
discovers all imported resource classes:
import asyncio
from viam.module.module import Module
from models.my_sensor import MySensor as MySensorModel
from models.my_camera import MyCamera as MyCameraModel
if __name__ == '__main__':
asyncio.run(Module.run_from_registry())
Add more resource.APIModel entries to ModularMain:
package main
import (
"mysensormodule"
"go.viam.com/rdk/module"
"go.viam.com/rdk/resource"
camera "go.viam.com/rdk/components/camera"
sensor "go.viam.com/rdk/components/sensor"
)
func main() {
// ModularMain can take multiple APIModel arguments, if your module implements multiple models.
module.ModularMain(
resource.APIModel{sensor.API, mysensormodule.MySensor},
resource.APIModel{camera.API, mysensormodule.MyCamera},
)
}
Each model needs its own init() function calling resource.RegisterComponent
(or resource.RegisterService) with its API, model, and constructor.
- Delete the temporary generated directory.
- Update
meta.jsonto list all models (or useviam module update-models --binary ./bin/moduleto detect them automatically from a Go binary).
Try It
- Generate a sensor module using
viam module generate. - Open the generated resource file and implement your config validation and
GetReadingsmethod. - Configure the module on your machine as a local module.
- Expand the Test section on your sensor. Verify readings appear automatically under GetReadings.
- Enable data capture on the sensor. Wait one minute, then check the DATA tab to confirm readings are flowing to the cloud.
- Add a new key to the readings map (for example,
"pressure": 1013.25). Rebuild and redeploy withviam module reload-local. Verify the new reading appears on the Test section.
Troubleshooting
What’s Next
- Write a Logic Module – write a module that monitors sensors, coordinates components, or runs automation logic.
- Deploy a Module – package your module and upload it to the Viam registry for distribution.
- Module Reference – complete reference for meta.json, CLI commands, environment variables, and resource interfaces.
Was this page helpful?
Glad to hear it! If you have any other feedback please let us know:
We're sorry about that. To help us improve, please tell us what we can do better:
Thank you!