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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())