Unofficial Python CLI + MCP server for Tech Sterowniki / eModul.pl floor-heating controllers. AI-agent ready.
Project description
emodul
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
--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.
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:
--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."
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.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
Wire conventions
- 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.
Security
- 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.
Caveats & known limitations
- 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.
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:
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file emodul-0.1.6.tar.gz.
File metadata
- Download URL: emodul-0.1.6.tar.gz
- Upload date:
- Size: 88.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
383e1eaa00314e5be607039e805890f88d3ed23852b13bc64ae0a149d97c6764
|
|
| MD5 |
cb71c63336a343652d3d3c886040ebdf
|
|
| BLAKE2b-256 |
bf6604cd691ec73f269fe970a8744ea1681879e0d9575bc14842e75f89ee40d0
|
Provenance
The following attestation bundles were made for emodul-0.1.6.tar.gz:
Publisher:
publish.yml on hculap/emodul
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
emodul-0.1.6.tar.gz -
Subject digest:
383e1eaa00314e5be607039e805890f88d3ed23852b13bc64ae0a149d97c6764 - Sigstore transparency entry: 1573486072
- Sigstore integration time:
-
Permalink:
hculap/emodul@cf1abaf5851bd65aa2e287ab66cee71a4d9a11b4 -
Branch / Tag:
refs/tags/v0.1.6 - Owner: https://github.com/hculap
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cf1abaf5851bd65aa2e287ab66cee71a4d9a11b4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file emodul-0.1.6-py3-none-any.whl.
File metadata
- Download URL: emodul-0.1.6-py3-none-any.whl
- Upload date:
- Size: 99.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
522df3bf90799630ee78b36ed30f524e9b54b7e9dcaedaab8858133090bafb1a
|
|
| MD5 |
cb2716448432698a44c3201a1516459a
|
|
| BLAKE2b-256 |
3fefa9decff29eb19548c63669e0b0167882f55f08632c865e6325153da32d42
|
Provenance
The following attestation bundles were made for emodul-0.1.6-py3-none-any.whl:
Publisher:
publish.yml on hculap/emodul
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
emodul-0.1.6-py3-none-any.whl -
Subject digest:
522df3bf90799630ee78b36ed30f524e9b54b7e9dcaedaab8858133090bafb1a - Sigstore transparency entry: 1573486090
- Sigstore integration time:
-
Permalink:
hculap/emodul@cf1abaf5851bd65aa2e287ab66cee71a4d9a11b4 -
Branch / Tag:
refs/tags/v0.1.6 - Owner: https://github.com/hculap
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cf1abaf5851bd65aa2e287ab66cee71a4d9a11b4 -
Trigger Event:
push
-
Statement type: