Skip to main content

Unofficial Python CLI for Tech Sterowniki / eModul.pl floor-heating controllers. AI-agent ready (Claude Skill).

Project description

emodul

CI License: MIT Python 3.11+ Claude Skill GitHub stars

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.

Install

From PyPI (recommended)

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

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

Either log in (recommended — enables silent JWT auto-refresh):

emodul auth login --email you@example.com

Stores the JWT 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-auths and retries the request once. 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 Parter           # name substring works

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 (e.g. Parter).
  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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

emodul-0.1.0.tar.gz (52.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

emodul-0.1.0-py3-none-any.whl (52.5 kB view details)

Uploaded Python 3

File details

Details for the file emodul-0.1.0.tar.gz.

File metadata

  • Download URL: emodul-0.1.0.tar.gz
  • Upload date:
  • Size: 52.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for emodul-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e5df6d7a1eafcd4b633e788bc4d27cebffd54532466ae36a15b2f7428730f4d7
MD5 528cf40d9d179c08e88279e90730231b
BLAKE2b-256 0e6ef8edad12eafa2657c4b117cd07fa24c8fd3e46a423febdbd1d0195dd582d

See more details on using hashes here.

File details

Details for the file emodul-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: emodul-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 52.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for emodul-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fd2edaf6b1de089c3cae3ef03b9a34be30c3c3401670c171bb990e964a9c0028
MD5 6d0f9b4fd269688e9d43bff4e99eef94
BLAKE2b-256 29e34334654e8bd1e457294f78a38083528a92f032272db0f2913688c368d923

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page