📖 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
- 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
--jsonfor 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.
| 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.
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 --allManual variant (if you'd rather edit the JSON yourself):
{
"mcpServers": {
"emodul": {
"command": "emodul",
"args": ["mcp"]
}
}
}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.
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 itEach 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 --versiongit clone https://github.com/hculap/emodul.git
cd emodul
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/emodul --versionOn 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.
emodul auth login --browserOpens 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.
emodul auth login --terminal --email you@example.comPrompts 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 refreshemodul 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 globalScheduleZone 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.
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 detectionSlug 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).
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 PINsType 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.
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.
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.csvPeriods 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).
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 popupemodul 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.
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 + removemacOS → 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. sudo loginctl enable-linger $USER to keep it alive when logged out.
Logs: journalctl --user -u emodul-watcher -f.
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')"The CLI is designed to be a clean tool surface for an LLM agent. Conventions:
--jsonon every command for stable structured output. Default text output is human-friendly (rich tables, colors) but--jsonis canonical.- Module selector
-maccepts a full 32-char udid, a unique prefix (e.g.abc12345), or a unique name substring of whatever the user named their controller. - Slug-based settings (
emodul settings listenumerates all 25) instead of raw IDOs. The agent never has to know that "emergency-mode" lives atMI:3145755:percent. --all-modulesfor cross-controller fan-out where it makes sense (settings set,settings audit,zones list -a).--no-waitfor fast fire-and-forget when the agent doesn't care about settle confirmation.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 auditto find any non-default config on the heating system. Then for each WARN with a clear fix, run the suggestedemodul settings set …command."
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
- Base:
https://emodul.pl(the.pland.eushare one backend) - Auth:
Authorization: Bearer <jwt>— no cookies, "Bearer " prefix required POST /api/v1/authentication→{token, user_id}GET /api/v1/users/{uid}/modulesand…/modules/{udid}(kitchen sink: zones + tiles + schedules)POST /…/zonesforconstantTemp/timeLimit/zoneOn|zoneOffPOST /…/zones/{zoneId}/global_scheduleto attach a globalSchedule (body includes full schedule definition +setInZones)GET /…/menu/{MU|MI|MS|MP}[/{id}:{pin},…]walks menu trees with inline PIN injectionPOST /…/menu/{type}/ido/{id}writes any parameterGET /api/v1/modules/{udid}/statistics/…— no/users/prefix hereGET /…/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
- All temperatures are integer tenths of °C (
215= 21.5 °C). The CLI accepts/displays Celsius; conversion happens informat.py. - Wire format
7= tenths °C;8= percent;10= on/off bool;106= numeric value (sub-format-dependent). humidity == 0means "no sensor", not 0% RH — CLI returnsNone.- After any write, server keeps
duringChange:"t"for ~5-30s and returns the OLD value during that window. CLI by default polls until cleared.
- JWT has no
expclaim — treat it as a long-lived API key. - Config at
~/.config/emodul/config.jsonischmod 600. - Password (if
auth loginwas used) lives ONLY in the OS keychain. Verify on macOS withsecurity find-generic-password -s emodul -a <email>. - Don't commit
~/.config/emodul/or anything inbenchamr/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.
- MP (manufacturer) menu PIN is unknown. PIN
5162works 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
/refreshendpoint and no long-lived API tokens. The CLI works around this with keychain-backed re-auth on 401. - Statistics:
--period yearand--period totalare rejected by the server (422 Invalid range). Use--period day|weekfor current data orstats dump --since YYYY-MMfor arbitrary ranges. - Some menu items report
access=false— server-side gated.settings showhides them by default; opt-in with--include-locked.
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.
This project owes its endpoint map and several hardening patterns to two community projects:
mariusz-ostoja-swierczynski/tech-controllers— Home Assistant integration, especiallytech.py(HTTP wrapper),const.py(tile-type taxonomy), and the comments inswitch.py/select.py/number.pydocumenting theduringChange:"t"race.kamil-bednarek/homebridge-tech-emodul— TypeScript Homebridge plugin, narrow but clean; confirmed the basic auth + zones POST shape.
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.