Skip to main content

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:

  1. Pulls every component repo listed in workspace.toml (core, the non-Rust SDKs and plugins, the clients).
  2. Runs cargo update in each Rust workspace it finds.
  3. Builds the server. Every Rust plugin is a member of core's workspace, so they are built by the same command — binaries land in core/target/debug/<name> with no separate plugin build step.
  4. Runs homecore --config core/config/homecore.dev.toml.

The dev config's [[plugins]] blocks point at target/debug/<name>, relative to HOMECORE_HOME (which in development is core/), so homecore launches exactly what was just built.

run-dev.sh is not sandboxed

It starts a real homeCore with the real plugin configs, so it connects to whatever hubs, bridges and cloud accounts those configs name. That is usually what you want on a dev box and emphatically not what you want while testing something destructive. To work in isolation, write a config with its own ports, its own [storage] paths and no [[plugins]], and pass it with --config.

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​

  1. Make a code change
  2. Ctrl-C in Terminal 1
  3. cargo run -p homecore (only changed crates recompile)
  4. Re-test with curl in Terminal 3
  5. 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​

RecipeWhat it runsWhen to use
just (default)just --listSee available recipes
just checkfmt → clippy → testBefore pushing
just fmtcargo fmt --all -- --checkCI-equivalent format check
just fmt-fixcargo fmt --allApply formatting in place
just clippyThe CI-mirror clippy command aboveLint pass
just testcargo test --all-featuresTest pass
just buildcargo buildDebug build
just build-releasecargo build --releaseRelease build
just runcargo run -- --config config/config.dev.tomlRun the plugin against your dev config
just cleancargo cleanWipe the target/ tree
just package../../hc-scripts/build-archive.sh --kind plugin --name <self> --buildProduce 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 works for every plugin without customization. Override the script path with HC_SCRIPTS=/path/to/hc-scripts just package if hc-scripts is not checked out beside core.

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:

LayerWhat its tests are for
hc-authPassword hashing, JWT issue/validate/expire/tamper, role→scope mapping, refresh rotation and reuse detection
hc-typesThe rule vocabulary, derived from the enums, so a client's mirror can be checked against the real thing
hc-coreTrigger matching, condition evaluation, the action executor (Delay, RepeatUntil, Parallel), the scheduler
hc-stateDevice registry round-trips, history queries
hc-apiScope 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​

  1. Add handler function in crates/hc-api/src/handlers.rs (or a new file for new resource types)
  2. Add route in crates/hc-api/src/lib.rs build_router()
  3. Add scope extractor parameter to the handler signature
  4. Add utoipa #[utoipa::path(...)] attribute for OpenAPI generation
  5. Register in openapi.rs if using the OpenAPI macro router
  6. Write a test in hc-api module tests

Adding a new action type​

  1. Add the variant to Action enum in crates/hc-types/src/rule.rs
  2. Add serde deserialization (usually automatic with #[serde(tag = "type", rename_all = "snake_case")])
  3. Add match arm in executor.rs run_single_action()
  4. Write unit tests in hc-core module tests
  5. 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:

  1. Starts HomeCore with an in-memory config (temp files under /tmp/)
  2. Connects the virtual device plugin
  3. Creates a rule via the REST API
  4. Publishes a state change via MQTT
  5. 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.