Adapter Authoring
This guide covers creating a new adapter crate for HomeCmdr. It reflects the current codebase — not an aspirational plugin system.
Publishing an official adapter? Official adapters live in the homecmdr/adapters registry and are installed into workspaces with
homecmdr pull <adapter-name>. This guide is for creating an adapter locally in your workspace.
Architecture Overview
The adapter system is compile-time linked and factory-driven.
crates/coredefines the shared runtime contracts (traits, command model, event bus, registry).crates/adaptersis a link crate that pulls adapter crates into the finalapibinary.crates/apidiscovers adapter factories at startup throughregistered_adapter_factories()(powered byinventory).- Each adapter crate owns its own config, validation, polling, command translation, and tests.
Adapters do not require edits to crates/api/src/main.rs or crates/core/src/config.rs. You will update three places for workspace linkage (see step 13).
Required Runtime Contracts
Before writing any code, read:
crates/core/src/adapter.rs—AdapterandAdapterFactorytraitscrates/core/src/model.rs—Device,DeviceKind,AttributeValuecrates/core/src/registry.rs—DeviceRegistrycrates/core/src/command.rs—DeviceCommandcrates/core/src/capability.rs— canonical capability catalog
Key rules:
- All device IDs must be namespaced:
"{adapter_name}:{vendor_id}". run()must publishEvent::AdapterStartedbefore doing work.run()must not exit on transient failures — log the error and sleep.- Polling failures should emit
Event::SystemError. command()returns:Ok(true)— command appliedOk(false)— device or command not owned by this adapterErr(...)— adapter recognized the command but could not apply it
- Registry updates must preserve prior
room_id. - When state is unchanged, preserve
updated_atand refresh onlylast_seen.
Existing Patterns
Pattern 1: Poll-only sensor
Reference: crates/adapter-open-meteo/src/lib.rs
- No
command()override needed. - Maps external data to
DeviceKind::Sensordevices. - Each poll upserts canonical state.
- Preserves room assignment via
previous.and_then(|d| d.room_id.clone()).
Pattern 2: Poll + commands for multiple devices
Reference: crates/adapter-elgato-lights/src/lib.rs
- One adapter owns multiple vendor devices.
- Device IDs encode vendor identity (e.g.
elgato_lights:light:0). command()parses the device ID, returnsOk(false)for devices it doesn't own.- Confirms state after commands before updating the registry.
- Removes stale devices when they disappear upstream.
Pattern 3: Poll + commands for one logical device
Reference: crates/adapter-roku-tv/src/lib.rs
- One adapter exposes one logical device.
command()checks one stable device ID.- Command translation is simple keypress mapping.
Step-by-Step Procedure
1. Choose the adapter name
The name must be stable and snake_case.
open_meteo
elgato_lights
roku_tv
This name is used in: the config section key [adapters.<name>], AdapterFactory::name(), Adapter::name(), device ID prefixes, and error messages.
Keep the crate name aligned: crates/adapter-<name>.
2. Create the crate
Create:
crates/adapter-<name>/Cargo.tomlcrates/adapter-<name>/src/lib.rs
Typical dependencies:
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = "0.4"
inventory = "0.3"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
homecmdr-core = { path = "../../crates/core" }
tokio = { version = "1", features = ["time"] }
tracing = "0.1"
3. Define the config struct
#[derive(Debug, Clone, Deserialize)]
pub struct ExampleConfig {
pub enabled: bool,
pub base_url: String,
pub poll_interval_secs: u64,
}
Guidelines:
- Include
enabled. - Include a poll interval for polled integrations.
- Use
serdedefaults only when there is a clear stable default. - Do not add adapter-specific config structs to
crates/core/src/config.rs.
4. Validate config inside the adapter crate
fn validate_config(config: &ExampleConfig) -> Result<()> {
if config.base_url.is_empty() {
bail!("adapters.example.base_url must not be empty");
}
if config.poll_interval_secs < 1 {
bail!("adapters.example.poll_interval_secs must be >= 1");
}
Ok(())
}
Error messages should name the full config path (adapters.<name>.<field>).
5. Implement the factory
pub struct ExampleFactory;
static EXAMPLE_FACTORY: ExampleFactory = ExampleFactory;
inventory::submit! {
RegisteredAdapterFactory { factory: &EXAMPLE_FACTORY }
}
impl AdapterFactory for ExampleFactory {
fn name(&self) -> &'static str { ADAPTER_NAME }
fn build(&self, config: AdapterConfig) -> Result<Option<Box<dyn Adapter>>> {
let config: ExampleConfig = serde_json::from_value(config)
.context("failed to parse example adapter config")?;
validate_config(&config)?;
if !config.enabled {
return Ok(None);
}
Ok(Some(Box::new(ExampleAdapter::new(config))))
}
}
Ok(None)= valid but disabled.Err(...)= invalid config.
6. Implement the adapter struct
Store fields derived from config to avoid repeated parsing:
pub struct ExampleAdapter {
client: reqwest::Client,
base_url: String,
poll_interval: Duration,
}
7. Implement polling
async fn poll_once(&self, registry: &DeviceRegistry) -> Result<()> {
let data = self.client.get(&self.base_url).send().await?.json().await?;
let device_id = format!("{ADAPTER_NAME}:my_device");
let previous = registry.get(&device_id);
let device = build_device(&device_id, &data, previous.as_ref());
registry.upsert(device).await?;
Ok(())
}
When building a device:
- Preserve prior
room_id. - Preserve
updated_atwhen state and metadata are unchanged. - Always set
last_seento now on successful observation.
8. Implement run()
#[async_trait]
impl Adapter for ExampleAdapter {
fn name(&self) -> &str { ADAPTER_NAME }
async fn run(&self, registry: DeviceRegistry, bus: EventBus) -> Result<()> {
bus.publish(Event::AdapterStarted {
adapter: self.name().to_string(),
});
loop {
if let Err(error) = self.poll_once(&registry).await {
tracing::error!(error = %error, "example poll failed");
bus.publish(Event::SystemError {
message: format!("example poll failed: {error}"),
});
}
sleep(self.poll_interval).await;
}
}
}
run() must not exit. Use fixed sleep intervals after failures (current adapters do not use exponential backoff).
9. Implement command() (only if needed)
async fn command(
&self,
device_id: &DeviceId,
command: DeviceCommand,
registry: DeviceRegistry,
) -> Result<bool> {
if !device_id.starts_with(ADAPTER_NAME) {
return Ok(false);
}
// translate and apply command
// update registry with post-command state
Ok(true)
}
Commands are already validated by the core before reaching your adapter. You may add adapter-local narrowing (e.g. vendor-specific value ranges) on top of canonical validation.
10. Map to canonical device state
Use DeviceKind intentionally:
| Kind | Use for |
|---|---|
Sensor | Read-only measured values |
Light | Controllable lighting |
Switch | Generic on/off devices |
Virtual | Logical devices with no physical counterpart |
Canonical ownership rules:
- Use
device.attributes.<capability_key>when a canonical capability covers the state. - Use
custom.<adapter>.<field>only for current-state attributes that don't fit the canonical catalog. - Use
metadata.vendor_specificfor opaque upstream identifiers and descriptive metadata. - Do not publish the same meaning in both a canonical attribute and vendor-specific data.
11. Handle disappearing devices
If the upstream source is authoritative, remove stale devices when they disappear:
let current_ids: HashSet<_> = fetched_devices.iter().map(|d| d.id.clone()).collect();
for stale_id in self.known_ids.difference(&current_ids) {
registry.remove(stale_id).await?;
}
Do not remove devices because a single poll failed.
12. Add config examples
Update config/default.toml with a usable (and disabled by default) example section:
[adapters.example]
enabled = false
base_url = "http://127.0.0.1:9000"
poll_interval_secs = 30
13. Add workspace linkage
Update three files:
- Root
Cargo.toml— add the crate to[workspace.members] crates/adapters/Cargo.toml— add it as a dependencycrates/adapters/src/lib.rs— adduse adapter_example as _;
Without step 3, the inventory::submit! side effect won't run and the factory will not be registered.
14. Write tests
Every adapter should cover at minimum:
- Config validation (valid, invalid, disabled)
- Polling normalization (external payload → canonical device)
- Command translation (if commands exist)
- State freshness (
updated_atstable on identical state) - Factory
build()behavior
Recommended test infrastructure: a tiny in-process mock server using tokio::net::TcpListener with ephemeral ports. See existing adapters for the pattern.
15. Verify
cargo fmt --all
cargo test -p adapter-<name> -p homecmdr-adapters
cargo check --workspace
Full Code Template
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use homecmdr_core::adapter::{Adapter, AdapterFactory, RegisteredAdapterFactory};
use homecmdr_core::bus::EventBus;
use homecmdr_core::command::DeviceCommand;
use homecmdr_core::config::AdapterConfig;
use homecmdr_core::event::Event;
use homecmdr_core::model::DeviceId;
use homecmdr_core::registry::DeviceRegistry;
use tokio::time::{sleep, Duration};
const ADAPTER_NAME: &str = "example";
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ExampleConfig {
pub enabled: bool,
pub poll_interval_secs: u64,
}
pub struct ExampleFactory;
static EXAMPLE_FACTORY: ExampleFactory = ExampleFactory;
inventory::submit! {
RegisteredAdapterFactory { factory: &EXAMPLE_FACTORY }
}
pub struct ExampleAdapter {
poll_interval: Duration,
}
impl ExampleAdapter {
pub fn new(config: ExampleConfig) -> Self {
Self {
poll_interval: Duration::from_secs(config.poll_interval_secs),
}
}
async fn poll_once(&self, registry: &DeviceRegistry) -> Result<()> {
let _ = registry;
Ok(())
}
}
impl AdapterFactory for ExampleFactory {
fn name(&self) -> &'static str { ADAPTER_NAME }
fn build(&self, config: AdapterConfig) -> Result<Option<Box<dyn Adapter>>> {
let config: ExampleConfig = serde_json::from_value(config)
.context("failed to parse example adapter config")?;
if config.poll_interval_secs == 0 {
bail!("adapters.example.poll_interval_secs must be >= 1");
}
if !config.enabled {
return Ok(None);
}
Ok(Some(Box::new(ExampleAdapter::new(config))))
}
}
#[async_trait]
impl Adapter for ExampleAdapter {
fn name(&self) -> &str { ADAPTER_NAME }
async fn run(&self, registry: DeviceRegistry, bus: EventBus) -> Result<()> {
bus.publish(Event::AdapterStarted {
adapter: self.name().to_string(),
});
loop {
if let Err(error) = self.poll_once(&registry).await {
bus.publish(Event::SystemError {
message: format!("example poll failed: {error}"),
});
}
sleep(self.poll_interval).await;
}
}
async fn command(
&self,
_device_id: &DeviceId,
_command: DeviceCommand,
_registry: DeviceRegistry,
) -> Result<bool> {
Ok(false)
}
}
Common Mistakes
- Editing
crates/api/src/main.rsto manually instantiate your adapter. - Editing
crates/core/src/config.rsto add adapter-specific config structs. - Using device IDs without the adapter prefix.
- Dropping
room_idon registry refresh. - Replacing
updated_aton every poll when state is identical. - Returning
Err(...)for a device ID the adapter does not own. - Forgetting to add
use <crate> as _;tocrates/adapters/src/lib.rs. - Adding new capabilities when an existing canonical one already covers the state.
- Writing tests that require a fixed local port without exclusive control.
- Putting vendor metadata into canonical attributes prematurely.
Review Checklist
Before finishing an adapter, verify:
- New crate exists under
crates/adapter-<name> - Crate has an
AdapterFactoryregistered withinventory::submit! - Adapter name matches the config section key
- Adapter is linked in root
Cargo.toml - Adapter is linked in
crates/adapters/Cargo.toml -
crates/adapters/src/lib.rsimports the crate for side-effect registration -
config/default.tomlcontains a usable example section - Device IDs are namespaced correctly
- Room assignment is preserved across refreshes
-
updated_atis stable for identical state -
command()returnsOk(false)for unsupported devices - Focused tests cover polling and commands
-
cargo fmt --allpasses - Targeted tests pass
MCP Tooling
The MCP server (crates/mcp-server) exposes tools that assist with adapter authoring:
scaffold_adapter— generates a new adapter crate skeleton with the correctinventory::submit!factory boilerplate and prints the three manual registration steps.run_cargo_check— runscargo checkon the workspace or a focused package.run_cargo_test— runscargo teston the workspace or a focused package.list_capabilities— lists the canonical capability schemas the runtime knows about.
See MCP Server for setup details.