smartbox 2.6.2__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. smartbox-2.7.0/.env.example +16 -0
  2. smartbox-2.7.0/.github/ISSUE_TEMPLATE/new-reseller.md +19 -0
  3. {smartbox-2.6.2 → smartbox-2.7.0}/.github/workflows/python-package.yml +2 -2
  4. {smartbox-2.6.2 → smartbox-2.7.0}/.gitignore +6 -0
  5. {smartbox-2.6.2 → smartbox-2.7.0}/CHANGELOG.md +56 -2
  6. smartbox-2.7.0/PKG-INFO +254 -0
  7. smartbox-2.7.0/README.md +234 -0
  8. {smartbox-2.6.2 → smartbox-2.7.0}/api-notes.md +269 -11
  9. {smartbox-2.6.2 → smartbox-2.7.0}/codecov.yml +0 -2
  10. {smartbox-2.6.2 → smartbox-2.7.0}/pyproject.toml +6 -2
  11. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/__init__.py +5 -0
  12. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/cmd.py +84 -2
  13. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/error.py +13 -0
  14. smartbox-2.7.0/src/smartbox/retry.py +78 -0
  15. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/session.py +4 -1
  16. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/socket.py +16 -30
  17. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/update_manager.py +112 -102
  18. smartbox-2.7.0/src/smartbox/ws_user.py +758 -0
  19. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_cmd.py +186 -1
  20. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_session.py +8 -0
  21. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_socket.py +172 -0
  22. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_update_manager.py +34 -0
  23. smartbox-2.7.0/tests/test_ws_user.py +887 -0
  24. smartbox-2.7.0/tools/probe_ws_user.py +491 -0
  25. {smartbox-2.6.2 → smartbox-2.7.0}/uv.lock +1 -1
  26. smartbox-2.6.2/.github/ISSUE_TEMPLATE/new-reseller.md +0 -18
  27. smartbox-2.6.2/PKG-INFO +0 -202
  28. smartbox-2.6.2/README.md +0 -182
  29. {smartbox-2.6.2 → smartbox-2.7.0}/.github/FUNDING.yml +0 -0
  30. {smartbox-2.6.2 → smartbox-2.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  31. {smartbox-2.6.2 → smartbox-2.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {smartbox-2.6.2 → smartbox-2.7.0}/.github/dependabot.yml +0 -0
  33. {smartbox-2.6.2 → smartbox-2.7.0}/.github/workflows/publish-to-pypi.yml +0 -0
  34. {smartbox-2.6.2 → smartbox-2.7.0}/.pre-commit-config.yaml +0 -0
  35. {smartbox-2.6.2 → smartbox-2.7.0}/.vscode/settings.json +0 -0
  36. {smartbox-2.6.2 → smartbox-2.7.0}/CODEOWNERS +0 -0
  37. {smartbox-2.6.2 → smartbox-2.7.0}/LICENSE +0 -0
  38. {smartbox-2.6.2 → smartbox-2.7.0}/ajtudela-pr-64-audit.md +0 -0
  39. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/models.py +0 -0
  40. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/py.typed +0 -0
  41. {smartbox-2.6.2 → smartbox-2.7.0}/src/smartbox/reseller.py +0 -0
  42. {smartbox-2.6.2 → smartbox-2.7.0}/tests/__init__.py +0 -0
  43. {smartbox-2.6.2 → smartbox-2.7.0}/tests/common.py +0 -0
  44. {smartbox-2.6.2 → smartbox-2.7.0}/tests/conftest.py +0 -0
  45. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/acm/1/samples.json +0 -0
  46. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/acm/1/setup.json +0 -0
  47. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/acm/1/status.json +0 -0
  48. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/acm/1/version.json +0 -0
  49. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/connected.json +0 -0
  50. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/htr/0/samples.json +0 -0
  51. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/htr/0/setup.json +0 -0
  52. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/htr/0/status.json +0 -0
  53. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/htr/0/version.json +0 -0
  54. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/htr_system/power_limit.json +0 -0
  55. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/mgr/away_status.json +0 -0
  56. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/mgr/nodes.json +0 -0
  57. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/pmo/3/samples.json +0 -0
  58. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/pmo/3/setup.json +0 -0
  59. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/pmo/3/status.json +0 -0
  60. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device1/pmo/3/version.json +0 -0
  61. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/connected.json +0 -0
  62. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/0/samples.json +0 -0
  63. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/0/setup.json +0 -0
  64. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/0/status.json +0 -0
  65. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/0/version.json +0 -0
  66. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/1/samples.json +0 -0
  67. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/1/setup.json +0 -0
  68. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/1/status.json +0 -0
  69. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/1/version.json +0 -0
  70. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/2/samples.json +0 -0
  71. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/2/setup.json +0 -0
  72. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/2/status.json +0 -0
  73. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/2/version.json +0 -0
  74. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/3/samples.json +0 -0
  75. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/3/setup.json +0 -0
  76. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/3/status.json +0 -0
  77. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/3/version.json +0 -0
  78. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/4/samples.json +0 -0
  79. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/4/setup.json +0 -0
  80. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/4/status.json +0 -0
  81. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/4/version.json +0 -0
  82. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/5/samples.json +0 -0
  83. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/5/setup.json +0 -0
  84. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/5/status.json +0 -0
  85. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_mod/5/version.json +0 -0
  86. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/htr_system/power_limit.json +0 -0
  87. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/mgr/away_status.json +0 -0
  88. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs/device2/mgr/nodes.json +0 -0
  89. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/devs.json +0 -0
  90. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/grouped_devs.json +0 -0
  91. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/groups/test_home/guest_users.json +0 -0
  92. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_prog_addr2.json +0 -0
  93. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_prog_addr3.json +0 -0
  94. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_prog_addr4.json +0 -0
  95. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_prog_addr5.json +0 -0
  96. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_prog_addr6.json +0 -0
  97. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_addr2.json +0 -0
  98. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_addr3.json +0 -0
  99. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_addr4.json +0 -0
  100. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_addr5.json +0 -0
  101. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_addr6.json +0 -0
  102. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_setup_no_boost.json +0 -0
  103. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/htr_status_boost_off.json +0 -0
  104. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/socket_update_status_frame.json +0 -0
  105. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/socket_update_sync_lost_frame.json +0 -0
  106. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/live/socket_update_version_frame.json +0 -0
  107. {smartbox-2.6.2 → smartbox-2.7.0}/tests/fixtures/test_path.json +0 -0
  108. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_live_payloads.py +0 -0
  109. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_models.py +0 -0
  110. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_reseller.py +0 -0
  111. {smartbox-2.6.2 → smartbox-2.7.0}/tests/test_session_reliability.py +0 -0
  112. {smartbox-2.6.2 → smartbox-2.7.0}/tools/api_functional_tests.py +0 -0
  113. {smartbox-2.6.2 → smartbox-2.7.0}/tools/away_probe.py +0 -0
  114. {smartbox-2.6.2 → smartbox-2.7.0}/tools/capture_prog.py +0 -0
  115. {smartbox-2.6.2 → smartbox-2.7.0}/tools/live_check.py +0 -0
  116. {smartbox-2.6.2 → smartbox-2.7.0}/tools/probe_extra_options.py +0 -0
  117. {smartbox-2.6.2 → smartbox-2.7.0}/tools/probe_modified_auto.py +0 -0
  118. {smartbox-2.6.2 → smartbox-2.7.0}/tools/probe_settle_window.py +0 -0
  119. {smartbox-2.6.2 → smartbox-2.7.0}/tools/prog_functional_tests.py +0 -0
  120. {smartbox-2.6.2 → smartbox-2.7.0}/tools/watch_status.py +0 -0
  121. {smartbox-2.6.2 → smartbox-2.7.0}/webapi-spec.md +0 -0
@@ -0,0 +1,16 @@
1
+ # Copy this file to ".env" and fill in your values so the `smartbox` CLI does
2
+ # not need the credentials on every invocation. A real shell environment
3
+ # variable always overrides the value set here, and an explicit command-line
4
+ # option overrides both.
5
+
6
+ # Your username/password as used for the mobile app / web app (required).
7
+ SMARTBOX_USERNAME=
8
+ SMARTBOX_PASSWORD=
9
+
10
+ # API name for your heater vendor, e.g. "api" or "api-foo" (see README).
11
+ SMARTBOX_API_NAME=api
12
+
13
+ # Only needed if your reseller is not built into the package.
14
+ SMARTBOX_BASIC_AUTH_CREDS=
15
+ SMARTBOX_X_REFERER=
16
+ SMARTBOX_X_SERIAL_ID=
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: New reseller
3
+ about: Add a new available reseller.
4
+ title: "[RESELLER] New available reseller"
5
+ labels: reseller
6
+ assignees: Delmael
7
+
8
+ ---
9
+
10
+ > [!NOTE]
11
+ > If you do not see your current reseller in the available list, you can ask to add it. We cannot ensure your device go through this platform.
12
+ > See [How to capture the values](https://github.com/ajtudela/smartbox/blob/main/api-notes.md#basic-auth-credential) — open your reseller's web app, watch the network tab, log in and read the `POST .../api/v2/client/token` request headers.
13
+
14
+ * [ ] I have checked with the `smartbox resellers` command that my reseller is not already present
15
+ * **Website url (mandatory)** (the `x-referer` header):
16
+ * Name of the reseller:
17
+ * API name (the `api-<...>` part of the request host, e.g. `api-foo`):
18
+ * Basic auth credential (the base64 string after `Basic ` in the `authorization` header):
19
+ * Serial id (the `x-serialid` header):
@@ -30,7 +30,7 @@ jobs:
30
30
  - name: Install the project
31
31
  run: uv sync --all-extras --dev
32
32
  - name: Lint with ruff
33
- run: uv run ruff check src/smartbox
33
+ run: uv run ruff check src/smartbox tests
34
34
 
35
35
  typechecker:
36
36
  runs-on: ubuntu-latest
@@ -52,7 +52,7 @@ jobs:
52
52
  - name: Install the project
53
53
  run: uv sync --all-extras --dev
54
54
  - name: Typecheck with mypy
55
- run: uv run mypy src/smartbox
55
+ run: uv run mypy src/smartbox tests
56
56
 
57
57
  build:
58
58
  runs-on: ubuntu-latest
@@ -73,3 +73,9 @@ junit.xml
73
73
  .DS_Store
74
74
 
75
75
  *.code-workspace
76
+
77
+ # ws_user probe token stores (contain live credentials)
78
+ *token*store*.json
79
+ wsu_store.json
80
+ *token*store*.json.tmp
81
+ wsu_store.json.tmp
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0
4
+
5
+ ### New
6
+ * Per-user `ws_user` websocket transport (`WsUserSocketSession`,
7
+ `check_ws_user_support`): one connection serves every device of the
8
+ account, replacing N per-device `socket_io` sessions. Wire protocol
9
+ and every failure mode verified live (api-notes.md, "Transports —
10
+ socket_io vs ws_user"). Selected via the new `ws_user_socket=` kwarg
11
+ on `UpdateManager`; the default transport is unchanged.
12
+ * `WsUserUnsupportedError` + the fallback doctrine: only deterministic
13
+ handshake rejections (HTTP 401/403/404/410 in the one-shot probe on a
14
+ fresh token; 404/410 in the run loop, or 401/403 that keep rejecting
15
+ freshly minted tokens) signal "endpoint unsupported"; transient
16
+ trouble is retried and never demotes.
17
+ * In `ws_user` mode `UpdateManager` ignores the SocketSession
18
+ pass-through kwargs (warned at construction) — the shared socket's
19
+ liveness is heartbeat-driven, not knob-driven.
20
+
21
+ ### Breaking
22
+ * `UpdateManager.socket_session` now returns `None` when the manager
23
+ runs in `ws_user` mode (previously always a `SocketSession`); use the
24
+ new `ws_user_socket` accessor to distinguish modes.
25
+
26
+ ### Features
27
+ * CLI: every authentication option also resolves from a `SMARTBOX_*`
28
+ environment variable, and the `smartbox` console script loads a `.env`
29
+ file (searched upward from the working directory) before parsing
30
+ options. Precedence: command line > shell environment > `.env`. See
31
+ `.env.example`.
32
+
33
+ ### Fixes
34
+ * CLI: `.env` parsing now follows python-dotenv semantics — an inline
35
+ `#` starts a comment only when it is preceded by whitespace and lies
36
+ outside quotes, and quoted values unquote even when a trailing
37
+ comment follows them. Previously inline comments silently became
38
+ part of the value and quote-stripping was defeated, corrupting
39
+ credentials written in the conventional quoted style.
40
+
41
+ ### Refactor
42
+ * `UpdateManager`: `subscribe_to_node_status`/`_setup`/`_version`/`_prog`
43
+ share one `_subscribe_to_node_key` implementation.
44
+
45
+ ### Tests & CI
46
+ * Socket tests for the namespace callbacks, websocket cleanup, SIGINT
47
+ handler and cancellation during connect; coverage floor of 95%
48
+ (`fail_under`) enforced.
49
+ * ruff and mypy run over `tests/` in CI; `socket.py` is no longer
50
+ excluded from codecov.
51
+
52
+ ### Docs
53
+ * README rewritten: library usage, command table, `.env` section and an
54
+ actionable Support section. api-notes documents how to capture the
55
+ Basic Auth credential and the new-reseller issue template points to it.
56
+
3
57
  ## 2.6.2
4
58
 
5
59
  Port round of upstream PR #64 (ajtudela, `improvements/fix_bugs`, 19
@@ -77,8 +131,8 @@ version bump).
77
131
  device group move, node select/power, group geo_data/extra_data, invite
78
132
  flows (invite/revoke/confirm — confirm is unauthenticated), quiet-home
79
133
  notification config GET/POST + test, encrypted-wifi-credentials and
80
- geocoding helpers. Read-only-verified paths pinned from the vendor app
81
- bundle; all writes marked **unverified on live hardware** in docstrings.
134
+ geocoding helpers. Read-only-verified paths pinned from the official
135
+ vendor app; all writes marked **unverified on live hardware** in docstrings.
82
136
  New base helpers: `_api_delete`, host-relative `_api_get` (params not
83
137
  logged — may carry secrets) and `_api_post_path` (auth optional).
84
138
  * Away-status writes default `enabled: true` to match the vendor app shape
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.5
2
+ Name: smartbox
3
+ Version: 2.7.0
4
+ Summary: Python API to control heating 'smart boxes'
5
+ Project-URL: Homepage, https://github.com/ajtudela/smartbox
6
+ Project-URL: Issues, https://github.com/ajtudela/smartbox/issues
7
+ Author-email: Alberto Tudela <ajtudela@gmail.com>, Delmael <delmael@outlook.com>, graham33 <graham@grahambennett.org>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.14.2
14
+ Requires-Dist: aiohttp<4,>=3
15
+ Requires-Dist: asyncclick
16
+ Requires-Dist: jq
17
+ Requires-Dist: pydantic<3,>=2
18
+ Requires-Dist: python-socketio<6,>=5
19
+ Description-Content-Type: text/markdown
20
+
21
+ # smartbox
22
+ ![build](https://github.com/ajtudela/smartbox/workflows/Python%20package/badge.svg)
23
+ [![PyPI version](https://badge.fury.io/py/smartbox.svg)](https://badge.fury.io/py/smartbox)
24
+ [![codecov](https://codecov.io/gh/ajtudela/smartbox/branch/main/graph/badge.svg?token=ghNZOGVzVv)](https://codecov.io/gh/ajtudela/smartbox)
25
+ [![PyPI license](https://img.shields.io/pypi/l/smartbox.svg)](https://pypi.python.org/pypi/smartbox/)
26
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/smartbox.svg)](https://pypi.python.org/pypi/smartbox/)
27
+
28
+ Python API to control heating 'smart boxes' (Helki and its resellers: Elnur, Haverland, Climastar, Technotherm, HJM, ...). It wraps the REST API and the socket.io channel, and ships a `smartbox` command line tool.
29
+
30
+ ## Installation
31
+
32
+ pip install smartbox
33
+
34
+ The package is importable as `smartbox` and ships `py.typed`.
35
+
36
+ ## Library usage
37
+
38
+ `AsyncSmartboxSession` is the entry point. Use it as an async context manager so its HTTP client is closed for you; when you already have an `aiohttp.ClientSession` (for example Home Assistant's shared one) pass it as `websession=` and it will not be closed on exit.
39
+
40
+ ```python
41
+ import asyncio
42
+ from smartbox import AsyncSmartboxSession
43
+
44
+ async def main() -> None:
45
+ async with AsyncSmartboxSession(
46
+ api_name="api", # or SMARTBOX_API_NAME
47
+ username="you@example.com",
48
+ password="secret",
49
+ raw_response=False, # return Pydantic models instead of dicts
50
+ ) as session:
51
+ for device in await session.get_devices():
52
+ for node in await session.get_nodes(device.dev_id):
53
+ status = await session.get_node_status(device.dev_id, node.model_dump())
54
+ print(device.name, node.name, status.mtemp, status.stemp)
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ With `raw_response=True` (the default) every method returns the raw `dict`/`list` from the API. With `raw_response=False` they return models from `smartbox.models` (`HtrNodeStatus`, `DefaultNodeSetup`, ...); the models are lenient (unknown keys kept, fields optional) because the API is undocumented and varies by reseller.
60
+
61
+ Constructor options worth knowing: `retry_attempts`/`backoff_factor` (transient 5xx / connection errors are retried with exponential backoff), `x_referer` / `x_serial_id` / `basic_auth_credentials` for a reseller that is not built in.
62
+
63
+ For live updates over socket.io use `UpdateManager`:
64
+
65
+ ```python
66
+ from smartbox import AsyncSmartboxSession, UpdateManager
67
+
68
+ async with AsyncSmartboxSession(...) as session:
69
+ manager = UpdateManager(session, device_id)
70
+ manager.subscribe_to_node_status(
71
+ lambda node_type, addr, status: print(node_type, addr, status)
72
+ )
73
+ await manager.run() # runs until cancelled
74
+ ```
75
+
76
+ ## Websocket transports
77
+
78
+ Status updates arrive over one of two transports; both are implemented
79
+ in this library and verified live (api-notes.md, "Transports —
80
+ socket_io vs ws_user"):
81
+
82
+ * **`socket_io`** (default): a per-device socket.io connection. The
83
+ `UpdateManager` owns it; `run()` runs the loop, `cancel()` is
84
+ terminal.
85
+ * **`ws_user`** (the vendor web apps' channel): ONE plain-WebSocket
86
+ connection (`WsUserSocketSession`) serves every device of the
87
+ account. Its server-side proxy PINGs keep the session supervised, and
88
+ sockets are cut by the server exactly at the access token's 4 h
89
+ expiry (close code 1006) — the reconnect with a fresh token is
90
+ mandatory and part of the steady state.
91
+
92
+ Caller contract for the shared ws_user socket:
93
+
94
+ socket_ = WsUserSocketSession(session)
95
+ # The caller owns run() — and must SUPERVISE it: run() exits on any
96
+ # failure (it is re-runnable; only cancel() is terminal), and a
97
+ # parked UpdateManager never observes a shared-socket death, so
98
+ # without a supervisor the transport dies silently. Exceptions
99
+ # surface when the task is awaited, so keep and await it.
100
+ task = asyncio.create_task(supervise(socket_.run()))
101
+ socket_.add_device(dev_id, dev_data_cb, update_cb) # register (any time)
102
+ socket_.remove_device(dev_id) # unregister
103
+ await socket_.cancel() # TERMINAL: later run() calls no-op
104
+
105
+ `run()` raises `InvalidAuthError` (rejected credentials — start a
106
+ reauthentication flow) and `WsUserUnsupportedError` (deterministic
107
+ 404/410 — the host does not serve ws_user; fall back to socket_io).
108
+
109
+ `UpdateManager(session, dev_id, ws_user_socket=socket_)` switches an
110
+ individual manager onto the shared connection: `run()` registers the
111
+ manager's device and parks until `cancel()` (the shared socket's own
112
+ restarts are handled by its supervisor, not by per-device consumers).
113
+ Pass-through SocketSession kwargs are ignored in this mode (warned).
114
+
115
+ ## `smartbox` command line tool
116
+
117
+ Use the `smartbox` tool to read status information from your heaters (nodes) and change settings.
118
+
119
+ ### Options
120
+
121
+ `-u`/`--username` and `-p`/`--password` (your mobile-app / web-app credentials) are required for every command except `resellers`. `-v`/`--verbose` enables debug logging.
122
+
123
+ These are only needed when your reseller is not built into the package:
124
+
125
+ * `-a`/`--api-name`: the API name for your heater vendor, from the 'API Host' entry under the 'Version' menu of the mobile/web app. For a host `api-foo.xxxx` or `api.xxxx` use `api-foo` or `api`.
126
+ * `-b`/`--basic-auth-creds`: the HTTP Basic Auth credential used for the initial authentication, as a base64 string. See [Basic Auth Credential](./api-notes.md#basic-auth-credential) in api-notes.md for how to capture it.
127
+ * `-r`/`--x-referer`: the `x-referer` header value.
128
+ * `-i`/`--x-serial-id`: the `x-serialid` header value.
129
+
130
+ ### Configuration via environment variables / `.env`
131
+
132
+ Every option above can be supplied through an environment variable instead of being typed on each invocation. The command line always takes precedence over the environment.
133
+
134
+ | Option | Environment variable |
135
+ | ------------------------- | --------------------------- |
136
+ | `-a`/`--api-name` | `SMARTBOX_API_NAME` |
137
+ | `-b`/`--basic-auth-creds` | `SMARTBOX_BASIC_AUTH_CREDS` |
138
+ | `-u`/`--username` | `SMARTBOX_USERNAME` |
139
+ | `-p`/`--password` | `SMARTBOX_PASSWORD` |
140
+ | `-r`/`--x-referer` | `SMARTBOX_X_REFERER` |
141
+ | `-i`/`--x-serial-id` | `SMARTBOX_X_SERIAL_ID` |
142
+
143
+ The `smartbox` command also reads a `.env` file (searched for in the working directory and its parents) before parsing options, so the usual workflow is to copy [`.env.example`](.env.example) to `.env`, fill it in once, and then run commands without any auth flags:
144
+
145
+ cp .env.example .env
146
+ # edit .env
147
+ smartbox devices
148
+
149
+ ### Commands
150
+
151
+ In the examples below `<auth options...>` stands for the options above (or nothing, when they come from `.env`).
152
+
153
+ Read-only, across every device/node:
154
+
155
+ | Command | What it shows |
156
+ | --- | --- |
157
+ | `devices` | the devices the account can see |
158
+ | `homes` | devices grouped into homes |
159
+ | `nodes` | the nodes of each device |
160
+ | `status` | live status of every node |
161
+ | `setup` | configuration of every node |
162
+ | `prog` | weekly heating schedule of every node |
163
+ | `device-away-status` | away status of every device |
164
+ | `device-connected-status` | whether each device is online |
165
+ | `device-power-limit` | power limit (watts) of every device |
166
+ | `health-check` | whether the API is alive |
167
+ | `api-version` | API build info |
168
+ | `resellers` | resellers with a built-in configuration |
169
+
170
+ ```
171
+ smartbox <auth options...> status
172
+ smartbox <auth options...> prog
173
+ ```
174
+
175
+ `guests` needs a home id:
176
+
177
+ smartbox <auth options...> guests -h <home id>
178
+
179
+ `node-samples` reads the temperature/consumption history of one node (`-s`/`-e` default to one hour before/after now):
180
+
181
+ smartbox <auth options...> node-samples -d <device id> -n <node addr> [-s <start unix ts>] [-e <end unix ts>]
182
+
183
+ Writes take named options, one per field to change:
184
+
185
+ smartbox <auth options...> set-status -d <device id> -n <node addr> [--mode auto] [--stemp 21.5 --units C] [--locked false]
186
+ smartbox <auth options...> set-setup -d <device id> -n <node addr> [--control-mode 1] [--offset 0.0] [--units C] [--true-radiant-enabled true] [--window-mode-enabled false] [--priority low]
187
+ smartbox <auth options...> set-prog -d <device id> -n <node addr> '{"prog": {"0": [2, 2, ...], ...}}'
188
+ smartbox <auth options...> set-device-away-status -d <device id> [--away true] [--enabled true] [--forced false]
189
+ smartbox <auth options...> set-device-power-limit -d <device id> <watts>
190
+
191
+ `socket` opens a long-lived socket.io connection and prints `dev_data` and `update` events until interrupted:
192
+
193
+ smartbox <auth options...> socket -d <device id>
194
+
195
+ See [api-notes.md](./api-notes.md) for notes on the REST and socket.io endpoints.
196
+
197
+ ## Development
198
+
199
+ Prerequisites:
200
+
201
+ uv
202
+ python >=3.14.2
203
+
204
+ Clone the repo, install dependencies and install pre-commit hooks:
205
+
206
+ git clone https://github.com/ajtudela/smartbox
207
+ cd smartbox
208
+ uv sync
209
+ pre-commit install
210
+
211
+ ## Testing
212
+
213
+ Run the full suite:
214
+
215
+ uv run pytest
216
+
217
+ Generate a coverage XML (e.g. for use in an editor):
218
+
219
+ uv run pytest --cov-report xml:cov.xml --cov smartbox --cov-append tests/
220
+
221
+ `tox` runs the tests against the installed package:
222
+
223
+ uv run tox
224
+ uv run tox -e py # the single environment declared in pyproject.toml
225
+
226
+ ## Changelog
227
+
228
+ Release notes are kept in [CHANGELOG.md](./CHANGELOG.md).
229
+
230
+ ## Support
231
+
232
+ This is a community project, maintained on a best-effort basis and provided without warranty.
233
+
234
+ ### Getting help and reporting problems
235
+
236
+ Open an issue at <https://github.com/ajtudela/smartbox/issues>. Templates are provided for the common cases:
237
+
238
+ * **Bug report** — a command or method misbehaves. Include the `smartbox` command you ran (or the code), the full output with `-v`/`--verbose`, and your reseller. **Redact the access token and any Basic Auth credential** before pasting logs.
239
+ * **Feature request** — a missing endpoint or option.
240
+ * **New reseller** — your heater vendor is not in the built-in list. The template asks for `api-name`, `x-referer`, `x-serial-id` and the Basic Auth credential; see ["Capturing it from the reseller's web app"](./api-notes.md#capturing-it-from-the-resellers-web-app) in api-notes.md for how to read them from the login request. Once added, everyone using that vendor benefits.
241
+
242
+ For questions about the API itself rather than this library, see [api-notes.md](./api-notes.md).
243
+
244
+ ### Supporting the maintainers
245
+
246
+ If this library is useful to you, you can buy the maintainers a coffee:
247
+
248
+ [![Buy a coffee to ajtudela][buymeacoffee-shield]][buymeacoffee-ajtudela]
249
+ [![Buy a coffee to delmael][buymeacoffee-shield]][buymeacoffee-delmael]
250
+
251
+ [buymeacoffee-ajtudela]: https://www.buymeacoffee.com/ajtudela
252
+ [buymeacoffee-delmael]: https://www.buymeacoffee.com/delmael
253
+
254
+ [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png
@@ -0,0 +1,234 @@
1
+ # smartbox
2
+ ![build](https://github.com/ajtudela/smartbox/workflows/Python%20package/badge.svg)
3
+ [![PyPI version](https://badge.fury.io/py/smartbox.svg)](https://badge.fury.io/py/smartbox)
4
+ [![codecov](https://codecov.io/gh/ajtudela/smartbox/branch/main/graph/badge.svg?token=ghNZOGVzVv)](https://codecov.io/gh/ajtudela/smartbox)
5
+ [![PyPI license](https://img.shields.io/pypi/l/smartbox.svg)](https://pypi.python.org/pypi/smartbox/)
6
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/smartbox.svg)](https://pypi.python.org/pypi/smartbox/)
7
+
8
+ Python API to control heating 'smart boxes' (Helki and its resellers: Elnur, Haverland, Climastar, Technotherm, HJM, ...). It wraps the REST API and the socket.io channel, and ships a `smartbox` command line tool.
9
+
10
+ ## Installation
11
+
12
+ pip install smartbox
13
+
14
+ The package is importable as `smartbox` and ships `py.typed`.
15
+
16
+ ## Library usage
17
+
18
+ `AsyncSmartboxSession` is the entry point. Use it as an async context manager so its HTTP client is closed for you; when you already have an `aiohttp.ClientSession` (for example Home Assistant's shared one) pass it as `websession=` and it will not be closed on exit.
19
+
20
+ ```python
21
+ import asyncio
22
+ from smartbox import AsyncSmartboxSession
23
+
24
+ async def main() -> None:
25
+ async with AsyncSmartboxSession(
26
+ api_name="api", # or SMARTBOX_API_NAME
27
+ username="you@example.com",
28
+ password="secret",
29
+ raw_response=False, # return Pydantic models instead of dicts
30
+ ) as session:
31
+ for device in await session.get_devices():
32
+ for node in await session.get_nodes(device.dev_id):
33
+ status = await session.get_node_status(device.dev_id, node.model_dump())
34
+ print(device.name, node.name, status.mtemp, status.stemp)
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ With `raw_response=True` (the default) every method returns the raw `dict`/`list` from the API. With `raw_response=False` they return models from `smartbox.models` (`HtrNodeStatus`, `DefaultNodeSetup`, ...); the models are lenient (unknown keys kept, fields optional) because the API is undocumented and varies by reseller.
40
+
41
+ Constructor options worth knowing: `retry_attempts`/`backoff_factor` (transient 5xx / connection errors are retried with exponential backoff), `x_referer` / `x_serial_id` / `basic_auth_credentials` for a reseller that is not built in.
42
+
43
+ For live updates over socket.io use `UpdateManager`:
44
+
45
+ ```python
46
+ from smartbox import AsyncSmartboxSession, UpdateManager
47
+
48
+ async with AsyncSmartboxSession(...) as session:
49
+ manager = UpdateManager(session, device_id)
50
+ manager.subscribe_to_node_status(
51
+ lambda node_type, addr, status: print(node_type, addr, status)
52
+ )
53
+ await manager.run() # runs until cancelled
54
+ ```
55
+
56
+ ## Websocket transports
57
+
58
+ Status updates arrive over one of two transports; both are implemented
59
+ in this library and verified live (api-notes.md, "Transports —
60
+ socket_io vs ws_user"):
61
+
62
+ * **`socket_io`** (default): a per-device socket.io connection. The
63
+ `UpdateManager` owns it; `run()` runs the loop, `cancel()` is
64
+ terminal.
65
+ * **`ws_user`** (the vendor web apps' channel): ONE plain-WebSocket
66
+ connection (`WsUserSocketSession`) serves every device of the
67
+ account. Its server-side proxy PINGs keep the session supervised, and
68
+ sockets are cut by the server exactly at the access token's 4 h
69
+ expiry (close code 1006) — the reconnect with a fresh token is
70
+ mandatory and part of the steady state.
71
+
72
+ Caller contract for the shared ws_user socket:
73
+
74
+ socket_ = WsUserSocketSession(session)
75
+ # The caller owns run() — and must SUPERVISE it: run() exits on any
76
+ # failure (it is re-runnable; only cancel() is terminal), and a
77
+ # parked UpdateManager never observes a shared-socket death, so
78
+ # without a supervisor the transport dies silently. Exceptions
79
+ # surface when the task is awaited, so keep and await it.
80
+ task = asyncio.create_task(supervise(socket_.run()))
81
+ socket_.add_device(dev_id, dev_data_cb, update_cb) # register (any time)
82
+ socket_.remove_device(dev_id) # unregister
83
+ await socket_.cancel() # TERMINAL: later run() calls no-op
84
+
85
+ `run()` raises `InvalidAuthError` (rejected credentials — start a
86
+ reauthentication flow) and `WsUserUnsupportedError` (deterministic
87
+ 404/410 — the host does not serve ws_user; fall back to socket_io).
88
+
89
+ `UpdateManager(session, dev_id, ws_user_socket=socket_)` switches an
90
+ individual manager onto the shared connection: `run()` registers the
91
+ manager's device and parks until `cancel()` (the shared socket's own
92
+ restarts are handled by its supervisor, not by per-device consumers).
93
+ Pass-through SocketSession kwargs are ignored in this mode (warned).
94
+
95
+ ## `smartbox` command line tool
96
+
97
+ Use the `smartbox` tool to read status information from your heaters (nodes) and change settings.
98
+
99
+ ### Options
100
+
101
+ `-u`/`--username` and `-p`/`--password` (your mobile-app / web-app credentials) are required for every command except `resellers`. `-v`/`--verbose` enables debug logging.
102
+
103
+ These are only needed when your reseller is not built into the package:
104
+
105
+ * `-a`/`--api-name`: the API name for your heater vendor, from the 'API Host' entry under the 'Version' menu of the mobile/web app. For a host `api-foo.xxxx` or `api.xxxx` use `api-foo` or `api`.
106
+ * `-b`/`--basic-auth-creds`: the HTTP Basic Auth credential used for the initial authentication, as a base64 string. See [Basic Auth Credential](./api-notes.md#basic-auth-credential) in api-notes.md for how to capture it.
107
+ * `-r`/`--x-referer`: the `x-referer` header value.
108
+ * `-i`/`--x-serial-id`: the `x-serialid` header value.
109
+
110
+ ### Configuration via environment variables / `.env`
111
+
112
+ Every option above can be supplied through an environment variable instead of being typed on each invocation. The command line always takes precedence over the environment.
113
+
114
+ | Option | Environment variable |
115
+ | ------------------------- | --------------------------- |
116
+ | `-a`/`--api-name` | `SMARTBOX_API_NAME` |
117
+ | `-b`/`--basic-auth-creds` | `SMARTBOX_BASIC_AUTH_CREDS` |
118
+ | `-u`/`--username` | `SMARTBOX_USERNAME` |
119
+ | `-p`/`--password` | `SMARTBOX_PASSWORD` |
120
+ | `-r`/`--x-referer` | `SMARTBOX_X_REFERER` |
121
+ | `-i`/`--x-serial-id` | `SMARTBOX_X_SERIAL_ID` |
122
+
123
+ The `smartbox` command also reads a `.env` file (searched for in the working directory and its parents) before parsing options, so the usual workflow is to copy [`.env.example`](.env.example) to `.env`, fill it in once, and then run commands without any auth flags:
124
+
125
+ cp .env.example .env
126
+ # edit .env
127
+ smartbox devices
128
+
129
+ ### Commands
130
+
131
+ In the examples below `<auth options...>` stands for the options above (or nothing, when they come from `.env`).
132
+
133
+ Read-only, across every device/node:
134
+
135
+ | Command | What it shows |
136
+ | --- | --- |
137
+ | `devices` | the devices the account can see |
138
+ | `homes` | devices grouped into homes |
139
+ | `nodes` | the nodes of each device |
140
+ | `status` | live status of every node |
141
+ | `setup` | configuration of every node |
142
+ | `prog` | weekly heating schedule of every node |
143
+ | `device-away-status` | away status of every device |
144
+ | `device-connected-status` | whether each device is online |
145
+ | `device-power-limit` | power limit (watts) of every device |
146
+ | `health-check` | whether the API is alive |
147
+ | `api-version` | API build info |
148
+ | `resellers` | resellers with a built-in configuration |
149
+
150
+ ```
151
+ smartbox <auth options...> status
152
+ smartbox <auth options...> prog
153
+ ```
154
+
155
+ `guests` needs a home id:
156
+
157
+ smartbox <auth options...> guests -h <home id>
158
+
159
+ `node-samples` reads the temperature/consumption history of one node (`-s`/`-e` default to one hour before/after now):
160
+
161
+ smartbox <auth options...> node-samples -d <device id> -n <node addr> [-s <start unix ts>] [-e <end unix ts>]
162
+
163
+ Writes take named options, one per field to change:
164
+
165
+ smartbox <auth options...> set-status -d <device id> -n <node addr> [--mode auto] [--stemp 21.5 --units C] [--locked false]
166
+ smartbox <auth options...> set-setup -d <device id> -n <node addr> [--control-mode 1] [--offset 0.0] [--units C] [--true-radiant-enabled true] [--window-mode-enabled false] [--priority low]
167
+ smartbox <auth options...> set-prog -d <device id> -n <node addr> '{"prog": {"0": [2, 2, ...], ...}}'
168
+ smartbox <auth options...> set-device-away-status -d <device id> [--away true] [--enabled true] [--forced false]
169
+ smartbox <auth options...> set-device-power-limit -d <device id> <watts>
170
+
171
+ `socket` opens a long-lived socket.io connection and prints `dev_data` and `update` events until interrupted:
172
+
173
+ smartbox <auth options...> socket -d <device id>
174
+
175
+ See [api-notes.md](./api-notes.md) for notes on the REST and socket.io endpoints.
176
+
177
+ ## Development
178
+
179
+ Prerequisites:
180
+
181
+ uv
182
+ python >=3.14.2
183
+
184
+ Clone the repo, install dependencies and install pre-commit hooks:
185
+
186
+ git clone https://github.com/ajtudela/smartbox
187
+ cd smartbox
188
+ uv sync
189
+ pre-commit install
190
+
191
+ ## Testing
192
+
193
+ Run the full suite:
194
+
195
+ uv run pytest
196
+
197
+ Generate a coverage XML (e.g. for use in an editor):
198
+
199
+ uv run pytest --cov-report xml:cov.xml --cov smartbox --cov-append tests/
200
+
201
+ `tox` runs the tests against the installed package:
202
+
203
+ uv run tox
204
+ uv run tox -e py # the single environment declared in pyproject.toml
205
+
206
+ ## Changelog
207
+
208
+ Release notes are kept in [CHANGELOG.md](./CHANGELOG.md).
209
+
210
+ ## Support
211
+
212
+ This is a community project, maintained on a best-effort basis and provided without warranty.
213
+
214
+ ### Getting help and reporting problems
215
+
216
+ Open an issue at <https://github.com/ajtudela/smartbox/issues>. Templates are provided for the common cases:
217
+
218
+ * **Bug report** — a command or method misbehaves. Include the `smartbox` command you ran (or the code), the full output with `-v`/`--verbose`, and your reseller. **Redact the access token and any Basic Auth credential** before pasting logs.
219
+ * **Feature request** — a missing endpoint or option.
220
+ * **New reseller** — your heater vendor is not in the built-in list. The template asks for `api-name`, `x-referer`, `x-serial-id` and the Basic Auth credential; see ["Capturing it from the reseller's web app"](./api-notes.md#capturing-it-from-the-resellers-web-app) in api-notes.md for how to read them from the login request. Once added, everyone using that vendor benefits.
221
+
222
+ For questions about the API itself rather than this library, see [api-notes.md](./api-notes.md).
223
+
224
+ ### Supporting the maintainers
225
+
226
+ If this library is useful to you, you can buy the maintainers a coffee:
227
+
228
+ [![Buy a coffee to ajtudela][buymeacoffee-shield]][buymeacoffee-ajtudela]
229
+ [![Buy a coffee to delmael][buymeacoffee-shield]][buymeacoffee-delmael]
230
+
231
+ [buymeacoffee-ajtudela]: https://www.buymeacoffee.com/ajtudela
232
+ [buymeacoffee-delmael]: https://www.buymeacoffee.com/delmael
233
+
234
+ [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png