Skip to main content

Developing Plugins

Plugins can be written in any language that has an MQTT client library. HomeCore provides first-class SDKs for Rust, Python, Node.js, and .NET Core.

SDKs live in the sdks/ directory of the workspace, each as an independent git repo.

Installing an SDK

None of the SDKs is published to a package registry — not crates.io, PyPI, npm, or NuGet. That is deliberate for Rust (plugins pin the SDK by tag and adopt updates on their own cadence) and simply not set up yet for the others. So there is no pip install homecore-plugin-sdk; installing means either the local checkout or a git tag.

Working inside the homeCore workspace

The normal case while developing a plugin. With core/, plugins/ and sdks/ cloned side by side, point your plugin at the checkout — SDK edits then take effect with no commit, tag or reinstall in between.

From plugins/hc-mything/
Rustnothing to do — see below
Pythonpip install -e ../../sdks/hc-plugin-sdk-py
Node.jsnpm install ../../sdks/hc-plugin-sdk-js
.NETdotnet add reference ../../sdks/hc-plugin-sdk-dotnet/HomeCoreSdk.csproj

Rust is the exception because the redirect is already done for you. A plugin's committed Cargo.toml declares the git dependency by tag, and the meta-workspace at plugins/Cargo.toml patches it to the checkout:

[patch."https://github.com/homeCore-io/hc-plugin-sdk-rs"]
plugin-sdk-rs = { path = "../sdks/hc-plugin-sdk-rs" }

Built from inside plugins/ you get the local source; cloned standalone by CI you get the tag. Check which with cargo tree -p hc-yourplugin -i plugin-sdk-rs. Do not change a plugin's own Cargo.toml to a path dependency — standalone CI has no workspace to patch it, so the build breaks there while working on your machine.

Python's -e and npm's directory install both link rather than copy, so the same "edit and it is live" property holds. .NET builds the SDK from source as part of your build.

From a release

Each SDK is tagged, so a plugin outside the workspace pins a known version:

Rustplugin-sdk-rs = { git = "https://github.com/homeCore-io/hc-plugin-sdk-rs", tag = "v0.3.10" }
Pythonpip install git+https://github.com/homeCore-io/hc-plugin-sdk-py@v0.2.0
Node.jsnpm install github:homeCore-io/hc-plugin-sdk-js#v0.2.0
.NETclone at the tag, then dotnet add reference …/HomeCoreSdk.csproj

NuGet has no git-install equivalent, which is why .NET clones.


Rust SDK (hc-plugin-sdk-rs)

The fastest start is hc-plugin-template — a working virtual-light plugin, small enough to read in one sitting, with the management protocol, a capability action, and a notice already wired up.

gh repo create my-plugin --template homeCore-io/hc-plugin-template

Add to Cargo.toml

The crate is named plugin-sdk-rs. Pin it by tag: it re-exports core's hc-types, which is the plugin ABI, so an unpinned dependency means your build changes when core does. See Installing an SDK for how this resolves locally.

[dependencies]
plugin-sdk-rs = { git = "https://github.com/homeCore-io/hc-plugin-sdk-rs", tag = "v0.3.10" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
anyhow = "1"

Minimal plugin

use plugin_sdk_rs::{PluginClient, PluginConfig};
use serde_json::json;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let client = PluginClient::connect(PluginConfig {
broker_host: "127.0.0.1".into(),
broker_port: 1883,
plugin_id: "plugin.my-device".into(),
password: String::new(),
})
.await?;

let publisher = client.device_publisher();

// Register the device.
publisher
.register_device_full(
"my_device_001",
"My Device",
Some("sensor"),
Some("living_room"),
Some(json!({
"temperature": { "type": "number" },
"humidity": { "type": "number" }
})),
)
.await?;

// Subscribing to commands is a SEPARATE call. Skip it and the device
// appears in homeCore, updates its state, and silently ignores every
// command — nothing is listening on its cmd topic.
publisher.subscribe_commands("my_device_001").await?;

publisher
.publish_state("my_device_001", &json!({ "temperature": 72.5, "humidity": 45.0 }))
.await?;
publisher.publish_availability("my_device_001", true).await?;

// Owns the process from here. Commands arrive on this callback, which is
// synchronous — hand slow work to a task over a channel.
client
.run(|device_id, payload| {
println!("Command for {device_id}: {payload}");
})
.await
}

A real plugin calls run_managed rather than run, passing the handle from enable_management, so core can heartbeat it, restart it, push configuration, and render its actions as buttons.

Publishing state updates

// Full state (retained — replaces the previous state).
publisher
.publish_state("my_device_001", &json!({
"temperature": 73.0,
"humidity": 44.5,
"battery": 87
}))
.await?;

// Partial (JSON merge-patch — only the fields given).
publisher
.publish_state_partial("my_device_001", &json!({ "temperature": 73.0 }))
.await?;

// After a command, attach provenance so the UI and the audit log can say
// what caused the change instead of showing an anonymous update.
publisher
.publish_state_for_command("my_device_001", &new_state, &command_payload, "my-plugin")
.await?;

Handling commands

homeCore never writes device state. A command arrives on homecore/devices/{id}/cmd, the plugin does whatever the device needs, and the plugin publishes what actually happened. That is why the UI can correctly show a light as off after a command the bulb refused.

let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, serde_json::Value)>(64);

tokio::spawn(async move {
while let Some((device_id, payload)) = rx.recv().await {
if let Some(on) = payload.get("on").and_then(|v| v.as_bool()) {
set_relay(on).await; // talk to the device
publisher // then publish the truth
.publish_state_for_command(&device_id, &json!({ "on": on }), &payload, "my-plugin")
.await
.ok();
}
}
});

client
.run_managed(
move |device_id, payload| {
// try_send, not send: dropping a command under load beats blocking
// the MQTT event loop behind it.
let _ = tx.try_send((device_id, payload));
},
mgmt,
)
.await

Notices

A notice is how a plugin's problem reaches the operator's screen rather than only the log. They render on the plugin's card in the web UI.

use plugin_sdk_rs::types::PluginNotice;

let notices = client.notices();

notices.raise(
PluginNotice::error("bridge_unreachable", "The bridge stopped answering")
.with_remedy("Check that the bridge is powered on and reachable"),
);

notices.clear("bridge_unreachable"); // when it answers again

A notice is state, not a log line. It stays up while the condition holds, so re-evaluate after each discovery sweep, reconnect, and config change rather than deciding once at startup — a plugin that raises no_devices_configured at boot and never looks again is still showing it after the user's devices arrive.

Notices and capability actions are available in every SDK.

Plugin isolation via per-device subscriptions

Every SDK uses per-device topic subscriptions — not wildcards. A plugin subscribes to homecore/devices/{device_id}/cmd for each device it owns, and receives commands for nothing else, which keeps well-behaved plugins from stomping on each other by convention. In Rust that is an explicit subscribe_commands() call; in Python, Node.js and .NET registering the device does it for you.

Trust boundary caveat: on the default embedded rumqttd broker, per-topic ACLs are not enforced. A misbehaving or hostile plugin could subscribe outside its declared patterns. Deployments that cannot rely on plugin correctness (containers, third-party code, compliance scenarios) should run HomeCore with an external Mosquitto broker, which enforces the same allow_pub / allow_sub patterns declared in [[broker.clients]]. See External Mosquitto deployment.

Cross-restart device cleanup

When a device disappears from a plugin's authoritative source — a Hue bulb deleted from the bridge, a Z-Wave node excluded, an entry removed from [[devices]] — its homeCore record needs to go away too. The SDK handles persistence and the diff so plugins only need to declare what's live each cycle.

// 1. Opt in once at startup (typically next to config.toml).
let client = PluginClient::connect(cfg)
.await?
.with_device_persistence(
Path::new(&config_path)
.parent()
.unwrap_or(Path::new("."))
.join(".published-device-ids.json"),
);

// 2. After a healthy sync where you know the full live set:
let live: HashSet<String> = my_upstream
.list_devices()
.iter()
.map(|d| d.hc_id())
.collect();
let report = publisher.reconcile_devices(live).await?;
// report.stale_unregistered: Vec<String>
// = devices unregistered because they're not in `live`
// report.unknown_in_live: Vec<String>
// = ids you passed but never registered (usually empty)

What the SDK does:

  • with_device_persistence(path) mirrors every register_device_full / unregister_device call to a JSON file. On startup, the file is loaded so the in-memory tracker isn't blank — that's how a plugin knows about devices it registered in a previous session.
  • reconcile_devices(live) computes tracked - live, calls unregister_device for each stale id, and writes the new live set back to disk.

What plugins must decide:

  • When to call. Only when the upstream sync actually succeeded. Calling reconcile after a partial fetch will wipe live devices behind a temporarily-unreachable upstream. The typical pattern is an all_bridges_succeeded (or equivalent) flag tracked across the per-source loop.
  • Whether to call at all. Plugins whose upstream has irregular reporting cadence (battery sensors that go quiet for hours, e.g. hc-ecowitt) should opt into persistence but skip auto-reconcile — the false-positive risk is worse than the zombie-device cost. Operators can clean up zombies with the core endpoint DELETE /api/v1/plugins/:id/devices when needed.

The same thing in the other SDKs. Identical semantics, idiomatic names:

EnableReconcile
Rustwith_device_persistence(path)reconcile_devices(live)
Pythonenable_device_persistence(path)reconcile_devices(live)
Node.jsenableDevicePersistence(path)reconcileDevices(live)
.NETEnableDevicePersistence(path)ReconcileDevicesAsync(live)

All four scope the snapshot filename by plugin id (.published-device-ids.plugin.hue.json), because deployments keep every plugin's config in one directory and every plugin derives the path the same way — unscoped, two plugins share one file and retire each other's devices.

Manual bulk wipe. Independent of SDK reconcile, an admin can call:

DELETE /api/v1/plugins/<plugin_id>/devices

…to delete every device whose plugin_id matches. The plugin stays registered; on its next sync cycle it re-registers anything still live. Useful for clearing zombies left over from development churn or config rearrangements without dropping the whole state DB. It is an API-only operation — the web UI does not surface a button for it.

Cross-device consumer plugins

Most plugins own their devices and only observe their own command topics. A cross-device consumer plugin also needs to observe state from other plugins' devices — e.g. a virtual thermostat aggregating temperature readings from YoLink and Ecowitt sensors.

The Rust SDK supports this directly:

// Subscribe to another plugin's device state (tracked for reconnect).
client.subscribe_state("yolink_sensor_a").await?;
client.subscribe_state("ecowitt_outdoor_temp").await?;

// Drive the event loop with TWO callbacks: own cmd + external state.
client.run_managed_with_state(
move |device_id, payload| {
// Commands on OUR devices (homecore/devices/thermostat_+/cmd)
},
move |device_id, payload| {
// State from OTHER devices we subscribed to
},
mgmt,
).await?;

// Later, at runtime:
client.unsubscribe_state("ecowitt_outdoor_temp").await?;

run_managed_with_state is a drop-in replacement for run_managed. Use run_managed when you don't need external state. The corresponding DevicePublisher::subscribe_state / unsubscribe_state methods let background tasks adjust subscriptions dynamically (e.g. when a user changes the sensor list via a runtime command).

Broker ACL: cross-device consumers need allow_sub = ["homecore/devices/+/state"] — broader than the typical plugin ACL. See the thermostat plugin Setup section for a complete example.

See hc-thermostat for a reference implementation.


Python SDK (hc-plugin-sdk-py)

Install it first. Subclass PluginBase, implement on_command, call run(). The API is synchronous — the SDK drives paho-mqtt's loop for you, so background work goes in a thread.

from homecore_plugin_sdk import PluginBase

DEVICE_ID = "light.virtual_py_01"
CAPABILITIES = {
"on": {"type": "boolean"},
"brightness": {"type": "integer", "minimum": 0, "maximum": 255},
}


class MyPlugin(PluginBase):
PLUGIN_ID = "plugin.my-sensor"

def __init__(self, **kwargs):
super().__init__(**kwargs)
self._state = {"on": False, "brightness": 128}

def on_connect(self) -> None:
# Called once the broker connection is up — register here, not in
# __init__, so a reconnect re-registers too.
self.register_device(DEVICE_ID, "Virtual Light", CAPABILITIES, area="living_room")
self.publish_availability(DEVICE_ID, True)
self.publish_state(DEVICE_ID, dict(self._state))
self.publish_plugin_status("active")

def on_command(self, device_id: str, payload: dict) -> None:
self._state.update({k: v for k, v in payload.items() if k in CAPABILITIES})
# Publish what you actually applied, with provenance attached.
self.publish_state_for_command(
device_id, dict(self._state), payload, fallback_source="my-plugin"
)


MyPlugin(broker_host="127.0.0.1", broker_port=1883).run()

Configuration comes from constructor arguments, then the environment (HC_BROKER_HOST, HC_BROKER_PORT, HC_PLUGIN_PASSWORD), then defaults. Requires Python 3.11+. See examples/virtual_light.py in the SDK repo for a complete plugin.


Node.js SDK (hc-plugin-sdk-js)

Install it first. Extend PluginBase, implement onCommand, call run(). Same shape as the Python SDK, on mqtt.js v5.

const { PluginBase } = require('homecore-plugin-sdk');

const DEVICE_ID = 'light.virtual_js_01';
const CAPABILITIES = {
on: { type: 'boolean' },
brightness: { type: 'integer', minimum: 0, maximum: 255 },
};

class MyPlugin extends PluginBase {
constructor(options = {}) {
super({ pluginId: 'plugin.my-device', ...options });
this._state = { on: false, brightness: 128 };
}

onConnect() {
this.registerDevice(DEVICE_ID, 'Virtual Light', CAPABILITIES, 'living_room');
this.publishAvailability(DEVICE_ID, true);
this.publishState(DEVICE_ID, { ...this._state });
this.publishPluginStatus('active');
}

onCommand(deviceId, payload) {
Object.assign(this._state, payload);
this.publishStateForCommand(deviceId, { ...this._state }, payload, 'my-plugin');
}
}

new MyPlugin({ brokerHost: '127.0.0.1', brokerPort: 1883 }).run();

Requires Node.js 18+. See examples/virtual_light.js in the SDK repo.


.NET SDK (hc-plugin-sdk-dotnet)

Install it first.

using HomeCore.PluginSdk;

var client = new PluginClient(new PluginOptions { PluginId = "plugin.my-device" });

client.OnCommand += (deviceId, payload) => {
Console.WriteLine($"Command for {deviceId}: {payload}");
};

await client.ConnectAsync();
await client.RegisterDeviceFullAsync("my_device_001", "My Device", deviceType: "sensor");

// Separate from registration — without it the device appears in homeCore and
// silently ignores every command.
await client.SubscribeCommandsAsync("my_device_001");

await client.PublishStateAsync("my_device_001", new { temperature = 72.5 });
await client.PublishAvailabilityAsync("my_device_001", true);
await client.RunAsync();

Configuration falls back to HC_BROKER_HOST, HC_BROKER_PORT, and HC_PLUGIN_PASSWORD when the options are not set.


Choosing a language

All four SDKs do the same job — every feature in the matrix below is in all of them. Pick the language you would rather write the device integration in.

The Rust SDK remains the reference implementation, and new protocol features land there first.

Logging secrets in .NET

Every SDK redacts field values whose names look secret. In Rust and Python a field passed alongside the message stays out of the message text, so that is enough. .NET renders every template argument into the message, so LogInformation("connecting with {ApiKey}", key) would publish the key in the text as well — the .NET SDK therefore also scrubs those values out of the rendered message. None of it helps with a secret you interpolate yourself, so the rule in every language is still: do not log secrets.


Management protocol

Plugins built with the official SDKs can opt into the management protocol, which enables:

  • Heartbeat monitoring — the plugin publishes to homecore/plugins/{id}/heartbeat every 30-60 seconds. HomeCore marks the plugin offline after 90 seconds without a heartbeat.
  • Remote configuration — HomeCore can push config changes via homecore/plugins/{id}/manage/cmd with set_config.
  • Dynamic log level — change the plugin's log verbosity at runtime via set_log_level without restarting.
  • Health checksping command with pong response.
  • Log forwarding — plugin logs are published to homecore/plugins/{id}/logs over MQTT, making them visible in the admin UI Activity page alongside core logs. Configurable minimum level via log_forward_level in the plugin's [logging] config.

All four SDKs (Rust, Python, Node.js, .NET) handle the management protocol automatically when enabled.

Capability manifest

Plugins declare plugin-specific actions in a typed manifest; the admin UI renders Actions buttons from it and hc-mcp exposes the entries as tools. Adding a new action doesn't require any changes to core, the SDKs, the web UI, or hc-mcp — the framework is fully data-driven.

See the dedicated Plugin Capabilities & Actions page for the full spec, manifest fields, stage vocabulary, and protocol. The short version below shows how to wire it up from the Rust SDK.

Sync (non-streaming) action

For a fire-and-forget command. Add a with_capabilities arm and handle the action through with_custom_handler:

let mgmt = client
.enable_management(
60,
Some(env!("CARGO_PKG_VERSION").to_string()),
Some(config_path.to_string()),
Some(log_level_handle),
)
.await?
.with_capabilities(hc_types::Capabilities {
spec: "1".into(),
plugin_id: String::new(), // SDK fills from configured plugin_id
actions: vec![hc_types::Action {
id: "rescan_devices".into(),
label: "Rescan devices".into(),
description: Some("Refresh inventory from the cloud.".into()),
params: None,
result: None,
stream: false,
cancelable: false,
concurrency: hc_types::Concurrency::default(),
item_key: None,
item_operations: None,
requires_role: hc_types::RequiresRole::User,
timeout_ms: None,
}],
})
.with_custom_handler(move |cmd| match cmd["action"].as_str()? {
"rescan_devices" => {
rescan_tx.try_send(()).ok();
Some(serde_json::json!({ "status": "ok" }))
}
_ => None,
});

Streaming action

Long-running flows that emit live progress, accept user prompts, and handle cancel. Use with_streaming_action and the StreamContext's helper methods:

use plugin_sdk_rs::{StreamContext, StreamingAction};
use serde_json::{json, Value};

let mgmt = mgmt.with_streaming_action(StreamingAction::new(
"include_node",
move |ctx: StreamContext, _params: Value| async move {
ctx.progress(Some(0), Some("starting"), Some("Press the button on each device")).await?;

// ... wait for device events, emit item_add per node ...
ctx.item_add(json!({ "node_id": 14, "status": "added" })).await?;
ctx.item_update(json!({ "node_id": 14, "status": "ready" })).await?;

// Wait for user "done" via awaiting_user / await_respond.
ctx.emit_awaiting_user_with_schema(
"Reply when finished",
json!({ "done": { "type": "boolean", "default": true } }),
).await?;
let _ = ctx.await_respond().await?;

// Always end with a terminal stage.
ctx.complete(json!({ "nodes_added": [14] })).await
},
)));

The stage helpers (progress, item_add / item_update / item_remove, awaiting_user, warning, complete, error, canceled) handle the envelope shape and the stream_topic for you. Check ctx.is_canceled() in cooperative loops; pair emit_awaiting_user_with_schema with await_respond when the closure also needs to process other async work concurrently.

The Z-Wave plugin's inclusion.rs is a comprehensive worked example covering all the patterns; the hc-captest plugin in plugins/hc-captest/ has six minimal-but-complete demos exercising every convention.

SDK feature matrix

FeatureRustPythonNode.js.NET
Publish state (full + partial)
Device registration (typed + full)
Device schema publishing
Availability publishing
Event publishing
Command handling
Per-device command subscription
Plugin status
Management protocol
Command change metadata
Auto-reconnect
Notices
Capability actions (immediate)
Capability actions (streaming)
Cross-device state subscription
Log forwarding (MQTT)
Device persistence + reconcile

See Plugin Overview: Management Protocol for the full MQTT topic reference and API endpoints.


Raw MQTT (any language)

You can write a plugin in any language using any MQTT client library. Follow the topic protocol directly:

1. Connect to broker

host: 127.0.0.1
port: 1883
client_id: plugin.my-device
username: plugin.my-device (same as client_id)
password: your-password (if broker auth enabled, else empty)

2. Register a device

Publish to homecore/plugins/plugin.my-device/register:

{
"device_id": "my_device_001",
"plugin_id": "plugin.my-device",
"name": "My Device",
"area": "living_room",
"device_type": "sensor",
"capabilities": {
"temperature": {"type": "number"},
"battery": {"type": "integer", "minimum": 0, "maximum": 100}
}
}

3. Publish state (retained)

Publish to homecore/devices/my_device_001/state with retain=true:

{"temperature": 72.5, "battery": 87}

4. Publish availability (retained)

Publish to homecore/devices/my_device_001/availability with retain=true:

online

or when disconnecting (use MQTT Last Will):

offline

5. Subscribe to commands

Subscribe to homecore/devices/my_device_001/cmd.

Incoming payload is a JSON object with device-specific fields. Apply them to the physical device, then publish the new state back.

Plugin file structure conventions

my-plugin/
├── Cargo.toml # [package] name = "hc-my-plugin"
├── .gitignore
├── config/
│ └── config.toml # default config (not committed if contains secrets)
├── config.example.toml # committed example without secrets
├── src/
│ └── main.rs
└── README.md

config.toml structure:

[homecore]
broker_host = "127.0.0.1"
broker_port = 1883
plugin_id = "plugin.my-device"
password = ""

[my_device]
# plugin-specific settings
host = "192.168.1.x"
username = "admin"
password = "change-me"

Distributing a plugin

Plugins do not ship as container images. They ship as signed .tar.zst artifacts in the plugin registry: a plugin.toml manifest plus the binary, ed25519-signed, indexed at https://homecore.io/registry/index.json.

Core downloads the artifact, verifies the signature against the public key in its [registry] config, unpacks it to $HOMECORE_HOME/plugins/<id>/<version>/, seeds a config with generated MQTT credentials, and runs the binary as a child process it supervises and restarts. Users install with Plugins → Add in the web UI.

Tagging v0.1.0 in a plugin repo does all of this: the shared release workflow builds a static musl binary, packages the .tar.zst, attaches it to the GitHub Release, notifies the registry, and then polls the served index until the entry appears — so a green release means an installable plugin, not just a successful build.

There used to be a plugins/Dockerfile.plugin template for building a plugin into its own container, run with network_mode: host against core's broker. That shape is retired and the template is gone. If you need a plugin on a different host, the SDK still supports it — point broker_host / broker_port in the plugin's [homecore] config at core's broker, and expose the broker accordingly.

Device type field

Register a device_type string to help UIs categorize devices correctly and filter scenes from device lists:

device_typeDescription
lightDimmable/color light
switchOn/off switch or outlet
sensorTemperature, humidity, door, motion, etc.
thermostatHVAC control
lockDoor lock
coverBlinds, shade, garage door
media_playerSpeaker, TV
sceneScene activator (device_type prevents it from appearing in device control lists)
picoButton-only remote (read-only, reports button events)
timerVirtual countdown timer

Testing your plugin

Use the virtual-device example as a reference for the full SDK lifecycle:

cargo run -p virtual-device -- --broker 127.0.0.1 --port 1883 --id plugin.virtual

Test that your plugin's devices appear:

curl -s http://localhost:8080/api/v1/devices \
-H "Authorization: Bearer $TOKEN" \
| jq '[.[] | select(.plugin_id == "plugin.my-device") | .name]'

Write a rule that reacts to your device's state changes and verify it fires in the event stream.