A stateless, one-shot CLI for ECHONET Lite — the Japanese smart-home protocol.
enl talks to ECHONET Lite devices and nothing else. It opens a socket, sends a UDP frame, collects responses with a timeout, and exits. No daemon, no state, no scheduler.
It's built to be AI-native and UNIX-friendly: stdout is pure structured JSON (one command = one JSON object), so jq and LLM function-calling can consume it directly. Diagnostics and machine-readable errors go to stderr. Distinct exit codes let cron / n8n branch on the outcome.
Find every ECHONET Lite device on the LAN:
$ enl discover{
"devices": [
{
"ip": "192.0.2.10",
"count": 1,
"instances": ["026301"]
}
]
}026301 is the device's EOJ: 0263 is the class (electric shutter), 01 is the instance number. Send it a command:
$ enl set 192.0.2.10 026301 open_close_operation close{
"ip": "192.0.2.10",
"eoj": "026301",
"esv": "SetRes",
"result": "accepted",
"properties": [
{ "epc": "E0", "name": "open_close_operation" }
]
}Note: The actual output includes additional fields (pdc, edt_hex, etc.) in each property. Abbreviated here for clarity.
"result": "accepted" comes back, and the physical shutter closes. No cloud, no hub — one UDP frame on the LAN.
These are real outputs from an actual electric shutter. The full walkthrough — including how describe introspects what a device can do before you touch it — is written up at casaviva.dev/posts/echonet-lite-basics.
- Stateless / one-shot — open socket → send → collect with timeout → exit. No daemonization, no background process to babysit.
stdoutis pure structured JSON — one command, one JSON object, nothing else mixed in.jqand LLM function-calling can consume it directly with no scraping. Diagnostics and machine-readable errors are routed tostderrinstead (tracingstructured logs +{"error": {...}}JSON), keeping the two channels clean.- Never breaks on unknown devices/EPCs — unknown classes or EPCs are returned losslessly as raw hex and the command still succeeds. The decode dictionary is additive, never required.
See CLAUDE.md for the full rationale (why no tokio, why a hand-written codec, etc.).
Tasks are defined with Task (task lists them all).
task build # release build → target/release/enlPort 3610 must be owned by the process, so host networking is required (a bridge network can't receive device responses; discover uses a per-host unicast CIDR sweep plus a multicast probe).
task docker:build # build the runtime image
task docker:run -- discover
⚠️ If an ECHONET integration (Home Assistant, etc.) holds port 3610, it will steal the responses. Stop it while testing.enlprocesses coexist with each other (v1.5.0):listenbinds the multicast group address224.0.23.0:3610instead of the wildcard, one-shots bind0.0.0.0:3610withSO_REUSEADDR, and overlapping one-shots are serialized with a lock file (/tmp/enl-3610.lock(a fixed host-global path, so cron jobs, services, and interactive shells share the same lock); 30 ms interval, up to 2 s). Non-enlholders of 3610 still cause exit 5 after theEADDRINUSEretry window. Sample IPs use the RFC 5737 documentation range192.0.2.0/24— replace them with your real device IPs.
enl discover # find nodes on the LAN
enl get 192.0.2.10 013001 80 # read (home AC, 0x80 operation status)
enl set 192.0.2.10 013001 80 30 # write (turn ON)
enl describe 192.0.2.10 013001 # introspect the property map
enl listen --eoj 0291 --epc 80 # wait for one lighting on/off INF notification
enl schema get | jq . # print a subcommand's output JSON Schema (no network)
RUST_LOG=debug enl discover # send diagnostics to stderrepc accepts hex (80) or a canonical name (power / operation_mode / open_close_state …); names resolve class-specific first. The set edt also accepts a hex value (42) or an enum's semantic name (close / on …) — use hex for numeric or multi-byte values.
enl get 192.0.2.10 013001 power operation_mode room_temperature # read by name
enl set 192.0.2.10 026301 open_close_operation close # close the shutter by nameEvery binary value always includes edt_hex; value is added when the decode dictionary knows it, and name when the EPC name is known.
Full command reference for using enl day to day: every subcommand's flags and output schema, exit codes, dev commands, and source layout.
All subcommands accept the global -i <IPv4> / --iface <IPv4> flag — your local IPv4 address, used by discover to infer a /24 CIDR when --cidr is omitted and by listen to pick the multicast join interface.
discover [--cidr <CIDR>] [--timeout-ms 3000]—{"devices":[{"ip","count","instances":[...]}]}. Sends a unicast CIDR sweep plus one multicast probe (multicast is the standard ECHONET Lite discovery method, and letsdiscoverrun with no arguments). With neither--cidrnor-i, the sweep is skipped and only multicast is used.get <ip> <eoj> <epc...> [--multicast] [--timeout-ms 2000]—{"ip","eoj","esv","properties":[{"epc","name?","pdc","edt_hex","value?"}]}set <ip> <eoj> <epc> <edt> [--nowait] [--multicast] [--timeout-ms 2000]—{"ip","eoj","esv","result":"accepted"|"sent","properties":[...]}describe <ip> <eoj> [--multicast] [--timeout-ms 2000]—{"ip","eoj","esv","get_map":[{"epc","name?","values?"}],"set_map":[...],"inf_map":[...]}.valueslists the value range of enum-typed EPCs ({"41":"open","42":"close",...}); numeric / unsupported EPCs omit it.raw <ip> <deoj> <esv> [epc[:edt]...] [--seoj 05FF01] [--multicast] [--timeout-ms 2000]— send an arbitrary ESV/EPC/EDT frame.{"ip","sent_hex","response_hex","frame?":{...}}. SNA is returned asresponse_hexrather than an error (a debugging / unsupported-op escape hatch); aparse_erroris included if the response can't be parsed. EPC/EDT are hex-only here.
--multicast sends the frame to 224.0.23.0 instead of <ip>, while the response is still expected from <ip>. There is no automatic fallback; the flag is always explicit. The multicast egress interface is left to the routing table (a known limitation on multi-homed hosts). Beware with set: a multicast Set is processed by every device whose EOJ matches the target DEOJ, not just <ip> — only the reply from <ip> is reported.
--nowait sends SetI (0x60, no response requested) from an ephemeral port and exits 0 as soon as the datagram is sent, without binding port 3610 or taking the one-shot lock. Since v1.5.0 a plain set also coexists with a running listen, so --nowait is now just the fastest fire-and-forget path. The trade-off: device rejections (SetI_SNA) are undetectable, because devices reply to port 3610 regardless of the request's source port (verified with real devices). "result":"sent" means "sent", not "executed" — confirm via the INF that listen receives, or a follow-up get.
enl raw 192.0.2.10 013001 62 80 # raw Get 0x80
enl raw 192.0.2.10 013001 61 80:30 # raw SetC 0x80=ONlisten [--count 1] [--timeout-ms 60000] [--from <ip>] [--eoj <hex>] [--epc <hex>]— wait for INF/INFC state-change notifications (binds the multicast group address224.0.23.0:3610— the wildcard0.0.0.0:3610stays free, soget/setrun concurrently) and exit oncecountevents are collected or the timeout elapses (0= wait indefinitely).{"events":[{"ip","tid","seoj","deoj","esv","properties":[...]}]}.--eojmatches the source EOJ: 4 hex digits = class (0291= any single-function lighting), 6 = exact instance. Zero events → exit 3 (timeout), one or more → exit 0. INFC is acknowledged with INFC_Res. Still one-shot: it never daemonizes — drive it from an external loop:
# when any lighting announces a state change, turn on another light
while ev=$(enl listen --eoj 0291 --epc 80 --timeout-ms 0); do
echo "$ev" | jq -e '.events[0].properties[0].value.power == "on"' >/dev/null \
&& enl set 192.0.2.11 029101 power on
doneBecause the socket is bound to the group address, unicast-addressed INF/INFC cannot be received (state-change announcements are multicast, so this rarely matters); binding a multicast address is Linux-specific. Multiple concurrent listen processes each receive a copy of every notification.
schema [discover|get|set|describe|raw|listen]— print the JSON Schema (draft 2020-12) of a subcommand's stdout output. Omit the target to get every subcommand keyed by name ({"discover":{...},"get":{...},...}). Stateless, no network. The output schema is a stable contract, so LLM function-calling /jqcan fetch it programmatically.
Designed so cron / n8n can branch on the result.
| code | meaning |
|---|---|
| 0 | success |
| 2 | CLI argument error (clap default) |
| 3 | timeout (no response) |
| 4 | device rejected (SNA) |
| 5 | network / bind failure |
| 1 | invalid input detected by enl (usage), parse error, or other unexpected error |
task test # tests, incl. codec round-trips
task clippy # lint (-D warnings)
task fmt # rustfmt
task check # CI equivalent (fmt:check + clippy + test)
task docker:test # tests inside Docker (no toolchain needed)src/codec.rs— frame data model + parse/build. Hand-written, zero-dependency. Round-trip tests guard against parse/build asymmetry bugs.src/properties.rs— optional decode layer, incl. the property-map parser (two encodings for ≤15 vs ≥16 properties).src/net.rs— UDP socket layer. One-shots own0.0.0.0:3610withSO_REUSEADDR(serialized among themselves via flock,EADDRINUSEbinds retried for up to 2 s against non-enlholders);listenbinds the multicast group address224.0.23.0:3610so both coexist.discoveris a CIDR sweep plus a multicast probe (the standard ECHONET Lite discovery method).src/commands.rs— discover / get / set / describe / raw / listen.src/schema.rs— JSON Schema of each subcommand's stdout output (theschemasubcommand).src/error.rs— machine-readable errors + exit codes.src/main.rs— clap CLI.
The core (discover / get / set / describe) is verified against real devices.
- Expanded decode dictionary —
82spec version,8Amanufacturer code (major vendors named, unknown ones left as hex), electric shutter0263, home AC0130. Unknown EPCs still return raw hex. -
rawsubcommand — send arbitrary ESV/EPC/EDT, return raw response hex. - Output schema stabilization — each subcommand's stdout JSON Schema is published via the
schemasubcommand and machine-fetchable, so LLMs /jqcan pin to it across versions.
enl is the first tool in a small family of protocol-specific CLIs (enl / mat / ais / mando) that a casa layer orchestrates into one smart home. The story and the reasoning are written up on casaviva:
- ECHONET Liteとは?家のシャッターを3コマンドで動かす — this demo, in full: discover → describe → set, and how ECHONET Lite relates to Matter.
- 自作Rust製ツール5本でスマートホームを一元管理する全体構成 — the overall architecture
enlfits into.