pydaitem 0.1.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 (62) hide show
  1. pydaitem-0.1.0/.github/workflows/publish.yml +49 -0
  2. pydaitem-0.1.0/.github/workflows/test.yml +37 -0
  3. pydaitem-0.1.0/.gitignore +23 -0
  4. pydaitem-0.1.0/CHANGELOG.md +44 -0
  5. pydaitem-0.1.0/LICENSE +21 -0
  6. pydaitem-0.1.0/PKG-INFO +301 -0
  7. pydaitem-0.1.0/README.md +274 -0
  8. pydaitem-0.1.0/docs/api-daitem.md +415 -0
  9. pydaitem-0.1.0/docs/openapi.yaml +1403 -0
  10. pydaitem-0.1.0/docs/project-architecture.md +101 -0
  11. pydaitem-0.1.0/pyproject.toml +75 -0
  12. pydaitem-0.1.0/samples/control.py +66 -0
  13. pydaitem-0.1.0/samples/persist_token.py +42 -0
  14. pydaitem-0.1.0/samples/read_state.py +40 -0
  15. pydaitem-0.1.0/scripts/probe_headers.py +119 -0
  16. pydaitem-0.1.0/scripts/smoke.py +151 -0
  17. pydaitem-0.1.0/src/pydaitem/__init__.py +78 -0
  18. pydaitem-0.1.0/src/pydaitem/cli/__init__.py +39 -0
  19. pydaitem-0.1.0/src/pydaitem/cli/__main__.py +8 -0
  20. pydaitem-0.1.0/src/pydaitem/cli/app.py +84 -0
  21. pydaitem-0.1.0/src/pydaitem/cli/commands.py +118 -0
  22. pydaitem-0.1.0/src/pydaitem/cli/exit_codes.py +9 -0
  23. pydaitem-0.1.0/src/pydaitem/cli/output.py +35 -0
  24. pydaitem-0.1.0/src/pydaitem/cli/parser.py +102 -0
  25. pydaitem-0.1.0/src/pydaitem/cli/schedule.py +115 -0
  26. pydaitem-0.1.0/src/pydaitem/client/__init__.py +102 -0
  27. pydaitem-0.1.0/src/pydaitem/client/account.py +26 -0
  28. pydaitem-0.1.0/src/pydaitem/client/auth.py +193 -0
  29. pydaitem-0.1.0/src/pydaitem/client/commands.py +68 -0
  30. pydaitem-0.1.0/src/pydaitem/client/logbook.py +43 -0
  31. pydaitem-0.1.0/src/pydaitem/client/panel.py +103 -0
  32. pydaitem-0.1.0/src/pydaitem/client/pkce.py +26 -0
  33. pydaitem-0.1.0/src/pydaitem/client/schedule.py +46 -0
  34. pydaitem-0.1.0/src/pydaitem/client/state.py +58 -0
  35. pydaitem-0.1.0/src/pydaitem/client/transport.py +121 -0
  36. pydaitem-0.1.0/src/pydaitem/const/__init__.py +90 -0
  37. pydaitem-0.1.0/src/pydaitem/const/errors.py +26 -0
  38. pydaitem-0.1.0/src/pydaitem/const/semantics.py +137 -0
  39. pydaitem-0.1.0/src/pydaitem/const/wire.py +18 -0
  40. pydaitem-0.1.0/src/pydaitem/credentials.py +99 -0
  41. pydaitem-0.1.0/src/pydaitem/exceptions.py +61 -0
  42. pydaitem-0.1.0/src/pydaitem/models/__init__.py +28 -0
  43. pydaitem-0.1.0/src/pydaitem/models/_shared.py +19 -0
  44. pydaitem-0.1.0/src/pydaitem/models/account.py +30 -0
  45. pydaitem-0.1.0/src/pydaitem/models/inventory.py +126 -0
  46. pydaitem-0.1.0/src/pydaitem/models/schedule.py +68 -0
  47. pydaitem-0.1.0/src/pydaitem/models/status.py +93 -0
  48. pydaitem-0.1.0/src/pydaitem/py.typed +0 -0
  49. pydaitem-0.1.0/src/pydaitem/system/__init__.py +97 -0
  50. pydaitem-0.1.0/src/pydaitem/system/capabilities.py +101 -0
  51. pydaitem-0.1.0/src/pydaitem/system/commands.py +67 -0
  52. pydaitem-0.1.0/src/pydaitem/system/schedule.py +43 -0
  53. pydaitem-0.1.0/src/pydaitem/system/state.py +13 -0
  54. pydaitem-0.1.0/src/pydaitem/tokens.py +106 -0
  55. pydaitem-0.1.0/tests/test_cli.py +283 -0
  56. pydaitem-0.1.0/tests/test_errors.py +204 -0
  57. pydaitem-0.1.0/tests/test_login.py +85 -0
  58. pydaitem-0.1.0/tests/test_schedule.py +158 -0
  59. pydaitem-0.1.0/tests/test_semantics.py +137 -0
  60. pydaitem-0.1.0/tests/test_system.py +182 -0
  61. pydaitem-0.1.0/tests/test_tokens.py +218 -0
  62. pydaitem-0.1.0/tests/test_units.py +144 -0
@@ -0,0 +1,49 @@
1
+ # PyPI publishing via trusted publishing (OIDC): no token to store.
2
+ #
3
+ # One-time setup at https://pypi.org/manage/account/publishing/: declare this repository
4
+ # as a trusted publisher for the "pydaitem" project, naming the workflow (publish.yml) and
5
+ # the environment (pypi).
6
+ #
7
+ # Ordering matters: publish the library BEFORE releasing the ha-daitem version that pins
8
+ # it, otherwise installation breaks for users.
9
+
10
+ name: Publish to PyPI
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+
27
+ - name: Build artifacts
28
+ run: |
29
+ python -m pip install --upgrade pip build
30
+ python -m build
31
+
32
+ - uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+
37
+ publish:
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ environment: pypi
41
+ permissions:
42
+ id-token: write # required for trusted publishing
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,37 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install dependencies
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install -e ".[dev]"
27
+
28
+ - name: Lint (ruff)
29
+ run: |
30
+ ruff check .
31
+ ruff format --check .
32
+
33
+ - name: Type check (mypy)
34
+ run: mypy src/pydaitem
35
+
36
+ - name: Unit tests
37
+ run: pytest -q
@@ -0,0 +1,23 @@
1
+ # Environments and build
2
+ .venv/
3
+ venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+
8
+ # Python
9
+ __pycache__/
10
+ *.py[cod]
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Secrets: never commit credentials, alarm codes or persisted tokens
16
+ .env
17
+ secrets.*
18
+ *.credentials
19
+ credentials.json
20
+ # Note: do not use a broad "credentials*" rule. It would also exclude source modules
21
+ # named credentials.py, which silently drops them from the built package.
22
+ *.token
23
+ .daitem.token
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
4
+ versioning.
5
+
6
+ ## [0.1.0] - 2026-09-13
7
+
8
+ First public release.
9
+
10
+ ### Added
11
+
12
+ - `DaitemClient`, the async low-level client for the private Daitem Secure API, grouped by
13
+ resource: `account`, `panel`, `commands`, `logbook`, `schedule`. Authentication uses
14
+ OAuth2 authorization_code + PKCE, driven headlessly. The client identifies itself
15
+ honestly rather than impersonating the mobile app: only `X-App-Name: eNova` is kept
16
+ as-is, a product routing key the gateway requires.
17
+ - `DaitemSystem`, the semantic façade binding a client, a system id and an alarm code, and
18
+ `connect()`, an async context manager yielding a ready one in one step. Grouped into
19
+ `capabilities`, `commands`, `schedule`, it owns the session protocol, preset indices and
20
+ group identifiers, so a change on the Daitem side never reaches consumer code.
21
+ - `PanelState`, `ArmMode` and `Fault`: semantic vocabularies owned by the library, mapped
22
+ from the raw API in one place. An unmapped value degrades to `UNKNOWN`
23
+ (`Anomalies.unknown_keys` for faults) with a one-off warning, rather than failing or
24
+ silently reading as absent.
25
+ - Full, presence/partial-preset and direct per-group arming and disarming
26
+ (`system.commands.*`), mirroring the mobile app. Commands are never retried: a replayed
27
+ request could arm or disarm twice.
28
+ - Recurring arm/disarm programs (`Schedule`, `ScheduleProgram`, `system.schedule.*`).
29
+ Owner-only, confirmed live: a restricted account gets `DaitemForbiddenError`. Creating a
30
+ new program has never been observed on the live API and is not supported; `set` only
31
+ updates an existing `id`.
32
+ - The exception hierarchy rooted at `DaitemError` covers every failure the library raises,
33
+ credential resolution included (`MissingCredentials`).
34
+ - Refresh token persistence through a pluggable `TokenStore` protocol, with
35
+ `FileTokenStore` (atomic write, mode `0600`) and `MemoryTokenStore`. A client with a
36
+ store resumes its session instead of replaying the Keycloak login form on every start.
37
+ - `resolve_credentials()`, resolving from arguments, environment variables then an env
38
+ file, and a `pydaitem` command line (`status`, `devices`, `presets`, `arm`, `disarm`,
39
+ `schedule ...`) with `--json` output and documented exit codes, so cron jobs, shell
40
+ scripts and MQTT bridges need no Python. Built on argparse, adding no dependency.
41
+ - `py.typed` (PEP 561), and a documented public API contract in the README separating the
42
+ stable surface from what tracks the API.
43
+ - `samples/`: runnable examples for reading state, persisting the token, and controlling
44
+ the alarm.
pydaitem-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yannick Olympio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.5
2
+ Name: pydaitem
3
+ Version: 0.1.0
4
+ Summary: Unofficial async client for the Daitem Secure API (Atral Topaze platform)
5
+ Project-URL: Homepage, https://github.com/tomahoax/pydaitem
6
+ Project-URL: Issues, https://github.com/tomahoax/pydaitem/issues
7
+ Author: Yannick Olympio
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: alarm,atral,daitem,home-assistant,home-automation,topaze
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Home Automation
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: aiohttp>=3.13.4
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.11; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.6; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pydaitem
29
+
30
+ **Unofficial** async Python client for the private Daitem Secure API (Atral "Topaze"
31
+ platform). It reads and controls a Daitem alarm from code, without going through the
32
+ mobile app.
33
+
34
+ Usable on its own from a script, a cron job, an MQTT bridge or the shell, and used as the
35
+ foundation of the [ha-daitem](https://github.com/tomahoax/ha-daitem) Home Assistant
36
+ integration. Nothing in the library depends on Home Assistant.
37
+
38
+ > **Not affiliated with Daitem or the Atral group.** The API it consumes is private,
39
+ > undocumented and non-contractual: it may change without notice with any app update. Use
40
+ > at your own risk, on your own hardware and with your own account.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install pydaitem
46
+ ```
47
+
48
+ Only dependency: `aiohttp`, already present in Home Assistant.
49
+
50
+ ## Command line
51
+
52
+ Reading a state or driving the alarm from a shell, a cron job or a Node-RED flow needs no
53
+ Python:
54
+
55
+ ```bash
56
+ pydaitem status --json
57
+ pydaitem devices
58
+ pydaitem presets
59
+ pydaitem arm --mode away --yes
60
+ ```
61
+
62
+ `presets` prints the raw partial-arming presets (`id` and `name`), the installation's own
63
+ wording before `capabilities.arm_modes()` resolves it into the stable `presence`/`partial`
64
+ vocabulary — useful to see what an installer named a preset, or what it was renamed to in
65
+ the app.
66
+
67
+ Credentials come from the environment or `--env-file`, and `--token-file` caches the
68
+ refresh token so repeated runs reuse a session.
69
+
70
+ Exit codes let a caller branch without parsing text, and are part of the compatibility
71
+ promise:
72
+
73
+ | Code | Meaning |
74
+ |------|---------|
75
+ | 0 | Success |
76
+ | 1 | Generic failure |
77
+ | 2 | Usage error, or confirmation impossible |
78
+ | 3 | Panel session held by another device |
79
+ | 4 | Authentication failure |
80
+
81
+ Code 3 is not a failure: it is the normal outcome when someone has the mobile app open, so
82
+ an automation should retry rather than alert.
83
+
84
+ ```bash
85
+ # Arm at night, tolerating someone using the app.
86
+ pydaitem arm --yes || [ $? -eq 3 ] && echo "retry later"
87
+ ```
88
+
89
+ **Arming and disarming ask for confirmation.** Without a terminal and without `--yes` the
90
+ command refuses rather than proceeding: a scheduled job must state its intent, and silently
91
+ arming a house because nobody could answer a prompt is the wrong default.
92
+
93
+ ## Schedules
94
+
95
+ Recurring arm/disarm programs, the same feature the app calls "planning":
96
+
97
+ ```bash
98
+ pydaitem schedule list --json
99
+ pydaitem schedule set 1 --hour 23 --minute 30 --yes # only the given fields change
100
+ pydaitem schedule set 1 --disarm --yes
101
+ pydaitem schedule delete 3 --yes
102
+ pydaitem schedule activate --yes # master switch for the whole schedule
103
+ pydaitem schedule deactivate --yes
104
+ ```
105
+
106
+ **Creating a new program is not supported.** It was never observed on the live API (no
107
+ official documentation exists to confirm the endpoint), so `schedule set` can only update
108
+ an existing program's `id` — read it from `schedule list` first. `set`, `delete`,
109
+ `activate` and `deactivate` ask for confirmation like `arm`/`disarm`: they drive the alarm
110
+ indirectly, by changing when it will arm or disarm itself.
111
+
112
+ **Owner-only, confirmed live.** A restricted account (the kind recommended for the
113
+ `ha-daitem` integration) gets a `DaitemForbiddenError` on every schedule call — use the
114
+ account's owner credentials instead.
115
+
116
+ ## Usage from Python
117
+
118
+ The quickest path is `connect()`, which owns the client and picks the account's first
119
+ installation:
120
+
121
+ ```python
122
+ import asyncio
123
+ from pydaitem import connect
124
+
125
+
126
+ async def main() -> None:
127
+ async with connect("me@example.com", "password", "1234") as system:
128
+ status = await system.read_status()
129
+ print(status.panel_state, status.active_groups)
130
+
131
+
132
+ asyncio.run(main())
133
+ ```
134
+
135
+ For finer control, work through `DaitemSystem`. It hides the endpoints, the session protocol, preset indices
136
+ and group identifiers, so a change on the Daitem side is absorbed here rather than in your
137
+ code.
138
+
139
+ ```python
140
+ import asyncio
141
+ from pydaitem import DaitemClient, DaitemSystem
142
+
143
+
144
+ async def main() -> None:
145
+ async with DaitemClient("me@example.com", "password") as client:
146
+ systems = await client.account.list_systems()
147
+ system = DaitemSystem(client, systems[0].id, "1234")
148
+
149
+ status = await system.read_status()
150
+ print(status.panel_state, status.active_groups)
151
+
152
+ inventory = await system.read_inventory()
153
+ print(len(inventory.sensors), "detectors")
154
+ print(inventory.central_anomalies.faults)
155
+
156
+
157
+ asyncio.run(main())
158
+ ```
159
+
160
+ Commands are single calls; the session is opened and released for you:
161
+
162
+ ```python
163
+ await system.commands.arm_away()
164
+ await system.commands.arm_presence()
165
+ await system.commands.disarm()
166
+
167
+ # Only advertise what the installation supports.
168
+ modes = await system.capabilities.arm_modes()
169
+ ```
170
+
171
+ ## Reusing a session between runs
172
+
173
+ By default every process start replays the Keycloak login form. Pass a token store and the
174
+ client resumes from the stored refresh token instead, falling back to a full login only if
175
+ that token is rejected.
176
+
177
+ ```python
178
+ from pydaitem import DaitemClient, FileTokenStore
179
+
180
+ store = FileTokenStore("~/.daitem.token")
181
+ async with DaitemClient(email, password, token_store=store) as client:
182
+ ...
183
+ ```
184
+
185
+ `FileTokenStore` writes atomically with mode `0600`: the refresh token is a credential and
186
+ should be treated like a password. It does real disk I/O, so inside Home Assistant back the
187
+ store with the config entry instead; the protocol is async precisely so that blocking I/O
188
+ never reaches the event loop.
189
+
190
+ Credentials themselves can be resolved from arguments, environment variables then an env
191
+ file:
192
+
193
+ ```python
194
+ from pydaitem import resolve_credentials
195
+
196
+ creds = resolve_credentials(env_file="~/.daitem.env")
197
+ ```
198
+
199
+ See `samples/` for runnable examples.
200
+
201
+ ## Public API contract
202
+
203
+ The point of this library is that **a change in the Daitem API should never force a change
204
+ in your code**. That only holds if you stay on the stable surface.
205
+
206
+ **Stable.** Breaking changes here get a major version bump.
207
+
208
+ - `DaitemSystem`: its plain reads (`read_status`, `read_inventory`, `system_id`), and its
209
+ resource namespaces `capabilities`, `commands`, `schedule`.
210
+ - `PanelState`, `ArmMode`, `Fault`: semantic vocabularies owned by this library.
211
+ - `TokenStore`, `FileTokenStore`, `MemoryTokenStore`, `connect()` and
212
+ `resolve_credentials`.
213
+ - The command line: its subcommands, its `--json` shape, and its exit codes.
214
+ - The exception hierarchy rooted at `DaitemError`.
215
+ - Model attributes and the semantic properties: `SystemStatus.panel_state`, `.is_armed`,
216
+ `.is_arming`, `.active_groups`, `Anomalies.faults`, `.has()`, `Inventory.sensors`,
217
+ `Inventory.controls`, and so on.
218
+
219
+ **Not stable.** These track the API and may change in any release.
220
+
221
+ - `DaitemClient` low-level methods, which mirror endpoints one for one.
222
+ - `SystemStatus.state`: the raw API string. Use `panel_state` instead.
223
+ - `Anomalies.flags` and `.active`: raw JSON keys. Use `faults` or `has()`.
224
+ - Any `.raw` payload, and anything underscore-prefixed.
225
+ - `SystemState`, deliberately absent from `pydaitem.__all__`.
226
+
227
+ **Unknown values degrade, they do not break.** A state this library does not know maps to
228
+ `PanelState.UNKNOWN`, an unmapped fault key lands in `Anomalies.unknown_keys`, and both log
229
+ a warning once. A Daitem update therefore shows up in your logs instead of silently
230
+ reading as "no state".
231
+
232
+ ## API constraints worth knowing before you design around it
233
+
234
+ These are not library choices, they are measured properties of the API, and they shape
235
+ any integration.
236
+
237
+ **One session per panel, across all accounts.** Calling `connect` while another device
238
+ (typically the mobile app) holds the session returns `DaitemSessionBusyError`. Creating a
239
+ secondary account does not work around it. Hence `client.panel.session()`, which releases
240
+ as soon as possible, and `client.panel.get_status()`, which reads without opening anything
241
+ when it can.
242
+
243
+ **Reading the state requires an existing session**, opened by any device. Without one,
244
+ `client.panel.get_state()` raises `DaitemNoSessionError`. `get_status()` handles that case.
245
+
246
+ **No live per-detector state.** The inventory gives identity, group, inhibition and
247
+ **faults** (battery, tamper, radio, masking), but not the open/closed state of a contact.
248
+ An opening only shows up in the panel history, while the system is armed.
249
+
250
+ **Faults live in the inventory**, so `read_inventory()` must be called on every poll cycle
251
+ for fault reporting to stay live. It needs no panel session, so this is cheap.
252
+
253
+ **Browser-flow authentication.** The Keycloak realm refuses the `password` grant, so the
254
+ library drives the `authorization_code` + PKCE flow server-side, without a browser.
255
+
256
+ **The logbook and the schedule are owner-only.** A restricted account gets a 403 on both
257
+ (confirmed live for the schedule endpoints).
258
+
259
+ ## System states
260
+
261
+ | `PanelState` | Meaning |
262
+ |--------------|---------|
263
+ | `DISARMED` | Disarmed |
264
+ | `ARMING` | An exit delay is running |
265
+ | `ARMED_FULL` | Every group armed |
266
+ | `ARMED_PRESENCE` | Partially armed through a named preset |
267
+ | `ARMED_GROUPS` | Partially armed by direct group activation |
268
+ | `TRIGGERED` | Alarm going off (raw value never captured yet) |
269
+ | `UNKNOWN` | A state this library does not know about |
270
+
271
+ ## Client identification
272
+
273
+ The client identifies itself honestly rather than posing as the mobile app:
274
+
275
+ ```
276
+ X-App-Name: eNova
277
+ X-App-Platform: python
278
+ X-App-Version: <library version>
279
+ User-Agent: pydaitem/<version> (+https://github.com/tomahoax/pydaitem) aiohttp/<v> Python/<v>
280
+ ```
281
+
282
+ `X-App-Name` keeps the app value because the server requires it to route to the right
283
+ product line; every other header is our own. This was verified by probing: without
284
+ `X-App-Name` the gateway returns 400, and any other value returns 500.
285
+
286
+ ## Development
287
+
288
+ ```bash
289
+ python -m venv .venv && source .venv/bin/activate
290
+ pip install -e ".[dev]"
291
+ pytest # unit tests, no network, no alarm
292
+ ruff check .
293
+ mypy src/pydaitem
294
+ ```
295
+
296
+ The bundled tests never call the API and never drive an alarm. `scripts/smoke.py` does
297
+ exercise the live API and is excluded from the published package.
298
+
299
+ ## Licence
300
+
301
+ MIT.