Skip to content

nogu3/enl

Repository files navigation

enl

A stateless, one-shot CLI for ECHONET Lite — the Japanese smart-home protocol.

License: MIT Rust

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.

Demo

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.

Why enl

  • Stateless / one-shot — open socket → send → collect with timeout → exit. No daemonization, no background process to babysit.
  • stdout is pure structured JSON — one command, one JSON object, nothing else mixed in. jq and LLM function-calling can consume it directly with no scraping. Diagnostics and machine-readable errors are routed to stderr instead (tracing structured 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.).

Install

From source (requires the Rust toolchain)

Tasks are defined with Task (task lists them all).

task build            # release build → target/release/enl

Docker (no local toolchain)

Port 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. enl processes coexist with each other (v1.5.0): listen binds the multicast group address 224.0.23.0:3610 instead of the wildcard, one-shots bind 0.0.0.0:3610 with SO_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-enl holders of 3610 still cause exit 5 after the EADDRINUSE retry window. Sample IPs use the RFC 5737 documentation range 192.0.2.0/24 — replace them with your real device IPs.

Quickstart

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 stderr

epc 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 name

Every binary value always includes edt_hex; value is added when the decode dictionary knows it, and name when the EPC name is known.

Reference

Full command reference for using enl day to day: every subcommand's flags and output schema, exit codes, dev commands, and source layout.

Subcommands & output schemas

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 lets discover run with no arguments). With neither --cidr nor -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":[...]}. values lists 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 as response_hex rather than an error (a debugging / unsupported-op escape hatch); a parse_error is 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=ON
  • listen [--count 1] [--timeout-ms 60000] [--from <ip>] [--eoj <hex>] [--epc <hex>] — wait for INF/INFC state-change notifications (binds the multicast group address 224.0.23.0:3610 — the wildcard 0.0.0.0:3610 stays free, so get/set run concurrently) and exit once count events are collected or the timeout elapses (0 = wait indefinitely). {"events":[{"ip","tid","seoj","deoj","esv","properties":[...]}]}. --eoj matches 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
done

Because 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 / jq can fetch it programmatically.

Exit codes

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

Development

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)

Project layout

  • 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 own 0.0.0.0:3610 with SO_REUSEADDR (serialized among themselves via flock, EADDRINUSE binds retried for up to 2 s against non-enl holders); listen binds the multicast group address 224.0.23.0:3610 so both coexist. discover is 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 (the schema subcommand).
  • src/error.rs — machine-readable errors + exit codes.
  • src/main.rs — clap CLI.

Roadmap

The core (discover / get / set / describe) is verified against real devices.

  • Expanded decode dictionary — 82 spec version, 8A manufacturer code (major vendors named, unknown ones left as hex), electric shutter 0263, home AC 0130. Unknown EPCs still return raw hex.
  • raw subcommand — send arbitrary ESV/EPC/EDT, return raw response hex.
  • Output schema stabilization — each subcommand's stdout JSON Schema is published via the schema subcommand and machine-fetchable, so LLMs / jq can pin to it across versions.

Learn more

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:

License

MIT

About

ECHONET Lite 専用 CLI。AIネイティブ・ステートレス・one-shot。jq と LLM がそのまま食える構造化 JSON 出力。

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages