pynapoleon 0.0.1__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.
- pynapoleon-0.0.1/LICENSE +21 -0
- pynapoleon-0.0.1/PKG-INFO +103 -0
- pynapoleon-0.0.1/README.md +76 -0
- pynapoleon-0.0.1/pyproject.toml +61 -0
- pynapoleon-0.0.1/setup.cfg +4 -0
- pynapoleon-0.0.1/src/pynapoleon/__init__.py +32 -0
- pynapoleon-0.0.1/src/pynapoleon/__main__.py +281 -0
- pynapoleon-0.0.1/src/pynapoleon/client.py +184 -0
- pynapoleon-0.0.1/src/pynapoleon/const.py +150 -0
- pynapoleon-0.0.1/src/pynapoleon/device.py +362 -0
- pynapoleon-0.0.1/src/pynapoleon/errors.py +40 -0
- pynapoleon-0.0.1/src/pynapoleon/models.py +94 -0
- pynapoleon-0.0.1/src/pynapoleon/py.typed +0 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/PKG-INFO +103 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/SOURCES.txt +24 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/dependency_links.txt +1 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/entry_points.txt +2 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/requires.txt +11 -0
- pynapoleon-0.0.1/src/pynapoleon.egg-info/top_level.txt +1 -0
- pynapoleon-0.0.1/tests/test_cli.py +364 -0
- pynapoleon-0.0.1/tests/test_client.py +272 -0
- pynapoleon-0.0.1/tests/test_encoding.py +53 -0
- pynapoleon-0.0.1/tests/test_live.py +79 -0
- pynapoleon-0.0.1/tests/test_set_validation.py +128 -0
- pynapoleon-0.0.1/tests/test_state_decode.py +69 -0
- pynapoleon-0.0.1/tests/test_write_batch.py +112 -0
pynapoleon-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sslivins
|
|
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,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pynapoleon
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python library for Napoleon Astound-series fireplaces (Ayla IoT platform)
|
|
5
|
+
Author-email: Stefan Slivinski <sslivins@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sslivins/pynapoleon
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/sslivins/pynapoleon/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: ayla-iot-unofficial<2,>=1.5.0
|
|
17
|
+
Requires-Dist: aiohttp>=3.11.12
|
|
18
|
+
Provides-Extra: tests
|
|
19
|
+
Requires-Dist: pytest>=8.3.4; extra == "tests"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.25.3; extra == "tests"
|
|
21
|
+
Requires-Dist: aioresponses>=0.7.8; extra == "tests"
|
|
22
|
+
Requires-Dist: python-dotenv>=1.0.1; extra == "tests"
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "tests"
|
|
24
|
+
Requires-Dist: ruff>=0.6.0; extra == "tests"
|
|
25
|
+
Requires-Dist: mypy>=1.10.0; extra == "tests"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# pynapoleon
|
|
29
|
+
|
|
30
|
+
Standalone Python library for Napoleon Astound-series fireplaces.
|
|
31
|
+
|
|
32
|
+
Napoleon's cloud is the [Ayla Networks](https://www.aylanetworks.com/) IoT
|
|
33
|
+
platform, so this library is a thin Napoleon-property mapping layer on top of
|
|
34
|
+
[`ayla-iot-unofficial`](https://pypi.org/project/ayla-iot-unofficial/) (the
|
|
35
|
+
same package the Shark vacuum Home Assistant integration uses).
|
|
36
|
+
|
|
37
|
+
> **Status:** alpha — under active reverse-engineering. APIs will change.
|
|
38
|
+
|
|
39
|
+
## Features (planned)
|
|
40
|
+
|
|
41
|
+
- Async login / token refresh (delegated to `ayla-iot-unofficial`)
|
|
42
|
+
- Discover fireplaces on the account
|
|
43
|
+
- Read state: power, flame, heater, setpoint, ember/top RGB lights, schedules
|
|
44
|
+
- Write state via batch datapoints (single round-trip)
|
|
45
|
+
- Apply favourites (`partytime`, `campfirewarmth`, `summerday`, `glowingsunset`)
|
|
46
|
+
- Celsius-native (with helpers for Fahrenheit display)
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
pip install pynapoleon
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from pynapoleon import NapoleonClient
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
async with NapoleonClient(
|
|
62
|
+
email="you@example.com",
|
|
63
|
+
password="...",
|
|
64
|
+
# app_id / app_secret default to the Napoleon mobile app values;
|
|
65
|
+
# override only if you've registered your own Ayla app.
|
|
66
|
+
) as client:
|
|
67
|
+
await client.login()
|
|
68
|
+
for fp in await client.fireplaces():
|
|
69
|
+
await fp.refresh()
|
|
70
|
+
print(fp.name, "power:", fp.power, "flame:", fp.flame_speed)
|
|
71
|
+
await fp.set_setpoint_c(20)
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
A small CLI is provided for manual testing:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
python -m pynapoleon login
|
|
82
|
+
python -m pynapoleon list
|
|
83
|
+
python -m pynapoleon state <DSN>
|
|
84
|
+
python -m pynapoleon set <DSN> power_on_off=1
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Security note
|
|
88
|
+
|
|
89
|
+
Like any Ayla-based device, talking to the cloud requires an `app_id` and
|
|
90
|
+
`app_secret`. This library ships the values used by the Napoleon mobile app
|
|
91
|
+
as defaults; they are **not secrets** in the cryptographic sense (any
|
|
92
|
+
mitmproxy capture exposes them), but the project does not endorse abuse.
|
|
93
|
+
|
|
94
|
+
Do **not** commit credential files, tokens, or mitmproxy captures.
|
|
95
|
+
|
|
96
|
+
## Reverse-engineering notes
|
|
97
|
+
|
|
98
|
+
See [`docs/protocol.md`](docs/protocol.md) for the property catalog and
|
|
99
|
+
write-command details derived from app traffic.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# pynapoleon
|
|
2
|
+
|
|
3
|
+
Standalone Python library for Napoleon Astound-series fireplaces.
|
|
4
|
+
|
|
5
|
+
Napoleon's cloud is the [Ayla Networks](https://www.aylanetworks.com/) IoT
|
|
6
|
+
platform, so this library is a thin Napoleon-property mapping layer on top of
|
|
7
|
+
[`ayla-iot-unofficial`](https://pypi.org/project/ayla-iot-unofficial/) (the
|
|
8
|
+
same package the Shark vacuum Home Assistant integration uses).
|
|
9
|
+
|
|
10
|
+
> **Status:** alpha — under active reverse-engineering. APIs will change.
|
|
11
|
+
|
|
12
|
+
## Features (planned)
|
|
13
|
+
|
|
14
|
+
- Async login / token refresh (delegated to `ayla-iot-unofficial`)
|
|
15
|
+
- Discover fireplaces on the account
|
|
16
|
+
- Read state: power, flame, heater, setpoint, ember/top RGB lights, schedules
|
|
17
|
+
- Write state via batch datapoints (single round-trip)
|
|
18
|
+
- Apply favourites (`partytime`, `campfirewarmth`, `summerday`, `glowingsunset`)
|
|
19
|
+
- Celsius-native (with helpers for Fahrenheit display)
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install pynapoleon
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from pynapoleon import NapoleonClient
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
async with NapoleonClient(
|
|
35
|
+
email="you@example.com",
|
|
36
|
+
password="...",
|
|
37
|
+
# app_id / app_secret default to the Napoleon mobile app values;
|
|
38
|
+
# override only if you've registered your own Ayla app.
|
|
39
|
+
) as client:
|
|
40
|
+
await client.login()
|
|
41
|
+
for fp in await client.fireplaces():
|
|
42
|
+
await fp.refresh()
|
|
43
|
+
print(fp.name, "power:", fp.power, "flame:", fp.flame_speed)
|
|
44
|
+
await fp.set_setpoint_c(20)
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
A small CLI is provided for manual testing:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
python -m pynapoleon login
|
|
55
|
+
python -m pynapoleon list
|
|
56
|
+
python -m pynapoleon state <DSN>
|
|
57
|
+
python -m pynapoleon set <DSN> power_on_off=1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Security note
|
|
61
|
+
|
|
62
|
+
Like any Ayla-based device, talking to the cloud requires an `app_id` and
|
|
63
|
+
`app_secret`. This library ships the values used by the Napoleon mobile app
|
|
64
|
+
as defaults; they are **not secrets** in the cryptographic sense (any
|
|
65
|
+
mitmproxy capture exposes them), but the project does not endorse abuse.
|
|
66
|
+
|
|
67
|
+
Do **not** commit credential files, tokens, or mitmproxy captures.
|
|
68
|
+
|
|
69
|
+
## Reverse-engineering notes
|
|
70
|
+
|
|
71
|
+
See [`docs/protocol.md`](docs/protocol.md) for the property catalog and
|
|
72
|
+
write-command details derived from app traffic.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# pyproject.toml
|
|
2
|
+
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "pynapoleon"
|
|
9
|
+
version = "0.0.1"
|
|
10
|
+
description = "Python library for Napoleon Astound-series fireplaces (Ayla IoT platform)"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Stefan Slivinski", email = "sslivins@gmail.com" }
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Home Automation"
|
|
22
|
+
]
|
|
23
|
+
# Runtime dependencies
|
|
24
|
+
dependencies = [
|
|
25
|
+
"ayla-iot-unofficial>=1.5.0,<2",
|
|
26
|
+
"aiohttp>=3.11.12"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
tests = [
|
|
31
|
+
"pytest>=8.3.4",
|
|
32
|
+
"pytest-asyncio>=0.25.3",
|
|
33
|
+
"aioresponses>=0.7.8",
|
|
34
|
+
"python-dotenv>=1.0.1",
|
|
35
|
+
"pytest-cov>=5.0.0",
|
|
36
|
+
"ruff>=0.6.0",
|
|
37
|
+
"mypy>=1.10.0"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
"Homepage" = "https://github.com/sslivins/pynapoleon"
|
|
42
|
+
"Bug Tracker" = "https://github.com/sslivins/pynapoleon/issues"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
pynapoleon = "pynapoleon.__main__:main"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools]
|
|
48
|
+
package-dir = {"" = "src"}
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
python_version = "3.11"
|
|
55
|
+
strict_optional = true
|
|
56
|
+
warn_unused_ignores = true
|
|
57
|
+
ignore_missing_imports = false
|
|
58
|
+
|
|
59
|
+
[[tool.mypy.overrides]]
|
|
60
|
+
module = ["ayla_iot_unofficial", "ayla_iot_unofficial.*"]
|
|
61
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""pynapoleon — Python client for Napoleon Astound-series fireplaces."""
|
|
2
|
+
|
|
3
|
+
from .client import NapoleonClient
|
|
4
|
+
from .device import Fireplace, decode_setpoint_c, encode_setpoint_c
|
|
5
|
+
from .errors import (
|
|
6
|
+
NapoleonApiError,
|
|
7
|
+
NapoleonAuthError,
|
|
8
|
+
NapoleonConnectionError,
|
|
9
|
+
NapoleonError,
|
|
10
|
+
NapoleonNotFoundError,
|
|
11
|
+
NapoleonValueError,
|
|
12
|
+
)
|
|
13
|
+
from .models import DaySchedule, FireplaceInfo, FireplaceState
|
|
14
|
+
|
|
15
|
+
__version__ = "0.0.1"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"NapoleonClient",
|
|
19
|
+
"Fireplace",
|
|
20
|
+
"FireplaceInfo",
|
|
21
|
+
"FireplaceState",
|
|
22
|
+
"DaySchedule",
|
|
23
|
+
"encode_setpoint_c",
|
|
24
|
+
"decode_setpoint_c",
|
|
25
|
+
"NapoleonError",
|
|
26
|
+
"NapoleonAuthError",
|
|
27
|
+
"NapoleonApiError",
|
|
28
|
+
"NapoleonConnectionError",
|
|
29
|
+
"NapoleonNotFoundError",
|
|
30
|
+
"NapoleonValueError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Smoke / debugging CLI: ``python -m pynapoleon``.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
* ``login`` — verify credentials work, exit 0 / non-zero
|
|
6
|
+
* ``list`` — list discovered fireplaces (DSN + name)
|
|
7
|
+
* ``state [--dsn DSN]`` — print the current state of one fireplace as JSON
|
|
8
|
+
* ``set <prop> <value> [--dsn DSN]``
|
|
9
|
+
— write a single property (e.g. ``power on``,
|
|
10
|
+
``flame_speed 3``, ``setpoint_c 20``,
|
|
11
|
+
``favourite partytime``)
|
|
12
|
+
|
|
13
|
+
DSN resolution: ``--dsn`` flag → ``NAPOLEON_DSN`` env var → auto-pick when
|
|
14
|
+
the account has exactly one fireplace; otherwise the CLI exits with a
|
|
15
|
+
helpful list of devices.
|
|
16
|
+
|
|
17
|
+
This is intended as a developer / smoke tool, NOT a long-lived process,
|
|
18
|
+
so each invocation does exactly one ``login()`` and one ``close()``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import dataclasses
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from . import const as C
|
|
32
|
+
from .client import NapoleonClient
|
|
33
|
+
from .device import Fireplace
|
|
34
|
+
from .errors import NapoleonAuthError, NapoleonError, NapoleonValueError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Helpers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
def _maybe_load_dotenv() -> None:
|
|
41
|
+
"""Best-effort load of ``.env`` next to the CWD.
|
|
42
|
+
|
|
43
|
+
``python-dotenv`` is a *test* extra, so we import it optionally — when
|
|
44
|
+
the package is installed normally (e.g. from PyPI) the CLI still works
|
|
45
|
+
using only the real process environment.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from dotenv import load_dotenv
|
|
49
|
+
except ImportError:
|
|
50
|
+
return
|
|
51
|
+
load_dotenv()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env(name: str) -> str | None:
|
|
55
|
+
"""Return ``os.environ[name]`` only when it is set AND non-empty.
|
|
56
|
+
|
|
57
|
+
Empty-string secrets in CI must NOT override the constants in
|
|
58
|
+
:mod:`pynapoleon.const`.
|
|
59
|
+
"""
|
|
60
|
+
val = os.environ.get(name)
|
|
61
|
+
return val if val else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_client_from_env() -> NapoleonClient:
|
|
65
|
+
email = _env("NAPOLEON_EMAIL")
|
|
66
|
+
password = _env("NAPOLEON_PASSWORD")
|
|
67
|
+
if not email or not password:
|
|
68
|
+
raise SystemExit(
|
|
69
|
+
"NAPOLEON_EMAIL and NAPOLEON_PASSWORD must be set "
|
|
70
|
+
"(via process env or a .env file)."
|
|
71
|
+
)
|
|
72
|
+
kwargs: dict[str, Any] = {}
|
|
73
|
+
app_id = _env("NAPOLEON_APP_ID")
|
|
74
|
+
app_secret = _env("NAPOLEON_APP_SECRET")
|
|
75
|
+
if (app_id is None) != (app_secret is None):
|
|
76
|
+
raise SystemExit(
|
|
77
|
+
"NAPOLEON_APP_ID and NAPOLEON_APP_SECRET must be set together "
|
|
78
|
+
"(or both omitted to use the built-in defaults)."
|
|
79
|
+
)
|
|
80
|
+
if app_id and app_secret:
|
|
81
|
+
kwargs["app_id"] = app_id
|
|
82
|
+
kwargs["app_secret"] = app_secret
|
|
83
|
+
return NapoleonClient(email, password, **kwargs)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_fireplace(
|
|
87
|
+
fireplaces: list[Fireplace], dsn: str | None
|
|
88
|
+
) -> Fireplace:
|
|
89
|
+
if dsn:
|
|
90
|
+
for fp in fireplaces:
|
|
91
|
+
if fp.dsn == dsn:
|
|
92
|
+
return fp
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
f"DSN {dsn!r} not found. Visible fireplaces: "
|
|
95
|
+
+ ", ".join(fp.dsn for fp in fireplaces)
|
|
96
|
+
)
|
|
97
|
+
if len(fireplaces) == 1:
|
|
98
|
+
return fireplaces[0]
|
|
99
|
+
if not fireplaces:
|
|
100
|
+
raise SystemExit("No Napoleon fireplaces found on this account.")
|
|
101
|
+
listing = "\n".join(f" {fp.dsn}\t{fp.name}" for fp in fireplaces)
|
|
102
|
+
raise SystemExit(
|
|
103
|
+
"Multiple fireplaces found; specify one with --dsn or NAPOLEON_DSN:\n"
|
|
104
|
+
+ listing
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _state_to_json(fp: Fireplace) -> str:
|
|
109
|
+
payload = {
|
|
110
|
+
"dsn": fp.dsn,
|
|
111
|
+
"name": fp.name,
|
|
112
|
+
"state": dataclasses.asdict(fp.state),
|
|
113
|
+
}
|
|
114
|
+
return json.dumps(payload, indent=2, sort_keys=True, default=str)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# `set` value parsing
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
_BOOL_ON = {"on", "true", "1", "yes"}
|
|
121
|
+
_BOOL_OFF = {"off", "false", "0", "no"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_bool(s: str) -> bool:
|
|
125
|
+
low = s.strip().lower()
|
|
126
|
+
if low in _BOOL_ON:
|
|
127
|
+
return True
|
|
128
|
+
if low in _BOOL_OFF:
|
|
129
|
+
return False
|
|
130
|
+
raise NapoleonValueError(f"expected on/off, got {s!r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_int(s: str) -> int:
|
|
134
|
+
try:
|
|
135
|
+
return int(s.strip(), 0)
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise NapoleonValueError(f"expected integer, got {s!r}") from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _apply_set(fp: Fireplace, prop: str, value: str) -> None:
|
|
141
|
+
p = prop.strip().lower()
|
|
142
|
+
if p == "power":
|
|
143
|
+
await fp.set_power(_parse_bool(value))
|
|
144
|
+
elif p == "flame_speed":
|
|
145
|
+
await fp.set_flame_speed(_parse_int(value))
|
|
146
|
+
elif p == "orange_flame":
|
|
147
|
+
await fp.set_orange_flame(_parse_int(value))
|
|
148
|
+
elif p == "yellow_flame":
|
|
149
|
+
await fp.set_yellow_flame(_parse_int(value))
|
|
150
|
+
elif p == "heater":
|
|
151
|
+
await fp.set_heater(_parse_int(value))
|
|
152
|
+
elif p in ("setpoint_c", "setpoint"):
|
|
153
|
+
await fp.set_setpoint_c(_parse_int(value))
|
|
154
|
+
elif p == "eco":
|
|
155
|
+
await fp.set_eco_mode(_parse_bool(value))
|
|
156
|
+
elif p == "boost":
|
|
157
|
+
await fp.set_boost_mode(_parse_bool(value))
|
|
158
|
+
elif p == "ember_brightness":
|
|
159
|
+
await fp.set_ember_bed_brightness(_parse_int(value))
|
|
160
|
+
elif p == "ember_cycling":
|
|
161
|
+
await fp.set_ember_bed_cycling(_parse_bool(value))
|
|
162
|
+
elif p == "top_light_cycling":
|
|
163
|
+
await fp.set_top_light_cycling(_parse_bool(value))
|
|
164
|
+
elif p == "favourite":
|
|
165
|
+
await fp.apply_favourite(value.strip().lower())
|
|
166
|
+
else:
|
|
167
|
+
raise NapoleonValueError(
|
|
168
|
+
f"unknown set property {prop!r}. Supported: power, flame_speed, "
|
|
169
|
+
"orange_flame, yellow_flame, heater, setpoint_c, eco, boost, "
|
|
170
|
+
"ember_brightness, ember_cycling, top_light_cycling, favourite"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Subcommand runners
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
async def _cmd_login(_args: argparse.Namespace) -> int:
|
|
178
|
+
client = _build_client_from_env()
|
|
179
|
+
try:
|
|
180
|
+
await client.login()
|
|
181
|
+
print(f"OK {client._email}") # noqa: SLF001 (CLI diagnostic only)
|
|
182
|
+
return 0
|
|
183
|
+
finally:
|
|
184
|
+
await client.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _cmd_list(_args: argparse.Namespace) -> int:
|
|
188
|
+
client = _build_client_from_env()
|
|
189
|
+
try:
|
|
190
|
+
await client.login()
|
|
191
|
+
fireplaces = await client.fireplaces()
|
|
192
|
+
if not fireplaces:
|
|
193
|
+
print("(no Napoleon fireplaces found)")
|
|
194
|
+
return 0
|
|
195
|
+
for fp in fireplaces:
|
|
196
|
+
print(f"{fp.dsn}\t{fp.name}")
|
|
197
|
+
return 0
|
|
198
|
+
finally:
|
|
199
|
+
await client.close()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _cmd_state(args: argparse.Namespace) -> int:
|
|
203
|
+
dsn = args.dsn or _env("NAPOLEON_DSN")
|
|
204
|
+
client = _build_client_from_env()
|
|
205
|
+
try:
|
|
206
|
+
await client.login()
|
|
207
|
+
fireplaces = await client.fireplaces()
|
|
208
|
+
fp = _resolve_fireplace(fireplaces, dsn)
|
|
209
|
+
await fp.refresh()
|
|
210
|
+
print(_state_to_json(fp))
|
|
211
|
+
return 0
|
|
212
|
+
finally:
|
|
213
|
+
await client.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _cmd_set(args: argparse.Namespace) -> int:
|
|
217
|
+
dsn = args.dsn or _env("NAPOLEON_DSN")
|
|
218
|
+
client = _build_client_from_env()
|
|
219
|
+
try:
|
|
220
|
+
await client.login()
|
|
221
|
+
fireplaces = await client.fireplaces()
|
|
222
|
+
fp = _resolve_fireplace(fireplaces, dsn)
|
|
223
|
+
await _apply_set(fp, args.prop, args.value)
|
|
224
|
+
print(f"OK {fp.dsn} {args.prop}={args.value}")
|
|
225
|
+
return 0
|
|
226
|
+
finally:
|
|
227
|
+
await client.close()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# argparse plumbing
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
prog="pynapoleon",
|
|
236
|
+
description="Smoke / debug CLI for the Napoleon (Ayla) cloud API.",
|
|
237
|
+
)
|
|
238
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
239
|
+
|
|
240
|
+
sub.add_parser("login", help="verify credentials")
|
|
241
|
+
sub.add_parser("list", help="list discovered fireplaces")
|
|
242
|
+
|
|
243
|
+
p_state = sub.add_parser("state", help="dump current state as JSON")
|
|
244
|
+
p_state.add_argument("--dsn", help="device serial; auto-picked if only one")
|
|
245
|
+
|
|
246
|
+
p_set = sub.add_parser("set", help="write one property")
|
|
247
|
+
p_set.add_argument("--dsn", help="device serial; auto-picked if only one")
|
|
248
|
+
p_set.add_argument("prop", help=f"property name (favourites: {C.FAVOURITES})")
|
|
249
|
+
p_set.add_argument("value", help="value (on/off, int, or favourite slot)")
|
|
250
|
+
|
|
251
|
+
return parser
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_DISPATCH = {
|
|
255
|
+
"login": _cmd_login,
|
|
256
|
+
"list": _cmd_list,
|
|
257
|
+
"state": _cmd_state,
|
|
258
|
+
"set": _cmd_set,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: list[str] | None = None) -> int:
|
|
263
|
+
_maybe_load_dotenv()
|
|
264
|
+
parser = _build_parser()
|
|
265
|
+
args = parser.parse_args(argv)
|
|
266
|
+
runner = _DISPATCH[args.cmd]
|
|
267
|
+
try:
|
|
268
|
+
return asyncio.run(runner(args))
|
|
269
|
+
except NapoleonAuthError as exc:
|
|
270
|
+
print(f"auth error: {exc}", file=sys.stderr)
|
|
271
|
+
return 2
|
|
272
|
+
except NapoleonValueError as exc:
|
|
273
|
+
print(f"value error: {exc}", file=sys.stderr)
|
|
274
|
+
return 3
|
|
275
|
+
except NapoleonError as exc:
|
|
276
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
277
|
+
return 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
raise SystemExit(main())
|