Architecture and protocols
Modules
| File / module | Purpose |
|---|---|
src/main.rs |
Async runtime entry point (denki::app::run()) |
src/app/doctor/report.rs |
Pure diagnostic report and next-step guidance, independent of device I/O and rendering |
src/app/ |
CLI dispatch, scan/info flows, shared helpers, integration tests |
src/cli.rs |
Clap argument definitions for all subcommands |
src/commands/ |
Power, lighting, and energy command handlers |
src/admin/ |
Aliases, device admin commands, login, shell completions |
src/resolve.rs |
Device name/IP resolution; require_kasa protocol guard |
src/devices/ |
DeviceKind, detect_kind, capability guards (can_*), devices.toml registry |
src/cipher.rs |
XOR autokey cipher: encode (TCP, length-prefixed) / encode_raw (UDP) |
src/transport.rs |
Kasa TCP send() and UDP broadcast_each() |
src/klap.rs |
KLAP handshake + AES-128-CBC session for Tapo devices |
src/hosts.rs |
Alias registry — maps friendly names to IP, protocol, and optional stable identity |
src/error.rs |
Typed error categories for library and automation |
src/output.rs |
Task-local CLI result collection and versioned JSON envelope |
src/energy.rs |
Measurement normalization and validated history |
src/commands/watch.rs |
Bounded streaming energy polling with CSV/JSONL output |
src/creds.rs |
Tapo credentials from env vars or denki login |
src/fmt.rs |
Formatting helpers — duration, on_time, parse_year_month, current_year_month |
src/bulb.rs |
Bulb and light-strip sysinfo parsing |
src/plug.rs |
Plug sysinfo parsing + ENE feature detection |
src/dimmer.rs |
HS220 dimmer sysinfo parsing |
src/strip.rs |
HS300/KP303 power strip sysinfo + per-outlet state |
src/tapo.rs |
Tapo get_device_info response parsing |
src/tapo_client.rs |
Upstream TPAP/KLAP plug adapter and identity-checked operations |
src/tapo_client/ |
Redacted error translation and offline adapter tests |
src/tapo_vendor/ |
Bundled upstream transport with streaming limits and response authentication |
src/ops.rs |
All API calls — bulb_set_*, relay_*, device_*, tapo_*, strip_* |
src/effects.rs |
Light-strip effect helpers |
src/display/ |
Colored terminal output for all device types |
src/lib.rs |
Library module graph |
Extending device support
- add the API call in
src/ops.rs - add or update the parser in the matching device module (
bulb.rs,plug.rs, etc.) - add a capability guard in
src/devices/and wire it throughsrc/commands/orsrc/admin/viasrc/app/dispatch.rs - update
devices.tomlto reflect the new capability - update the README and inline docs so behavior and help text stay aligned
- add a regression test for the parser or capability guard (
src/app/tests/for CLI/capability coverage)
Protocol details
Kasa — port 9999
XOR autokey cipher. Starting key 0xAB (171); each output byte becomes the key for the next byte.
- Encrypt:
c = p ^ key; key = c - Decrypt:
p = c ^ key; key = c - TCP:
encode()prepends a 4-byte big-endian length; receiver reads that many cipher bytes then callsdecode() - UDP:
encode_raw()for send (no prefix),decode()for receive — adding a prefix causes garbage - Connect timeout: 5 seconds
- Exchange deadline: 10 seconds for the complete write/read exchange after connecting
- Response limit: 1 MiB, checked before allocating the response body
- Mutations: validate the requested namespace/method's
err_code; missing, malformed, or nonzero codes are errors
KLAP (Tapo) — port 80
AES-128-CBC over plain HTTP. Uses raw TcpStream — some Tapo firmware returns 400 for standard HTTP clients. Each HTTP request uses one shared 10-second deadline across connection, writes, headers, and body. Response bodies are capped at 1 MiB before allocation.
Auth hash: SHA256(SHA1(username) || SHA1(password))
Handshake:
1. POST /app/handshake1 — send 16 random bytes (local_seed); receive remote_seed || server_hash; verify SHA256(local_seed || remote_seed || auth_hash) == server_hash; save TP_SESSIONID cookie
2. POST /app/handshake2 — send SHA256(remote_seed || local_seed || auth_hash)
Key derivation:
- key = SHA256("lsk" || local_seed || remote_seed || auth_hash)[..16]
- iv_base = SHA256("iv" || local_seed || remote_seed || auth_hash)[..12]
- seq = i32::from_be_bytes(iv_full[28..32])
- sig = SHA256("ldk" || local_seed || remote_seed || auth_hash)[..28]
Per request: POST /app/request?seq=N; seq += 1; iv = iv_base || seq.to_be_bytes(); body = SHA256(sig || seq || cipher) || cipher
Response: require a 32-byte tag followed by nonempty, block-aligned ciphertext.
Verify SHA256(sig || current_sequence || ciphertext) in constant time before
AES-CBC decryption. Reject tampered, replayed, or cross-session frames with
integrity_failed. Handshake server-proof comparisons also use constant time.
ops.rs Naming Conventions
| Prefix | Used for |
|---|---|
relay_on / relay_off |
set_relay_state — plugs, dimmers, strips |
device_* |
emeter / schedule / time / LED — spans all relay devices |
bulb_set_* |
brightness, color-temp, color via smartlife.iot.smartbulb.lightingservice |
strip_* |
per-outlet commands using context.child_ids |
tapo_* |
KLAP session operations |
hosts.rs Public API
The scan command loads hosts.json before discovery and stops if it cannot be loaded.
It reconciles observations in memory, preserving existing aliases. When changes
need saving, it acquires the registry lock and reloads the file. If that snapshot
differs from the original, it returns registry_conflict without writing; otherwise
it atomically replaces the file. Network discovery never holds the file lock.
| Function | Purpose |
|---|---|
load() |
Read hosts.json from disk; returns BTreeMap<String, HostEntry> |
save(map) |
Explicit full replacement under a lock; atomic write |
save_if_unchanged(previous, map) |
Locked comparison and atomic commit; rejects stale scan snapshots |
save_if_new_in(name, ip, map) |
Legacy no-op; unauthenticated discovery cannot enroll targets |
require_group_approval(matches) |
Reject the whole group if a Kasa target lacks explicit enrollment |
lookup_by_ip_in(ip, map) |
Reverse lookup alias name from an in-memory map |
lookup(name) |
Exact-then-substring match; errors on ambiguity |
normalize(s) |
Lowercase + collapse non-alphanumeric to spaces for fuzzy matching |
Automation and identity checks
Kasa read and write operations share response-envelope validation; each requested
namespace/method must report an integer zero err_code. Tapo operations require
integer zero error_code. Device info validates explicit state before toggle or
control. Sysinfo/Tapo info checks stored identities at the destination address.
Authenticated Tapo observations can reconcile identities after DHCP moves.
Kasa IDs are unauthenticated: a matching ID from a different address returns
identity_rebind_required without changing any alias. An operator must verify the
new address independently and explicitly update it with denki alias NAME IP.
Legacy aliases learn identity only at their saved address during scan. Kasa identity
checks detect mismatches but cannot authenticate a peer that copies a device ID.
CLI handlers record structured results in a task-local output context. Human
rendering routes to stderr under --json; dispatch emits the schema-v2 envelope
once, including failures. Group results are collected before returning partial
failure. Info uses its normal data fetch and rendering path in both modes.
Energy text, JSON, and streaming share normalized Measurement values; watch
retains an EnergyReader between samples to reuse KLAP sessions. Streaming has
its own schema-v1 records and never accumulates the complete sample history.
Local storage
src/storage.rs provides private same-directory temporary files, file sync,
atomic replacement, and parent-directory sync on Unix. A write or rename failure
before publication leaves the previous destination intact and removes the temporary
file. A directory-sync failure after publication is reported, but the replacement
may already be visible. Readers need no lock and see complete snapshots.
Registry edits acquire an OS advisory lock on hosts.json.lock before reading and
hold it through replacement. Credential saves similarly use credentials.json.lock.
Lock sidecars must never be unlinked: the stable inode coordinates all writers.
The OS releases the lock when a process exits or is killed. save(map) explicitly
replaces a registry; callers modifying an earlier load() snapshot should use
save_if_unchanged to avoid overwriting another process's edits.
Temporary files start with mode 0600 on Unix, before any credential bytes are written; atomic replacement also tightens permissions on previously permissive credential files. Other platforms inherit their filesystem's access controls. This provides local file protection, not encryption at rest.
Tapo automatic mode
Protocol::Tapo selects the bundled, hardened tapo 0.12.2 implementation; Protocol::Klap still
selects Denki's original KLAP implementation. The upstream p100 constructor
selects a generic plug handler and negotiates TPAP or KLAP; it is not a P125 model
assertion or a forced TPAP transport. Denki's adapter converts already-decoded
upstream fields directly, without running the KLAP base64 decoder again.
The adapter reuses creds::load, sets 10-second upstream request timeouts and a
30-second overall operation deadline, and makes no automatic retries. Upstream
errors become stage-specific, redacted messages. P125 power writes require the
model/type guard in src/devices/tapo.rs, then verify state and device identity
in the same session. devices.toml tracks tapo_auto_supports and
tapo_auto_verified separately from original-client capabilities and verification.
Every buffered automatic-mode HTTP control response uses the same 1 MiB streaming cap. Excessive advertised length is rejected before reading, and actual chunks are checked before extending the buffer, including responses without a length. Oversized negotiation responses fail rather than trigger protocol fallback. Peer-selected SHA-crypt work is restricted to 1,000–100,000 rounds (default 5,000); malformed rounds and non-crypt salts are rejected before hashing. Credential transforms and SPAKE2 proof work run off the async executor, with fixed work caps because a started blocking task cannot be cancelled by the operation timeout. Redirects and ambient HTTP proxies are disabled so authentication stays at the selected device endpoint. Automatic-mode KLAP responses authenticate their MAC against the current request sequence before decryption, just like explicit KLAP.
The upstream source is embedded under src/tapo_vendor/, with its MIT license,
provenance, and security regression tests. This is intentional: a versioned Cargo
path override would be discarded during registry packaging and silently restore
the vulnerable upstream behavior. Package preflight must compile the embedded
source. Remove the copy only once upstream offers equivalent protections. Device
TLS certificates remain self-signed; protocol-level authentication is required,
not a claim that the local endpoint has a publicly trusted certificate. These
hardening changes have offline coverage, not new hardware-verification evidence.
Untrusted presentation and discovery
Human-readable device strings are escaped before applying trusted terminal styles. The original values remain available in JSON and in protocol/configuration data. Shell hints use POSIX quoting; controls and empty targets suppress the hint rather than silently changing the command target. Unauthenticated Kasa scan observations never generate physical-control hints. Discovery validates each reply before reserving its source for deduplication, bounds retained results, and cannot create an alias at an address saved with a different protocol. Neither a copied ID nor a matching address authenticates Kasa; a trusted LAN is still required.
Malformed weekday arrays are reported as invalid instead of indexing outside the seven-day label table or pretending the schedule is valid.
Local trust and authentication policy
New Kasa observations never add aliases. Only explicit set / denki alias
sets group_approved; v1 and older v2 entries default to false. Identity learning
at a saved address never upgrades this flag. Group commands validate every Kasa
target before any device work; Tapo's authenticated targets are unchanged.
This protects local target selection, not the inherently unauthenticated Kasa wire.
DENKI_REQUIRE_TPAP=1 rejects non-TPAP handshakes at the original and bundled
transport boundaries, including refresh. Default compatibility mode warns before
KLAP, whose captured fast-hash handshake can be checked against password guesses
offline. The protocol cannot be repaired unilaterally by this client. CLI login
uses hidden prompting or bounded redirected stdin, never a positional password.
Configuration trust boundary
src/storage/security.rs centralizes bounded reads, Unix ownership/permission
checks, and safe file opening. Registry reads require integrity (no group/other
writes); credential reads additionally require confidentiality (no group/other
permissions). Both use the checked file descriptor, reject hard links and
non-regular files, and open with O_NOFOLLOW | O_NONBLOCK on Unix. Lock sidecars
use the same file validation before locking. New directories use mode 0700.
Ancestor directories must belong to the current effective user or root and must
not be group/other writable. Root-owned sticky temporary directories are allowed
as ancestors, but never as the immediate configuration directory. Root-managed
system symlinks (for example macOS /var) are checked through their targets;
user-owned symlink directories are rejected. Root and same-UID processes remain
trusted. These Unix checks do not constitute a Windows ACL audit.
The existing atomic replacement path can safely replace a permissive credential file during login without reading it. It never follows a destination symlink. Reads are limited to 16 KiB for credentials and 4 MiB for the alias registry.