Dev Workflow
Standard dev session
For multi-component work (core + plugins + UI together), the easiest
launcher is hc-scripts/run-dev.sh from the workspace root:
./hc-scripts/run-dev.sh # build all + run core (debug)
./hc-scripts/run-dev.sh --release # release build
./hc-scripts/run-dev.sh --no-build # skip cargo, use existing binaries
./hc-scripts/run-dev.sh --no-pull # skip `git pull` in every repo
The script:
- Pulls every component repo listed in
workspace.toml(core, the plugins, the SDKs, the clients). - Runs
cargo updateagainst each meta-layout workspace (plugins/,clients/,sdks/, plus core). - Builds plugins via the meta-layout (
cargo build --manifest-path plugins/Cargo.toml -p <name>) — binaries land atplugins/target/debug/<name>(the shared workspace target dir, not per-plugin). - Builds homecore.
- Runs
homecore --config core/config/homecore.dev.toml.
The dev config's [[plugins]] blocks point at
../plugins/target/debug/<name> so homecore launches whatever
run-dev.sh just built. If a code change doesn't seem to take
effect after restart, the most common cause is a stale binary at the
old per-plugin target path — the meta-layout reshape moved outputs
to the shared dir.
For tighter loops on a single component, you can also run cargo directly. Three terminal windows is enough:
Terminal 1 — the server
cd homeCore/core
cargo run -p homecore
HomeCore uses the current working directory as its base. Running
from core/ picks up config/homecore.toml and writes data to
data/ right there.
On first run, copy the admin password from the startup output.
To restart after a code change: Ctrl-C, then cargo run -p homecore
again. Only changed crates recompile.
Terminal 2 — virtual device (optional)
Start after the server is up:
cargo run -p virtual-device -- --broker 127.0.0.1 --port 1883 --id plugin.virtual
Terminal 3 — API calls
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YOUR_PASSWORD"}' | jq -r .token)
Optional: Terminal 4 — live event stream
websocat "ws://localhost:8080/api/v1/events/stream?token=$TOKEN"
Typical loop
- Make a code change
Ctrl-Cin Terminal 1cargo run -p homecore(only changed crates recompile)- Re-test with
curlin Terminal 3 - Check Terminal 1 for log output
Pre-PR check (just check)
Every Rust component (core + each plugin) ships an aligned Justfile
with the same recipes. From the component's repo root:
just # default: list all recipes
just check # fmt + clippy + test — mirrors what CI runs
The clippy invocation matches hc-scripts/.github/workflows/rust-ci.yml
exactly:
cargo clippy --all-targets --all-features -- \
-D warnings \
-A clippy::too_many_arguments \
-A clippy::type_complexity \
-A clippy::result_large_err
So if just check passes locally, CI passes too. The three allowed
lints are stylistic-only allowances — see
hc-scripts/.github/workflows/rust-ci.yml for the rationale on each.
Recipe reference
| Recipe | What it runs | When to use |
|---|---|---|
just (default) | just --list | See available recipes |
just check | fmt → clippy → test | Before pushing |
just fmt | cargo fmt --all -- --check | CI-equivalent format check |
just fmt-fix | cargo fmt --all | Apply formatting in place |
just clippy | The CI-mirror clippy command above | Lint pass |
just test | cargo test --all-features | Test pass |
just build | cargo build | Debug build |
just build-release | cargo build --release | Release build |
just run | cargo run -- --config config/config.dev.toml | Run the plugin against your dev config |
just clean | cargo clean | Wipe the target/ tree |
just package | ../../hc-scripts/build-archive.sh --kind plugin --name <self> --build | Produce a dist/<name>-<ver>-<plat>.tar.gz fragment |
The package recipe wraps hc-scripts/build-archive.sh — same script
CI uses to produce release tarballs. BIN_NAME auto-derives from the
directory name, so the same Justfile ships byte-identical to every
plugin without per-repo customization. Override the script path with
HC_SCRIPTS=/path/to/hc-scripts just package if your checkout doesn't
follow the meta-layout.
Install just once if needed:
cargo install just
Cargo commands reference
For tighter feedback loops on a single crate, fall back to cargo
directly:
# Fastest feedback — check compiles without building
cargo check --workspace
cargo check -p hc-core # single crate
# Build
cargo build --workspace
cargo build --release # optimized binary
# Run
cargo run -p homecore
cargo run -p homecore --release
# Run virtual device
cargo run -p virtual-device -- --broker 127.0.0.1 --port 1883 --id plugin.virtual
# Test
cargo test --workspace
cargo test -p hc-auth # single crate
cargo test -p hc-core # rule engine tests
cargo test -p hc-api # API + WebSocket auth tests
cargo test -p homecore --test integration_test # full-stack integration test
cargo test -p hc-core repeat_until # specific test by name
cargo test --workspace -- --nocapture # show println! output
# Watch for changes (install once: cargo install cargo-watch)
cargo watch -x "check -p hc-core"
cargo watch -x "test -p hc-api"
Tests
cd core
cargo test --all-features # the whole workspace, unit + integration
cargo test -p hc-core # one crate
cargo test --test integration_test # one integration binary
Roughly 450 test functions across core at the time of writing, so a
specific number here would be wrong within a week — cargo test is the
count that matters. What the layers cover:
| Layer | What its tests are for |
|---|---|
hc-auth | Password hashing, JWT issue/validate/expire/tamper, role→scope mapping, refresh rotation and reuse detection |
hc-types | The rule vocabulary, derived from the enums, so a client's mirror can be checked against the real thing |
hc-core | Trigger matching, condition evaluation, the action executor (Delay, RepeatUntil, Parallel), the scheduler |
hc-state | Device registry round-trips, history queries |
hc-api | Scope enforcement per route, WebSocket auth, session invalidation, atomic rule import, and the OpenAPI-vs-router check |
homecore (integration) | The full stack: virtual device → MQTT → rule fires → command back |
Plugins and SDKs carry their own suites; hc-captest exists specifically
to assert the capability spec end to end against a running core.
Web UI development
The web UI is hc-web — Flutter, its own repo, built and served separately from core. You need the Flutter SDK, not cargo.
The app calls /api/v1 as a relative path on purpose (one build
artifact runs anywhere, with no build-time configuration), and core sends
no CORS headers at all — so a browser cannot call core cross-origin.
Something has to make the API same-origin with the app. In production
that is the nginx inside hc-web's container; in development it is a
60-line proxy in the repo:
# Terminal 1 — proxy: serves the app and forwards /api/v1 to a real core
HOMECORE_URL=http://127.0.0.1:8080 node tool/dev.mjs # -> http://localhost:3001
# Terminal 2 — Flutter's dev server, which does the incremental compile
flutter run -d web-server --web-port 5001 --web-hostname 127.0.0.1
Press R in terminal 2 to hot-restart: seconds, rather than a
four-minute image rebuild. WebSockets are proxied too — without them the
app loads and then shows stale state forever, because
/api/v1/events/stream is how it learns the house changed.
scripts/build.sh produces the production bundle
(flutter build web --release into build/web), which the repo's
Dockerfile wraps in the nginx image.
The retired Leptos client
[web_admin] in homecore.toml still exists and still serves a
directory of static files from core's root path. It was built for the
Leptos/WASM admin that core used to bake in; that client is retired, the
setting defaults to off, and the standard deployment leaves it off.
run-dev.sh --webui still builds and trunk serves that retired client.
It is not the flag you want for UI work — use the two terminals above.
Isolated dev environment
Use a throwaway directory to keep state separate from your main installation:
HOMECORE_HOME=/tmp/hc-dev cargo run -p homecore
# or
cargo run -p homecore -- --home /tmp/hc-dev
Reset it cleanly:
rm -rf /tmp/hc-dev/data/
HOMECORE_HOME=/tmp/hc-dev cargo run -p homecore
Debugging with RUST_LOG
# Rule engine internals
RUST_LOG=info,hc_core::engine=debug,hc_core::executor=debug cargo run -p homecore
# MQTT traffic
RUST_LOG=info,hc_mqtt_client=debug cargo run -p homecore
# State bridge
RUST_LOG=info,hc_core::state_bridge=debug cargo run -p homecore
# Everything (very noisy)
RUST_LOG=trace cargo run -p homecore
# All rule-engine related
RUST_LOG=info,hc_core=debug,hc_mqtt_client=debug,hc_broker=warn cargo run -p homecore
Common compiler errors
"cannot borrow self as mutable because it is also borrowed as immutable"
Usually means you're holding a reference across an async await point. The fix is to clone the value before the await:
// ❌ fails
let name = &self.state.get_name();
self.state.update().await?; // borrow problem
// ✅ fix
let name = self.state.get_name().clone();
self.state.update().await?;
"future cannot be sent between threads safely" / Send bound
An async fn that captures a non-Send type (e.g. Rc, RefCell) passed to tokio::spawn. Either use Arc<Mutex<>> instead, or restructure so the non-Send value is dropped before the first await.
"the trait bound is not satisfied for Box<dyn Future>"
Recursive async functions need Box::pin:
fn run_action(action: Action) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async move {
match action {
Action::Parallel { actions } => {
for a in actions {
run_action(a).await?; // ← recursive call OK inside Box::pin
}
}
// ...
}
Ok(())
})
}
Deadlock in RwLock
Never hold a write lock across an await. Take the data you need, drop the lock, then do async work:
// ❌ deadlock
let mut rules = self.rules.write().await;
rules.push(new_rule);
persist_to_db(&new_rule).await?; // deadlock — write lock still held
// ✅ fix
{
let mut rules = self.rules.write().await;
rules.push(new_rule.clone());
} // lock dropped here
persist_to_db(&new_rule).await?;
Adding a new REST endpoint
- Add handler function in
crates/hc-api/src/handlers.rs(or a new file for new resource types) - Add route in
crates/hc-api/src/lib.rsbuild_router() - Add scope extractor parameter to the handler signature
- Add
utoipa#[utoipa::path(...)]attribute for OpenAPI generation - Register in
openapi.rsif using the OpenAPI macro router - Write a test in
hc-apimodule tests
Adding a new action type
- Add the variant to
Actionenum incrates/hc-types/src/rule.rs - Add serde deserialization (usually automatic with
#[serde(tag = "type", rename_all = "snake_case")]) - Add match arm in
executor.rsrun_single_action() - Write unit tests in
hc-coremodule tests - Add to the action type reference in
docs/devNotes.md
Integration test
The integration test starts a full HomeCore instance on a random port:
cargo test -p homecore --test integration_test
It:
- Starts HomeCore with an in-memory config (temp files under
/tmp/) - Connects the virtual device plugin
- Creates a rule via the REST API
- Publishes a state change via MQTT
- Asserts the rule fired and the expected command was published
To add a new integration test scenario, add a test function in tests/integration_test.rs.