Skip to content

hculap/emodul

Repository files navigation

emodul

CI PyPI License: MIT Python 3.10+ Claude Skill GitHub stars

📖 Landing page · 📜 Changelog · 🔌 PyPI · 🤖 AGENT.md

Unofficial Python CLI for the Tech Sterowniki eModul.pl cloud (Polish floor-heating controllers: L-4X WIFI, L-8, L-9, L-12, etc.).

Reverse-engineered from the Angular SPA bundle, hardened against bugs found in the community tech-controllers Home Assistant integration, and designed to be driven by AI agents out of the box via the bundled SKILL.md.

⚠️ Unofficial. Not affiliated with TECH Sterowniki Sp. z o.o. or eModul.pl. Use against your own account only. The vendor may rate-limit or invalidate tokens at any time.

emodul status                          → live zone table with action (heating/idle)
emodul settings audit                  → flags bad/non-default parameters across all controllers
emodul zones set-temp Salon 21.5       → blocks until controller acknowledges (no race on re-read)
emodul watch install-service           → launchd/systemd background poller → SQLite event log

Why

  • Drive your floor heating from a terminal. Set temperatures, attach schedules, audit configuration, pull historical data — no clicks, no web UI.
  • Hand it to an AI agent. Every command supports --json for stable machine-readable output. The agent doesn't need to know HTTP, JWT or PIN handling — only the slug-named commands.
  • Reach things the web SPA hides. Service menu (PIN 5162) parameters, raw statistics, alarm history, multi-controller cross-drift detection, long-term transition logging.
  • Survive reboots. Background watcher installs as a launchd plist (macOS) or systemd user unit (Linux). Auto-restarts on crash. Auto-re-authenticates on token expiry via OS keychain.

Three ways to use this 🤖

Path For which agents What it gives you
A: MCP server Claude Desktop / Cursor chat / Continue / Cline / Zed / JetBrains AI / OpenCode / Gemini CLI One pipx install emodul + a JSON entry in the client config. Chat clients call get_status, set_zone_temperature, etc. as native tools.
B: AGENT.md prompt Claude Code / Codex CLI / Cursor agent / Aider Paste a single URL; the CLI agent runs pipx install emodul && emodul skill install && emodul auth login --browser. SKILL.md gets discovered automatically.
C: Copy-paste fallback claude.ai web / ChatGPT web / Cowork (sandboxed) Sandboxed agent prints the commands; user runs them in their own terminal.

See AGENT.md for full per-runtime configs.

Path A in 30 seconds — Claude Desktop

pipx install emodul
emodul auth login --browser       # one-time login (opens a local form)
emodul install claude-desktop     # drops MCP-flavored skill + writes the
                                  # mcpServers.emodul entry into your
                                  # claude_desktop_config.json (with backup)

Quit & reopen Claude Desktop (⌘+Q on macOS) → ask "what's the temperature in Salon?" and Claude calls the MCP tool natively.

For both Claude Code AND Claude Desktop in one go:

emodul install --all

Manual variant (if you'd rather edit the JSON yourself):

{
  "mcpServers": {
    "emodul": {
      "command": "emodul",
      "args": ["mcp"]
    }
  }
}

Path B in 30 seconds — Claude Code (or any CLI agent)

Paste this URL into your agent:

https://raw.githubusercontent.com/hculap/emodul/main/AGENT.md

The agent handles everything: install, skill registration, browser auth, default-module selection.


Install

From PyPI (recommended)

pipx install emodul         # isolated install, recommended
# OR
pip install emodul          # plain pip (use a venv on PEP-668 systems)

After install, wire emodul into your AI clients:

emodul install claude-code       # CLI-flavored skill for Claude Code / Codex CLI
emodul install claude-desktop    # MCP-flavored skill + mcpServers config for Claude Desktop
emodul install --all             # both at once (only what's detected)
emodul install --dry-run         # preview without writing
emodul uninstall claude-desktop  # reverse it

Each install creates a timestamped .bak-… of any config it touches; the last 5 are kept. Pass --force to overwrite an existing mcpServers.emodul entry whose arguments differ.

Verify:

emodul --version

From source (for development)

git clone https://github.com/hculap/emodul.git
cd emodul
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/emodul --version

On macOS the system Python is PEP-668 externally-managed; .venv keeps things clean. pipx handles this automatically. Activate the venv with source .venv/bin/activate if you want plain emodul on PATH.

First-time setup

Browser flow (recommended — best when an AI agent is driving)

emodul auth login --browser

Opens a local sign-in page (http://127.0.0.1:<random-port>/) with an Apple-style form (dark-mode aware). You type your eModul.pl credentials into the browser — the CLI captures the JWT and stores it. The agent running this command never sees your password.

The flow auto-selects: --browser when stdin isn't a TTY (agent context), --terminal when interactive. Override with explicit --terminal / --browser.

Terminal flow (interactive)

emodul auth login --terminal --email you@example.com

Prompts for the password in stdin.

Either way, the JWT lands in ~/.config/emodul/config.json (chmod 600) and your password in the OS keychain (Keychain on macOS, GNOME Keyring / KWallet on Linux, Credential Locker on Windows). On any future 401 the CLI silently re-authenticates. Opt out with --no-keychain. Remove the password with emodul auth forget-password.

…or paste a JWT you already have (e.g. from DevTools → Application → Local Storage → token on emodul.pl):

emodul auth import-token "eyJhbGciOi..." --user-id 123456789
# (run `auth login` later to also seed the keychain and enable auto-refresh)

Pick a default controller so -m becomes optional:

emodul modules list
emodul modules select <module-name>    # name substring works (use a value from `modules list`)

Cache the Polish translation dictionary (16,368 entries — used to resolve txtId references in tiles and menus):

emodul i18n refresh

Daily commands

Status & zones

emodul status                                    # rich table of all zones in default module
emodul status --json                             # same data, machine-readable

emodul zones list                                # current state per zone
emodul zones list -a                             # cross-module, with "Module" column
emodul zones show Salon                          # full data + raw JSON
emodul zones audit                               # behavioural analysis (mean/min/max/stdev/gap)
emodul zones audit --period week                 # uses /stats/linear

emodul zones set-temp Salon 21.5                 # constantTemp; blocks ~5-30s until settled
emodul zones boost Salon 23 90                   # 23 °C for 90 min, then revert
emodul zones on  Salon
emodul zones off Salon
emodul zones rename Salon "Living"
emodul zones schedule Salon --mode global --index 0      # attach globalSchedule

Zone selector accepts either a numeric zone_id or a unique case-insensitive name substring.

--wait / --no-wait: all zone writes by default block until the controller clears its duringChange:"t" flag (the API otherwise reports the OLD value for ~30s — Home Assistant integration issue #184). Disable with --no-wait for fire-and-forget.

Settings (named parameters, no raw IDOs)

Twenty-five named slugs across MU/MI/MS (no MP — that PIN is unknown).

emodul settings list                             # inventory: name / label / category
emodul settings show                             # dashboard table with audit verdicts
emodul settings show --category safety           # filter
emodul settings show --include-locked            # show items the server reports as access=false
emodul settings get emergency-mode
emodul settings set emergency-mode 30
emodul settings set diagnostic-file off --all-modules    # apply to every controller
emodul settings audit                            # bad/warn items + cross-module drift detection

Slug categories: safety (emergency-mode, antifreeze, actuator-protection, temp-max/min), actuator (hysteresis, sigma-range, weather-control, optimum-start, sensor-calibration), schedule (heating, cooling, presets), diagnostic (diagnostic-file, show-all).

Menus (when you need raw IDO access)

emodul menu show MU                              # user menu (no PIN)
emodul menu show MI                              # fitter menu (no PIN)
emodul menu unlock MS 0 5162                     # one-time PIN unlock — saved to config
emodul menu show MS                              # subsequent reads auto-include PIN
emodul menu set MI 3145755 30                    # raw ido write
emodul menu forget-pins MS                       # wipe saved PINs

Type aliases: user/MU, fitters/MI, service/MS, manufacturer/MP. MP PIN is not 5162 — it's a separate code held by Tech / installers. Not required for normal use.

Schedules

emodul schedules list                            # all 5 globalSchedules: day mask, intervals, used-by
emodul schedules show 0                          # detail (by index)
emodul schedules show "Salon i Łazienka"         # detail (by name substring)

Each TECH controller has exactly 5 globalSchedule slots. The CLI decodes day masks (Pn Wt Śr Cz Pt — —), interval times (06:00-21:00 → 21.5 °C), and setback temperatures. The Używają column lists which zones currently reference each schedule.

Statistics

emodul stats available                                          # what series exist
emodul stats linear --period day                                # today's temp curves
emodul stats linear --period week
emodul stats linear --month 4 --year 2026
emodul stats column consumptions --period month --month 4 --year 2026
emodul stats csv consumptions --period month --month 4 --year 2026 --out apr.csv

# Multi-month batch:
emodul stats dump --since 2025-10 --until 2026-05               # YYYY-MM
emodul stats dump --since 6m                                    # 6 months ago → now
emodul stats dump --since 1y                                    # 1 year ago → now
emodul stats dump --since 12m --kind csv --state consumptions --out year.csv

Periods accepted: day, week, and explicit --month X --year Y. year/total are rejected by the server (422 on L-4X WIFI). For longer ranges use stats dump, which iterates months and merges results into one payload. Empty months auto-dropped by default (--keep-empty overrides).

Alarms

emodul alarms history                                           # last 30 days, all types
emodul alarms history --from 2026-04-01 --to 2026-05-18 --type warning
emodul alarms ack 123                                           # acknowledge popup

Tiles, i18n, low-level

emodul tiles list --translate                                   # decode txtId via i18n cache
emodul i18n refresh                                             # fetch fresh 757 KB PL dictionary
emodul i18n lookup 873                                          # txtId 873 → "Wersja modułu"

emodul poll                                                     # one-shot delta poll
emodul poll --since 1779120000                                  # only changes since epoch

# Escape hatch when you need a not-yet-wrapped endpoint:
emodul raw GET '/api/v1/users/{user_id}/modules'
emodul raw POST '/api/v1/users/{user_id}/modules/{udid}/zones' \
  --body '{"zone":{"id":9002,"zoneState":"zoneOn"}}'

{user_id} and {udid} placeholders are auto-substituted from your config.


Watcher (background process)

Long-running poller that records relay/zone transitions to SQLite. Insert-on- change only — a year of "nothing happens" stays tiny.

emodul watch run                                                # foreground, Ctrl-C to stop
emodul watch run --once                                         # single poll then exit
emodul watch run --interval 30                                  # custom poll seconds

emodul watch install-service --interval 60                      # auto-start on boot
emodul watch status                                             # recent events + service health
emodul watch uninstall-service                                  # stop + remove

macOS → writes ~/Library/LaunchAgents/com.emodul.watcher.plist, launchctl loads it, sets KeepAlive + ThrottleInterval=60. Logs: tail -f /tmp/emodul-watcher.{out,err}.log.

Linux → writes ~/.config/systemd/user/emodul-watcher.service, systemctl --user enable --now. ⚠️ Run once: sudo loginctl enable-linger $USER to keep it alive when logged out. Logs: journalctl --user -u emodul-watcher -f.

What it records

Database at ~/.local/state/emodul/state.db:

Table Captures When inserted
tile_events Pompa, Styk beznapięciowy on/off only on state change
zone_events Setpoint, current temp, mode, per-zone relay state when any of setpoint/mode/relay changes
run_log Startup, errors, API failures each event

Query examples:

# Heating intervals for Salon over last 7 days:
sqlite3 -header -column ~/.local/state/emodul/state.db \
  "SELECT datetime(ts,'unixepoch','localtime') AS time, name, relay
   FROM zone_events
   WHERE name='Salon' AND ts > strftime('%s','now','-7 days')
   ORDER BY ts"

# Pump cycles count this month:
sqlite3 ~/.local/state/emodul/state.db \
  "SELECT COUNT(*) FROM tile_events
   WHERE tile_id=8002 AND state=1
     AND ts > strftime('%s','now','start of month')"

For AI agents

The CLI is designed to be a clean tool surface for an LLM agent. Conventions:

  1. --json on every command for stable structured output. Default text output is human-friendly (rich tables, colors) but --json is canonical.
  2. Module selector -m accepts a full 32-char udid, a unique prefix (e.g. abc12345), or a unique name substring of whatever the user named their controller.
  3. Slug-based settings (emodul settings list enumerates all 25) instead of raw IDOs. The agent never has to know that "emergency-mode" lives at MI:3145755:percent.
  4. --all-modules for cross-controller fan-out where it makes sense (settings set, settings audit, zones list -a).
  5. --no-wait for fast fire-and-forget when the agent doesn't care about settle confirmation.
  6. emodul raw <METHOD> <path> [--body JSON] is the escape hatch when the agent needs an undocumented endpoint. {user_id} and {udid} are auto-substituted.

A typical agent prompt:

"Use emodul --json settings audit to find any non-default config on the heating system. Then for each WARN with a clear fix, run the suggested emodul settings set … command."


Architecture

emodul/
  api.py                    httpx wrapper; every endpoint as a method
                            + wait_until_settled / is_*_settled helpers
  auth.py                   keychain-backed refresher (called by ApiClient on 401)
  config.py                 ~/.config/emodul/config.json (chmod 600)
  format.py                 °C ↔ tenths conversion, table rendering, JSON output
  i18n.py                   16K-entry PL dictionary cache
  settings_map.py           25 named parameters → (menu_type, ido, kind, recommend, bad)
  storage.py                SQLite schema for the watcher
  cli.py                    click root group, Ctx with module-name resolver
  commands/
    auth.py                 login / import-token / whoami / logout / forget-password
    modules.py              list / select / show / sync / rename
    zones.py                list / show / set-temp / boost / on / off
                            schedule / rename / schedule-set / audit
    menu.py                 show / unlock / set / forget-pins
    settings.py             list / show / get / set / audit
    schedules.py            list / show
    stats.py                available / linear / column / csv / dump
    alarms.py               history / ack
    misc.py                 tiles / i18n / poll / raw / status
    watch.py                run / status / install-service / uninstall-service

Endpoint map (gist)

  • Base: https://emodul.pl (the .pl and .eu share one backend)
  • Auth: Authorization: Bearer <jwt>no cookies, "Bearer " prefix required
  • POST /api/v1/authentication{token, user_id}
  • GET /api/v1/users/{uid}/modules and …/modules/{udid} (kitchen sink: zones + tiles + schedules)
  • POST /…/zones for constantTemp / timeLimit / zoneOn|zoneOff
  • POST /…/zones/{zoneId}/global_schedule to attach a globalSchedule (body includes full schedule definition + setInZones)
  • GET /…/menu/{MU|MI|MS|MP}[/{id}:{pin},…] walks menu trees with inline PIN injection
  • POST /…/menu/{type}/ido/{id} writes any parameter
  • GET /api/v1/modules/{udid}/statistics/…no /users/ prefix here
  • GET /…/alarm_history/from/{date}/to/{date}/type/{all|alarm|warning|notification}
  • GET /api/v1/i18n/{lang} (757 KB Polish dictionary, 16,368 entries)
  • GET /…/update/data/parents/{JSON}/alarm_ids/{JSON}[/last_update/{ts}] — yes, JSON arrays embedded in the URL path

Wire conventions

  • All temperatures are integer tenths of °C (215 = 21.5 °C). The CLI accepts/displays Celsius; conversion happens in format.py.
  • Wire format 7 = tenths °C; 8 = percent; 10 = on/off bool; 106 = numeric value (sub-format-dependent).
  • humidity == 0 means "no sensor", not 0% RH — CLI returns None.
  • After any write, server keeps duringChange:"t" for ~5-30s and returns the OLD value during that window. CLI by default polls until cleared.

Security

  • JWT has no exp claim — treat it as a long-lived API key.
  • Config at ~/.config/emodul/config.json is chmod 600.
  • Password (if auth login was used) lives ONLY in the OS keychain. Verify on macOS with security find-generic-password -s emodul -a <email>.
  • Don't commit ~/.config/emodul/ or anything in benchamr/probes/ (they may contain JWTs).
  • The CLI does not log requests or responses to disk by default. The watcher only persists state transitions, never tokens.

Caveats & known limitations

  • MP (manufacturer) menu PIN is unknown. PIN 5162 works for MS only. MP requires a different code that Tech doesn't publish; you don't need it for normal use. Antystop-pomp, max floor temperature safety, PID-vs- hysteresis algorithm selector all live behind MP and are invisible from here.
  • No WebSocket / SSE push channel on eModul — confirmed by probing. All "live" updates come from HTTP polling. The watcher does this.
  • No /refresh endpoint and no long-lived API tokens. The CLI works around this with keychain-backed re-auth on 401.
  • Statistics: --period year and --period total are rejected by the server (422 Invalid range). Use --period day|week for current data or stats dump --since YYYY-MM for arbitrary ranges.
  • Some menu items report access=false — server-side gated. settings show hides them by default; opt-in with --include-locked.

Contributing

PRs welcome. See CONTRIBUTING.md for dev setup, where new endpoints belong, and how to anonymise bug reports. Project follows the Contributor Covenant.

Have a different TECH controller (L-8, L-9, L-12, …)? Try emodul status against your account and open an issue with whatever breaks — most things should "just work" since the API shape is shared across models.

Acknowledgements

This project owes its endpoint map and several hardening patterns to two community projects:

Tech Sterowniki publishes no official SDK or schema for eModul itself, though their techsterowniki/sinum-mcp repo bundles OpenAPI schemas for their sibling Sinum product, which confirm wire conventions (×10 temp, unit codes 0-6) used across their codebase.

About

Local CLI + MCP server for Tech Sterowniki / eModul.pl floor-heating controllers. Talk to your TECH heating from Claude Desktop, Cursor, Continue, Claude Code & others via 16 MCP tools.

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages