Adding Features
Step-by-step checklists for the most common extension points. Follow these in order — each step builds on the previous one.
Adding a new REST endpoint
-
Add the handler function in
crates/hc-api/src/handlers.rs(or a new file for a new resource type).pub async fn get_my_resource(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<MyResourceResponse>, ApiError> {
require_scope(&user, "read:my_resource")?;
let data = state.store.get_my_resource(id).await?;
Ok(Json(data.into()))
} -
Add the route in
crates/hc-api/src/lib.rsinsidebuild_router()..route("/api/v1/my-resource/:id", get(handlers::get_my_resource)) -
Add the scope to the
require_scopetable inhc-authif it's a new permission scope. Verify it incrates/hc-auth/src/scopes.rs. -
Add the
utoipaattribute for OpenAPI generation:#[utoipa::path(
get,
path = "/api/v1/my-resource/{id}",
params(("id" = Uuid, Path, description = "Resource ID")),
responses(
(status = 200, description = "Resource found", body = MyResourceResponse),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = [])),
tag = "my-resource"
)]
pub async fn get_my_resource(...) { ... } -
Register the path in
crates/hc-api/src/openapi.rsin thepaths(...)macro call. -
Write tests in the
#[cfg(test)]block at the bottom ofcrates/hc-api/src/handlers.rs(or in a test module for the new file). Use the existing test helpers to set up an in-memoryAppState.cargo test -p hc-api
Adding a new action type
-
Add the variant to the
Actionenum incrates/hc-types/src/rule.rs.#[serde(tag = "type", rename_all = "snake_case")]
pub enum Action {
// ... existing variants ...
MyNewAction {
device_id: String,
value: JsonValue,
#[serde(default)]
enabled: bool,
},
}Use
#[serde(default)]on optional fields so TOML/JSON without that field deserializes cleanly. -
Add the match arm in
crates/hc-core/src/executor.rsinrun_single_action().Action::MyNewAction { device_id, value, .. } => {
// implement the action here
// use ctx.publish to send MQTT commands
// use ctx.event_bus to fire events
// use ctx.state for state reads/writes
Ok(ActionOutcome::Ok)
} -
Add an
ActionTraceentry — returnOk(ActionOutcome::Ok)on success,Ok(ActionOutcome::Skipped)if the action is disabled, or propagate errors asActionOutcome::Error(msg). -
Write unit tests in
crates/hc-core/src/executor.rsor thetests/directory. Build a minimalExecutorContextwith mock state and verify the action's observable side effects.cargo test -p hc-core -
Update
devNotes.md— add the new action to the action type reference table indocs/devNotes.md. Document key fields, what it does, and any TOML example. -
Update this website — add the new action to
docs/rules/actions.md.
Adding a new trigger type
-
Add the variant to the
Triggerenum incrates/hc-types/src/rule.rs.pub enum Trigger {
// ... existing variants ...
MyNewTrigger {
some_field: String,
},
} -
Add trigger matching in
crates/hc-core/src/engine.rsin thematches_trigger()function. This function receives the incomingEventand theTriggerfrom the rule and returnsOption<TriggerContext>.Trigger::MyNewTrigger { some_field } => {
match event {
Event::Custom { event_type, payload } if event_type == some_field => {
Some(TriggerContext { ... })
}
_ => None,
}
} -
Decide which bus emits the event — if the trigger is fired by a scheduled or internal event, emit it on
pub_busfrom the scheduler or a manager. If it responds to a raw MQTT message, it reads frominternal_bus. -
Add catch-up logic in
crates/hc-core/src/scheduler.rsif the trigger is time-based and should fire on restart if missed. -
Write unit tests covering trigger match and no-match cases.
cargo test -p hc-core -
Update
devNotes.mdanddocs/rules/triggers.mdwith the new trigger type, required fields, and a RON example.
Adding a new condition type
-
Add the variant to the
Conditionenum incrates/hc-types/src/rule.rs.pub enum Condition {
// ... existing variants ...
MyNewCondition {
field: String,
expected_value: JsonValue,
},
} -
Add the evaluation logic in
crates/hc-core/src/engine.rsinevaluate_condition(). This function is synchronous — it must not callawait. Use thedevice_cacheDashMap for device state reads.Condition::MyNewCondition { field, expected_value } => {
let actual = /* read from cache or other in-memory source */;
let passed = actual == *expected_value;
ConditionTrace {
condition_type: "MyNewCondition".into(),
passed,
actual: Some(actual.clone()),
expected: Some(expected_value.clone()),
reason: format!("{field} == {actual} (expected {expected_value}) → {}", if passed { "PASS" } else { "FAIL" }),
}
} -
Write unit tests.
cargo test -p hc-core -
Update
devNotes.mdanddocs/rules/conditions.md.
Adding a new notification channel
-
Add the provider struct in
crates/hc-notify/src/. Implement theNotificationProvidertrait:#[async_trait]
pub trait NotificationProvider: Send + Sync {
async fn send(&self, title: &str, message: &str) -> Result<()>;
} -
Register the channel in
NotificationService::new()when the config contains the new channel type. -
Add config fields to the
[notify.channels]section definition insrc/main.rsor the config parser. -
Update
docs/events/notifications.mdwith setup instructions.
Adding a new device type constant
The device_type field is a free-form string, but canonical values are listed in crates/hc-types/src/device.rs and used for scene filtering. To add a new type:
- Add the constant string to the doc comment in
hc-types. - Update
docs/plugins/developing-plugins.mdwith the new device type in the table. - Update
docs/devices/scenes.mdif the new type affects scene filtering behavior.
Testing patterns
Unit test with mock state
#[tokio::test]
async fn test_my_action() {
let state = StateStore::in_memory().await.unwrap();
let (pub_tx, _) = tokio::sync::broadcast::channel(16);
let event_bus = EventBus { tx: pub_tx };
let ctx = ExecutorContext {
rule_id: Uuid::new_v4(),
state: state.clone(),
publish: None,
notify: None,
event_bus: Some(event_bus),
device_cache: Arc::new(DashMap::new()),
// ... other fields with Arc::new(DashMap::new()) defaults
trigger_context: TriggerContext::default(),
};
let action = Action::MyNewAction {
device_id: "test_device".into(),
value: json!({"on": true}),
enabled: true,
};
let outcome = run_single_action(&action, &ctx).await.unwrap();
assert!(matches!(outcome, ActionOutcome::Ok));
}
Integration test
The full-stack integration test in tests/integration_test.rs starts a real HomeCore instance on a random port, connects the virtual device plugin, creates a rule via the REST API, and asserts the rule fires end-to-end. Add new integration scenarios as separate #[tokio::test] functions in that file.
cargo test -p homecore --test integration_test
Dry-run via API
Test rule conditions without executing actions:
curl -s -X POST http://localhost:8080/api/v1/automations/{id}/test \
-H "Authorization: Bearer $TOKEN" | jq
The response includes actual, expected, elapsed_ms, and reason per condition.