- Rust 89%
- Shell 11%
| apps/homectl/src | ||
| config | ||
| crates | ||
| docs | ||
| legacy | ||
| scripts | ||
| .gitignore | ||
| AGENTS.md | ||
| bulbs-wheel | ||
| Cargo.toml | ||
| dance-bulbs | ||
| dance-room | ||
| README.md | ||
| TUTORIAL.md | ||
homectl
A unified home automation CLI with first-class support for TP-Link Kasa devices, LG TVs, and the existing color-wheel workflow.
Current commands:
db- initialize, migrate, inspect, and back up the household SQLite databaseiot- model execution endpoints, logical devices, profiles, capabilities, and commandshouse- add and list housesspace- add, list, and tree-view rooms/closets/zonesitem- add items and track placement historyreport- summarize a space and the items currently in ittask- define task templates and selector-driven blueprintsscene- list, plan, and apply configured scenescompletions- generate shell completion scriptsinvoke- invoke a capability on active profiles in a roombulb- one-shot control for bulbssync- RTMP/video-driven ambient lighting syncwheel- interactive terminal color pickerplug- smart plug controllgtv- LG WebOS controlcalibrate- repeatable inventory review and read-only device checklistdevice onboard wifi- inspect and provision a device through a Wi-Fi setup AP
Installation
cargo install --path .
# Installs `homectl` into ~/.cargo/bin
# Or build it locally:
cargo build --release
# Binary is at target/release/homectl
This repository is a Cargo workspace with the installable root package homectl, the app entrypoint in apps/homectl/src/main.rs, and shared libraries under crates/.
Output and scripting
All commands support the global output contract. Automatic mode is visual only for an interactive terminal and becomes pipe-safe plain output otherwise. Use --output human for the colorful terminal view, --output json (or --json) for a valid JSON event array, and --output raw (or --raw) for stable ASCII records. Progress and errors are written to stderr. --no-color, NO_COLOR, TERM=dumb, and piped output disable ANSI color. plug and bulb accept - as a newline-delimited stdin target; see docs/cli-output.md for examples.
Household database planning docs live in docs/household-db-plan.md and docs/household-v1.sql.
Hands-on walkthroughs live in TUTORIAL.md.
Wi-Fi onboarding
The provider-neutral onboarding surface is intentionally CLI-oriented:
homectl device onboard wifi interfaces
homectl device onboard wifi aps --interface wlp5s0
printf '%s\n' "$WIFI_PASSWORD" | homectl device onboard wifi provision \
--interface wlp5s0 \
--ap 'TP-Link_Setup' \
--device-ip 192.168.0.1 \
--ssid 'home-network' \
--password-stdin
Use the aps output to select the device-specific setup SSID, such as
TP-LINK_Light Strip_98FE, and pass that value with --ap. The provision
command treats an explicitly supplied --ap as the selected network, waits
for a stable association, verifies the temporary route to 192.168.0.1, and
queries the device's Kasa Wi-Fi scan before it reads the password. It refuses
to submit credentials if the adapter loses carrier or the Kasa device does
not respond.
The Linux backend is pure Rust and does not require nmcli or NetworkManager.
The interface listing protects adapters that own a default route; never select
the Ethernet adapter used for the host's Internet connection. The current
backend can scan and associate to an open setup AP, then owns a temporary
192.168.0.2/24 address for the common device address 192.168.0.1. These
operations require CAP_NET_ADMIN and are deliberately limited to a clean,
unused Wi-Fi adapter. DHCP, WPA authentication, post-join discovery, and
health checking remain unavailable. Provisioning refuses before reading the
password unless the temporary AP session is established and the device-side
preflight succeeds.
The legacy Kasa protocol uses the Python-Kasa-compatible netif request with a
softaponboarding fallback and compatibility key type 3. It applies to the
root device of legacy bulbs, plugs, and strips. Newer SMART/KLAP devices are
not claimed by this command yet. Passwords must come from stdin, are not printed
or persisted, and onboarding never changes the household config or database.
Household workflows
The new household commands use SQLite as the operational source of truth. By default, homectl stores the database at $XDG_DATA_HOME/homectl/homectl.db (or ~/.local/share/homectl/homectl.db). Override that with --db /path/to/file.db.
The household configuration belongs in $XDG_CONFIG_HOME/homectl/household.json (or ~/.config/homectl/household.json). Set HOMECTL_CONFIG to use another path. Do not commit this file: it can contain IP addresses, MAC addresses, and integration credentials.
Bootstrap a house model
homectl --db home.db db init
homectl --db home.db house add main-home --name "Main Home" --timezone America/Los_Angeles
homectl --db home.db space add living --house main-home --name "Living Room" --kind room
homectl --db home.db space add hall-closet --house main-home --name "Hall Closet" --kind closet
homectl --db home.db space tree --house main-home
Track items and placement history
homectl --db home.db item add monstera-1 --house main-home --name "Large Monstera" --kind plant
homectl --db home.db item place monstera-1 --space living --reason initial-setup
homectl --db home.db item move monstera-1 --to hall-closet --reason frost-warning
homectl --db home.db item show monstera-1
homectl --db home.db item history monstera-1
homectl --db home.db report space living
Define chore building blocks
homectl --db home.db task template add water-plant --title "Water plant" --group chores --emoji plant
homectl --db home.db task blueprint add all-plants --template water-plant \
--selector '{"tag":"plant","status":"active"}' \
--expand per-item
homectl --db home.db task template list
homectl --db home.db task blueprint list
Model IoT endpoints and profiles
These commands introduce the first normalized IoT concepts in the household DB:
- an
endpointis where execution happens, such as a direct LAN transport or acmddhost - a
deviceis the logical household thing you care about, such as a TV stack or lamp - a
profileis one concrete integration binding for that device - a
capabilityis the normalized surface exposed by that profile - a
commandis a named action path for command-backed integrations
homectl --db home.db item add media --house main-home --name "Media" --kind computer
homectl --db home.db item add living-tv --house main-home --name "Living Room TV" --kind television
homectl --db home.db iot endpoint add living-cmdd \
--house main-home \
--name "Living Room cmdd" \
--kind cmdd \
--locality host-local \
--transport http \
--host media \
--config '{"base_url":"http://media:8089","auth_mode":"hmac","queue":"display"}'
homectl --db home.db iot device add living-display \
--house main-home \
--name "Living Display" \
--kind monitor \
--item living-tv \
--host media \
--primary-integration cmdd
homectl --db home.db iot profile add living-display-cmdd \
--device living-display \
--endpoint living-cmdd \
--integration cmdd_display \
--role screen \
--execution command \
--config '{"profile":"living-room"}'
homectl --db home.db iot capability add \
--profile living-display-cmdd \
--capability switch \
--mode mixed \
--source cmdd \
--config '{"command":"monitor-off"}'
homectl --db home.db iot command add living-display-monitor-off \
--profile living-display-cmdd \
--name monitor-off \
--capability switch \
--endpoint-action monitor-off \
--args '{"queue":"display"}'
These commands intentionally cover the first delivery slice from docs/household-db-plan.md: database lifecycle, houses, spaces, items, placement history, task scaffolding, scenes, and the first IoT profile model. Policies, task scheduling, and floorplan rendering remain planned follow-on work.
Room-level invocation
After an item has been placed in a space, its device profile has been added,
and that profile has a switch capability, homectl invoke can resolve the
room's active targets:
homectl --db home.db invoke --space living --capability switch --value on --dry-run
homectl --db home.db invoke --space living --capability switch --value off
homectl --db home.db invoke --space living --zone overhead --capability switch --value off
homectl --db home.db invoke --space foyer --capability switch --value off
--value on and --value off are shorthand for JSON booleans. Direct Kasa
bulb and plug profiles are executable now when their profile config contains
{"ip":"..."} and their integration is kasa_bulb, tp_link_bulb,
kasa_plug, or tp_link_plug. cmdd-backed profiles execute registered
profile commands through crates/home-device-cmdd. Other integrations are
reported as unsupported rather than executed through an unsafe fallback.
For the old living-room-overhead off && foyer-light off style of control, use
the separate living-foyer-overheads-on and living-foyer-overheads-off
scenes. The corresponding living-foyer-office-overheads-on and
living-foyer-office-overheads-off scenes include office overheads as a
separate group. Keep the legacy shell scripts around only as reference while
confidence grows.
If you edit config/household.json, re-run homectl db seed --from config/household.json so the database picks up the new room or zone labels.
Bootstrap the household DB
homectl --db home.db db seed
This reads ~/.config/homectl/household.json by default, creates the house,
spaces, items, devices, profiles, and capability bindings, and seeds the initial
scene catalog. Use --from /path/to/household.json to import another file.
Host-backed display and media devices are modeled in the same household config:
use host_item to tie a screen or speaker to the computer that drives it,
execution_model: "command" to route through cmdd, and endpoint +
commands to register the exact monitor, media, or IR actions that are safe to
expose.
Scene and mode execution
Scenes are ordered lists of capability actions with JSON selectors. List the
configured catalog, then use plan before apply while migrating a legacy
workflow:
homectl scene list
homectl scene plan house-watch
homectl scene apply house-watch --dry-run
homectl scene apply house-watch
homectl scene plan house-off
homectl scene apply house-off
The initial mode catalog includes house-on, house-full, house-watch,
house-calm, house-calmer, house-dim, house-dimmer, and house-off.
These names intentionally describe whole-home behavior; the old
living-room <mode> script mixed whole-home and room-specific actions.
Room-specific scenes such as living-dim, kitchen-off, and foyer-off are
also available.
Selectors can target a house, one or more spaces, zones, device kinds, integrations, roles, or tags. Scene actions fail explicitly when they resolve no targets or when an adapter reports an error. Independent actions are kept as separate plan entries so the dry-run output can be compared against the legacy behavior during migration.
Shell completions
Generate completions from the same command definition used by homectl:
homectl completions bash > ~/.local/share/bash-completion/completions/homectl
homectl completions zsh > ~/.zfunc/_homectl
homectl completions fish > ~/.config/fish/completions/homectl.fish
homectl completions powershell > homectl.ps1
The completion singular alias is also accepted. Supported shells are Bash,
Elvish, Fish, PowerShell, and Zsh. The generated scripts include subcommands,
options, aliases, and declared enum values directly from the CLI definition.
The current seed covers bulbs, plugs, the modeled displays, pi0 IR control,
the office AV power identity, and native WebOS lgtv screen/power control.
Media-player control remains intentionally deferred. laptop and laptop_so
are intentionally absent from the seed and remain manually managed; homectl
must not attempt to control them.
remote0 scene migration
The supported mode actions under ../zwave/actions/remote0 have first-class
scene IDs. The mode buttons are aliases of the canonical house scenes. Media
button actions are intentionally deferred while their control model is being
redesigned. The three empty legacy button-4 scripts are represented by safe
pi0 /usr/bin/true no-op scenes.
| Legacy button action | Homectl scene | Behavior |
|---|---|---|
| button1 single | remote0-button1-single-press |
house-on |
| button1 double | remote0-button1-double-press |
house-watch |
| button1 triple | remote0-button1-triple-press |
house-calm |
| button1 hold release | remote0-button1-hold-release |
house-calmer |
| button2 single | remote0-button2-single-press |
house-off |
| button2 double | remote0-button2-double-press |
house-dim |
| button2 triple | remote0-button2-triple-press |
house-dimmer |
| button2 hold release | remote0-button2-hold-release |
house-off |
| button4 double | remote0-button4-double-press |
Safe no-op |
| button4 triple | remote0-button4-triple-press |
Safe no-op |
| button4 hold release | remote0-button4-hold-release |
Safe no-op |
The pi0 IR profile uses fixed EMOTIVA and LG_TV command arguments. The LG
TV action is a toggle and is available separately as
living-tv-power-toggle; it is not silently included in idempotent house
scenes.
Testing from pi0
Run the following from the repository checkout. The copy to pi0 is over SSH and includes the private household seed; keep its permissions restricted.
cargo build --release
ssh pi0 'mkdir -p ~/.config/homectl ~/.local/share/homectl'
scp target/release/homectl pi0:~/.cargo/bin/homectl
scp config/household.json pi0:~/.config/homectl/household.json
ssh pi0 'chmod 700 ~/.config/homectl && chmod 600 ~/.config/homectl/household.json && homectl db seed --from ~/.config/homectl/household.json'
Before invoking hardware, plan every migrated remote action:
for scene in \
remote0-button1-single-press remote0-button1-double-press \
remote0-button1-triple-press remote0-button1-hold-release \
remote0-button2-single-press remote0-button2-double-press \
remote0-button2-triple-press remote0-button2-hold-release \
remote0-button4-double-press \
remote0-button4-triple-press remote0-button4-hold-release
do
ssh pi0 "homectl scene plan $scene"
done
Then use this order for live testing, stopping after any unexpected result:
# Verify pi0 cmdd without changing household hardware.
ssh pi0 'homectl scene apply remote0-button4-double-press'
# Verify the known AV outlet -> five-second -> EMOTIVA IR sequence.
ssh pi0 'homectl scene apply living-av-off'
ssh pi0 'homectl scene apply living-av-on'
# Only run this when the current TV power state is known, because POWER toggles.
ssh pi0 'homectl scene apply living-tv-power-toggle'
# Walk the house modes in legacy remote order.
ssh pi0 'homectl scene apply remote0-button1-single-press'
ssh pi0 'homectl scene apply remote0-button1-double-press'
ssh pi0 'homectl scene apply remote0-button1-triple-press'
ssh pi0 'homectl scene apply remote0-button1-hold-release'
ssh pi0 'homectl scene apply remote0-button2-double-press'
ssh pi0 'homectl scene apply remote0-button2-triple-press'
ssh pi0 'homectl scene apply remote0-button2-single-press'
Use scene apply <scene> --dry-run in place of any live command when you want
to inspect the resolved targets again. house-on and house-full include the
five-second Emotiva action; because IR POWER is a toggle, do not repeat those
scenes unless the receiver's current state is understood.
If you edit config/household.json, re-run homectl db seed --from config/household.json before planning or applying scenes.
Usage
1. Set Mode (One-Shot Control)
Set one or multiple bulbs to a specific state. You can mix global defaults with specific overrides.
Syntax: homectl bulb [OPTIONS] TARGETS...
Where TARGET is IP or IP:KEY=VALUE,KEY=VALUE...
Supported Keys:
horhue: 0-360 (Color)sorsat: 0-100 (Richness)vorvalorbri: 0-100 (Brightness)on: Turn bulb onoff: Turn bulb off
Examples:
Turn all bulbs red:
homectl bulb --hue 0 --sat 100 --val 100 192.168.1.50 192.168.1.51
Turn one green and one blue:
homectl bulb 192.168.1.50:h=120,s=100,v=100 192.168.1.51:h=240,s=100,v=100
Set a default brightness but override color for specific bulbs:
homectl bulb --val 50 192.168.1.50:h=0 192.168.1.51:h=120
Turn off one bulb and dim another:
homectl bulb 192.168.1.50:off 192.168.1.51:v=10
2. Sync Mode (Ambient Lighting)
Syncs bulbs to an audio/video stream. Requires ffmpeg installed.
Syntax: homectl sync [OPTIONS] --bulbs IP:SCALE ...
Example:
homectl sync \
--source rtmp://localhost/live/stream \
--mode ambient \
--bulbs 192.168.1.50:1.0 192.168.1.51:0.5
Scene color knobs
The sync command now exposes tools to tame how each frame is reduced to a single color:
- By default,
syncsnapshots each target bulb's current state before playback starts and restores it on exit; use--hold-last-stateto leave the synced look active when the command ends. --color-mode <average|median|dominant|brightest>chooses the hue/saturation extraction strategy, while brightness is now derived from the whole frame so darker input naturally produces darker bulbs.- Very dim or weakly saturated pixels are automatically down-weighted for hue selection, which keeps near-black scenes from drifting into stray greens.
--scene-saturation-boost <0.0-1.0>lifts the computed saturation so dim scenes stay colorful.--scene-brightness-floor <0.0-1.0>keeps the light from collapsing to black during very dark shots.--scene-dark-threshold <0.0-1.0>tells the analyzer when a frame counts as a "dark scene," while--scene-dark-scale <0.0-1.0>scales the final brightness so those scenes stay moody rather than flickering to green.--target-luma <0.0-1.0>is a gentle normalization knob now; it nudges overall brightness without hard-replacing scene value.
Understanding HSV vs. Brightness
Smart bulbs generally work in the HSV color model. It's often clearer than RGB for lighting control.
Hue (H) - The "Color"
- Range: 0 to 360 degrees.
- Represents the pigment of the color.
- Examples:
- 0 = Red
- 120 = Green
- 240 = Blue
- 60 = Yellow
- 300 = Magenta
Saturation (S) - The "Richness"
- Range: 0 to 100%.
- Represents how "pure" the color is versus how "washed out" (white) it is.
- Examples:
- 100% = Pure, deep color (e.g., Neon Red).
- 50% = Pastel color (e.g., Pink).
- 0% = White/Gray (no color).
Value (V) - The "Brightness"
- Range: 0 to 100%.
- Represents the intensity of the light emitted.
- Also known as: Brightness.
- Note: In this tool,
val,bri, andvare synonyms. - Examples:
- 100% = Full brightness.
- 10% = Dim night light.
- 0% = Off (Black).
Common Scenarios
"I just want to dim the lights"
Keep Hue and Saturation as is (or don't specify them if partial updates are supported/defaults used) and reduce Value.
homectl bulb 192.168.1.50:v=20
"I want warm white light"
Set Hue to Orange/Yellow (~30-40), Saturation to a low value (~10-20), and Value to taste.
homectl bulb 192.168.1.50:h=35,s=15,v=80
"I want pure white"
Set Saturation to 0. Hue doesn't matter (usually).
homectl bulb 192.168.1.50:s=0,v=100
Multi-Computer Workflow
homectl has two distinct machine roles. Keeping them separate avoids copying the old per-host iot.json model everywhere.
Service Hosts
Service hosts run cmdd and expose hardware operations over the LAN. The current service hosts are media, home, office, office_so, and pi0. They need the cmdd binary, their ~/.config/cmdd/config.toml, and a systemd user service. They do not need to contain the household seed file just to serve display or IR commands. A service host may also be an interactive seat, but that is an optional second role and requires its own homectl install, private seed, and local database.
Deploy or update the service hosts from a trusted machine that has this repository and the private seed/config material:
export CMDD_API_KEY='your-shared-secret'
./scripts/deploy-cmdd.sh home media office office_so pi0
The deployment uses SSH for setup, installs cmdd as a user service, and starts it on port 8089. Normal homectl invocations do not use SSH; they send LAN HTTP requests to the static IPs in the private household config.
Interactive Seats
Interactive seats are machines from which a person or automation initiates a household action. Examples are pi0, laptop, laptop_so, phone, phone_so, and any always-on machine chosen to receive button events. An interactive seat needs:
- The
homectlexecutable - A private copy of
config/household.json - Its own local database at
~/.local/share/homectl/homectl.db
The database is local and disposable. The household JSON is the source of truth; the database is a seeded execution/indexing copy. An interactive seat does not need SSH access to a service host for normal invocations, but it must be on the LAN and able to reach the service-host IPs.
Laptop Setup
A normal Git clone does not include config/household.json, because that file is ignored and contains private addresses, hardware identifiers, and API credentials. Set up a laptop in this order:
git clone <repo-url> ~/src/repos/kasa-rs
cd ~/src/repos/kasa-rs
cargo install --path . --bin homectl
mkdir -p ~/.config/homectl
Copy the private seed file from the trusted configuration machine using a secure channel. For example, run this on the laptop and replace <admin-seat> and the source path with the actual values:
scp <admin-seat>:/path/to/kasa-rs/config/household.json ~/.config/homectl/household.json
chmod 600 ~/.config/homectl/household.json
homectl db seed --from ~/.config/homectl/household.json
Once seeded, run commands directly from the laptop:
homectl scene apply living-foyer-overheads-off
That scene replaces the common two-command sequence that turns off living and foyer overheads. Progressive variants are available for office and kitchen:
homectl scene apply living-foyer-office-overheads-off
homectl scene apply living-foyer-office-overheads-kitchen-dim
The laptop talks directly to the cmdd service-host IP over the LAN. SSH is only needed to provision the laptop, copy the private seed file, or perform maintenance.
Phone Usage
The preferred phone workflow is to SSH into an interactive seat that already has homectl, the private seed, and a seeded database:
ssh laptop 'homectl invoke --space living --zone overhead --capability switch --value off'
The target may be another prepared interactive seat such as home or media; use whichever machine is always available and has the local database. In this workflow, the phone does not need the household database, the API key, Rust, or a local copy of the private seed file.
Running homectl directly in Android Termux is an optional advanced setup, not the default. It requires installing Rust and Git, cloning the repository, securely transferring the ignored private seed file, and seeding a Termux-local database:
pkg install rust git
git clone <repo-url> ~/src/repos/kasa-rs
cd ~/src/repos/kasa-rs
cargo install --path . --bin homectl
mkdir -p ~/.config/homectl
# Securely transfer household.json into ~/.config/homectl/household.json.
chmod 600 ~/.config/homectl/household.json
homectl db seed --from ~/.config/homectl/household.json
Termux then invokes the same LAN endpoints as a laptop. It is more maintenance than the SSH workflow and should only be used when a local phone installation is specifically wanted.
Buttons and Automation
A button should call a stable wrapper or automation entrypoint on one chosen always-on interactive seat. The button should not contain the household database, the full device inventory, or the CMDD_API_KEY. The wrapper runs homectl locally using that seat's seeded database and sends the resulting request over the LAN.
A button integration should call a named scene on the chosen seat. The repository includes a stable wrapper for the common whole-home modes:
scripts/house-mode off
scripts/house-mode watch
scripts/house-mode --dry-run dim
The wrapper is equivalent to:
homectl scene apply house-off
homectl scene apply house-watch
Keep button logic semantic and stable, such as living-lights-off or house-night-mode; map those names to scene IDs in the wrapper rather than duplicating device actions. Keep device IPs, cmdd endpoint details, and command selection in the household config and database. If the button can only reach a host over SSH, SSH may be used as the transport to the wrapper, but that is an automation integration detail rather than a requirement of homectl or cmdd.
Keeping Seats in Sync
When the private household config changes, update the source copy first, then securely distribute the updated file and re-seed every interactive seat that runs homectl:
homectl db seed --from ~/.config/homectl/household.json
If a service-host IP changes, update the endpoint base_url in the private seed file, re-seed the interactive seats, and redeploy the affected cmdd host if its server config changed. These changes are expected to be rare, but the database should not be edited by hand to compensate for a changed seed file.
The seed operation is idempotent: it adds missing records and updates endpoint configuration without duplicating existing data.
Debugging a Remote Call
Before a cmdd invocation, homectl prints a directly pasteable curl command using $CMDD_API_KEY. Use it from the same LAN seat to distinguish an unreachable service, an allowlist problem, or a command execution failure.