aeraforhome 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.
- aeraforhome-0.1.0/PKG-INFO +132 -0
- aeraforhome-0.1.0/README.md +119 -0
- aeraforhome-0.1.0/aera/__init__.py +16 -0
- aeraforhome-0.1.0/aera/api.py +327 -0
- aeraforhome-0.1.0/aera/const.py +50 -0
- aeraforhome-0.1.0/aera/contentful.py +139 -0
- aeraforhome-0.1.0/aera/device.py +243 -0
- aeraforhome-0.1.0/aeraforhome.egg-info/PKG-INFO +132 -0
- aeraforhome-0.1.0/aeraforhome.egg-info/SOURCES.txt +12 -0
- aeraforhome-0.1.0/aeraforhome.egg-info/dependency_links.txt +1 -0
- aeraforhome-0.1.0/aeraforhome.egg-info/requires.txt +6 -0
- aeraforhome-0.1.0/aeraforhome.egg-info/top_level.txt +1 -0
- aeraforhome-0.1.0/pyproject.toml +24 -0
- aeraforhome-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aeraforhome
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python API for Aera for Home smart fragrance diffusers
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: aioresponses>=0.7; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# aeraforhome-api
|
|
15
|
+
|
|
16
|
+
Unofficial Python client for the Aera for Home smart fragrance diffuser cloud API, reverse-engineered from the Android app (v2.3.6). Covers:
|
|
17
|
+
|
|
18
|
+
- **Authentication**: email/password login via the Ayla Networks IoT platform, automatic token refresh on 401.
|
|
19
|
+
- **Device control**: power on/off, intensity adjustment, timed fragrance sessions, schedule management.
|
|
20
|
+
- **Device state**: fragrance name, remaining percentage, cartridge presence, error conditions.
|
|
21
|
+
- **Fragrance catalog**: Mini fragrance lookup with names, codes, and QR URLs via Contentful CMS.
|
|
22
|
+
|
|
23
|
+
This library is designed to power a Home Assistant integration, but has no dependency on Home Assistant and can be used standalone.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install aeraforhome
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install from source:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from aera import AeraApi
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
api = AeraApi("your-email@example.com", "your-password")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
await api.login()
|
|
48
|
+
|
|
49
|
+
devices = await api.get_devices()
|
|
50
|
+
for device in devices:
|
|
51
|
+
props = await api.get_device_properties(device)
|
|
52
|
+
print(f"{device.device_name}: {device.fragrance_name} ({device.fragrance_remaining}%)")
|
|
53
|
+
|
|
54
|
+
# Control a device
|
|
55
|
+
await api.set_power(devices[0], True)
|
|
56
|
+
await api.set_intensity(devices[0], 5)
|
|
57
|
+
|
|
58
|
+
# Start a 60-minute session
|
|
59
|
+
await api.start_session(devices[0], 60)
|
|
60
|
+
|
|
61
|
+
# Get all Mini-compatible fragrances (for QR scanning)
|
|
62
|
+
fragrances = await api.get_mini_fragrances()
|
|
63
|
+
for f in fragrances:
|
|
64
|
+
print(f"{f['name']} ({f['code']}): {f['qr_url']}")
|
|
65
|
+
|
|
66
|
+
finally:
|
|
67
|
+
await api.close()
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported Devices
|
|
73
|
+
|
|
74
|
+
| Model | Type | Max Intensity |
|
|
75
|
+
|-------|------|---------------|
|
|
76
|
+
| Aera 1 | `aera1` | 10 |
|
|
77
|
+
| Aera 2 | `aera2` | 10 |
|
|
78
|
+
| Aera 3 | `aera3` | 10 |
|
|
79
|
+
| Aera 3.1 | `aera31` | 10 |
|
|
80
|
+
| Aera Mini | `aeraMini` | 5 |
|
|
81
|
+
|
|
82
|
+
## API Methods
|
|
83
|
+
|
|
84
|
+
| Method | Description |
|
|
85
|
+
|--------|-------------|
|
|
86
|
+
| `login()` | Authenticate with email/password |
|
|
87
|
+
| `refresh_auth()` | Refresh the access token |
|
|
88
|
+
| `get_devices()` | Fetch all devices (includes room names) |
|
|
89
|
+
| `get_device_properties(device)` | Fetch current state for a device |
|
|
90
|
+
| `set_power(device, on)` | Turn device on/off |
|
|
91
|
+
| `set_intensity(device, level)` | Set fragrance intensity |
|
|
92
|
+
| `start_session(device, minutes)` | Start a timed session |
|
|
93
|
+
| `stop_session(device)` | Stop a running session |
|
|
94
|
+
| `get_schedules(device)` | Fetch device schedules |
|
|
95
|
+
| `update_schedule(device, key, data)` | Update a schedule |
|
|
96
|
+
| `get_mini_fragrances()` | Get all Mini fragrance names, codes, and QR URLs |
|
|
97
|
+
| `get_device_metadata()` | Fetch user-assigned room names and positions |
|
|
98
|
+
| `sign_out()` | Sign out from the service |
|
|
99
|
+
| `close()` | Close the HTTP session |
|
|
100
|
+
|
|
101
|
+
## Device Properties
|
|
102
|
+
|
|
103
|
+
| Property | Description |
|
|
104
|
+
|----------|-------------|
|
|
105
|
+
| `device_name` | User-assigned room name (falls back to product name) |
|
|
106
|
+
| `device_type` | `DeviceType` enum |
|
|
107
|
+
| `is_online` | Connection status |
|
|
108
|
+
| `is_power_on` | Power state |
|
|
109
|
+
| `intensity` | Current intensity level |
|
|
110
|
+
| `fragrance_name` | Resolved fragrance name (via Contentful for Mini) |
|
|
111
|
+
| `fragrance_color` | Hex color from fragrance catalog |
|
|
112
|
+
| `fragrance_remaining` | Percentage remaining (0-100) |
|
|
113
|
+
| `is_cartridge_present` | Whether a cartridge is inserted (full-size only) |
|
|
114
|
+
| `session_active` | Whether a timed session is running |
|
|
115
|
+
| `session_time_remaining` | Minutes left in session |
|
|
116
|
+
| `has_error` | Whether the device has an error condition |
|
|
117
|
+
|
|
118
|
+
## Scripts
|
|
119
|
+
|
|
120
|
+
- **`example.py`** - Minimal usage example showing device listing and control.
|
|
121
|
+
- **`test_local.py`** - Interactive test script that dumps all device properties including raw values, useful for debugging.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Disclaimer
|
|
131
|
+
|
|
132
|
+
This is an unofficial, reverse-engineered client with no affiliation to Aera, Prolitec, or Ayla Networks. It may break if the upstream API changes.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# aeraforhome-api
|
|
2
|
+
|
|
3
|
+
Unofficial Python client for the Aera for Home smart fragrance diffuser cloud API, reverse-engineered from the Android app (v2.3.6). Covers:
|
|
4
|
+
|
|
5
|
+
- **Authentication**: email/password login via the Ayla Networks IoT platform, automatic token refresh on 401.
|
|
6
|
+
- **Device control**: power on/off, intensity adjustment, timed fragrance sessions, schedule management.
|
|
7
|
+
- **Device state**: fragrance name, remaining percentage, cartridge presence, error conditions.
|
|
8
|
+
- **Fragrance catalog**: Mini fragrance lookup with names, codes, and QR URLs via Contentful CMS.
|
|
9
|
+
|
|
10
|
+
This library is designed to power a Home Assistant integration, but has no dependency on Home Assistant and can be used standalone.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install aeraforhome
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or install from source:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from aera import AeraApi
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
api = AeraApi("your-email@example.com", "your-password")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
await api.login()
|
|
35
|
+
|
|
36
|
+
devices = await api.get_devices()
|
|
37
|
+
for device in devices:
|
|
38
|
+
props = await api.get_device_properties(device)
|
|
39
|
+
print(f"{device.device_name}: {device.fragrance_name} ({device.fragrance_remaining}%)")
|
|
40
|
+
|
|
41
|
+
# Control a device
|
|
42
|
+
await api.set_power(devices[0], True)
|
|
43
|
+
await api.set_intensity(devices[0], 5)
|
|
44
|
+
|
|
45
|
+
# Start a 60-minute session
|
|
46
|
+
await api.start_session(devices[0], 60)
|
|
47
|
+
|
|
48
|
+
# Get all Mini-compatible fragrances (for QR scanning)
|
|
49
|
+
fragrances = await api.get_mini_fragrances()
|
|
50
|
+
for f in fragrances:
|
|
51
|
+
print(f"{f['name']} ({f['code']}): {f['qr_url']}")
|
|
52
|
+
|
|
53
|
+
finally:
|
|
54
|
+
await api.close()
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Supported Devices
|
|
60
|
+
|
|
61
|
+
| Model | Type | Max Intensity |
|
|
62
|
+
|-------|------|---------------|
|
|
63
|
+
| Aera 1 | `aera1` | 10 |
|
|
64
|
+
| Aera 2 | `aera2` | 10 |
|
|
65
|
+
| Aera 3 | `aera3` | 10 |
|
|
66
|
+
| Aera 3.1 | `aera31` | 10 |
|
|
67
|
+
| Aera Mini | `aeraMini` | 5 |
|
|
68
|
+
|
|
69
|
+
## API Methods
|
|
70
|
+
|
|
71
|
+
| Method | Description |
|
|
72
|
+
|--------|-------------|
|
|
73
|
+
| `login()` | Authenticate with email/password |
|
|
74
|
+
| `refresh_auth()` | Refresh the access token |
|
|
75
|
+
| `get_devices()` | Fetch all devices (includes room names) |
|
|
76
|
+
| `get_device_properties(device)` | Fetch current state for a device |
|
|
77
|
+
| `set_power(device, on)` | Turn device on/off |
|
|
78
|
+
| `set_intensity(device, level)` | Set fragrance intensity |
|
|
79
|
+
| `start_session(device, minutes)` | Start a timed session |
|
|
80
|
+
| `stop_session(device)` | Stop a running session |
|
|
81
|
+
| `get_schedules(device)` | Fetch device schedules |
|
|
82
|
+
| `update_schedule(device, key, data)` | Update a schedule |
|
|
83
|
+
| `get_mini_fragrances()` | Get all Mini fragrance names, codes, and QR URLs |
|
|
84
|
+
| `get_device_metadata()` | Fetch user-assigned room names and positions |
|
|
85
|
+
| `sign_out()` | Sign out from the service |
|
|
86
|
+
| `close()` | Close the HTTP session |
|
|
87
|
+
|
|
88
|
+
## Device Properties
|
|
89
|
+
|
|
90
|
+
| Property | Description |
|
|
91
|
+
|----------|-------------|
|
|
92
|
+
| `device_name` | User-assigned room name (falls back to product name) |
|
|
93
|
+
| `device_type` | `DeviceType` enum |
|
|
94
|
+
| `is_online` | Connection status |
|
|
95
|
+
| `is_power_on` | Power state |
|
|
96
|
+
| `intensity` | Current intensity level |
|
|
97
|
+
| `fragrance_name` | Resolved fragrance name (via Contentful for Mini) |
|
|
98
|
+
| `fragrance_color` | Hex color from fragrance catalog |
|
|
99
|
+
| `fragrance_remaining` | Percentage remaining (0-100) |
|
|
100
|
+
| `is_cartridge_present` | Whether a cartridge is inserted (full-size only) |
|
|
101
|
+
| `session_active` | Whether a timed session is running |
|
|
102
|
+
| `session_time_remaining` | Minutes left in session |
|
|
103
|
+
| `has_error` | Whether the device has an error condition |
|
|
104
|
+
|
|
105
|
+
## Scripts
|
|
106
|
+
|
|
107
|
+
- **`example.py`** - Minimal usage example showing device listing and control.
|
|
108
|
+
- **`test_local.py`** - Interactive test script that dumps all device properties including raw values, useful for debugging.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Disclaimer
|
|
118
|
+
|
|
119
|
+
This is an unofficial, reverse-engineered client with no affiliation to Aera, Prolitec, or Ayla Networks. It may break if the upstream API changes.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Aera for Home API - Python wrapper for the Aera smart fragrance diffuser."""
|
|
2
|
+
|
|
3
|
+
from aera.api import AeraApi
|
|
4
|
+
from aera.device import AeraDevice, DeviceType
|
|
5
|
+
from aera.const import (
|
|
6
|
+
AERA_APP_ID,
|
|
7
|
+
AERA_APP_SECRET,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AeraApi",
|
|
12
|
+
"AeraDevice",
|
|
13
|
+
"DeviceType",
|
|
14
|
+
"AERA_APP_ID",
|
|
15
|
+
"AERA_APP_SECRET",
|
|
16
|
+
]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Aera for Home API client using the Ayla IoT platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from aera.const import (
|
|
11
|
+
AERA_APP_ID,
|
|
12
|
+
AERA_APP_SECRET,
|
|
13
|
+
ALL_READABLE_PROPERTIES,
|
|
14
|
+
DEVICE_METADATA_KEY,
|
|
15
|
+
DEVICE_SERVICE_URL,
|
|
16
|
+
PROP_SET_INTENSITY_MANUAL,
|
|
17
|
+
PROP_SET_POWER_STATE,
|
|
18
|
+
PROP_SET_SESSION_LENGTH,
|
|
19
|
+
USER_SERVICE_URL,
|
|
20
|
+
)
|
|
21
|
+
from aera.contentful import ContentfulClient
|
|
22
|
+
from aera.device import AeraDevice
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AeraAuthError(Exception):
|
|
28
|
+
"""Raised when authentication fails."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AeraApiError(Exception):
|
|
32
|
+
"""Raised when an API call fails."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AeraApi:
|
|
36
|
+
"""Async client for the Aera for Home cloud API (Ayla Networks)."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
email: str,
|
|
41
|
+
password: str,
|
|
42
|
+
session: aiohttp.ClientSession | None = None,
|
|
43
|
+
app_id: str = AERA_APP_ID,
|
|
44
|
+
app_secret: str = AERA_APP_SECRET,
|
|
45
|
+
):
|
|
46
|
+
self._email = email
|
|
47
|
+
self._password = password
|
|
48
|
+
self._app_id = app_id
|
|
49
|
+
self._app_secret = app_secret
|
|
50
|
+
self._session = session
|
|
51
|
+
self._owns_session = session is None
|
|
52
|
+
self._access_token: str | None = None
|
|
53
|
+
self._refresh_token: str | None = None
|
|
54
|
+
self._devices: dict[str, AeraDevice] = {}
|
|
55
|
+
self._contentful: ContentfulClient | None = None
|
|
56
|
+
|
|
57
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
58
|
+
if self._session is None or self._session.closed:
|
|
59
|
+
self._session = aiohttp.ClientSession()
|
|
60
|
+
self._owns_session = True
|
|
61
|
+
return self._session
|
|
62
|
+
|
|
63
|
+
def _auth_header(self) -> dict[str, str]:
|
|
64
|
+
if not self._access_token:
|
|
65
|
+
raise AeraAuthError("Not authenticated. Call login() first.")
|
|
66
|
+
return {"Authorization": f"auth_token {self._access_token}"}
|
|
67
|
+
|
|
68
|
+
async def login(self) -> bool:
|
|
69
|
+
"""Authenticate with the Ayla user service."""
|
|
70
|
+
session = await self._get_session()
|
|
71
|
+
payload = {
|
|
72
|
+
"user": {
|
|
73
|
+
"email": self._email,
|
|
74
|
+
"password": self._password,
|
|
75
|
+
"application": {
|
|
76
|
+
"app_id": self._app_id,
|
|
77
|
+
"app_secret": self._app_secret,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async with session.post(
|
|
82
|
+
f"{USER_SERVICE_URL}/users/sign_in.json",
|
|
83
|
+
json=payload,
|
|
84
|
+
) as resp:
|
|
85
|
+
if resp.status == 401:
|
|
86
|
+
raise AeraAuthError("Invalid email or password.")
|
|
87
|
+
if resp.status != 200:
|
|
88
|
+
text = await resp.text()
|
|
89
|
+
raise AeraAuthError(f"Login failed ({resp.status}): {text}")
|
|
90
|
+
data = await resp.json()
|
|
91
|
+
self._access_token = data.get("access_token")
|
|
92
|
+
self._refresh_token = data.get("refresh_token")
|
|
93
|
+
_LOGGER.debug("Login successful")
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
async def refresh_auth(self) -> bool:
|
|
97
|
+
"""Refresh the access token."""
|
|
98
|
+
if not self._refresh_token:
|
|
99
|
+
raise AeraAuthError("No refresh token available. Call login() first.")
|
|
100
|
+
session = await self._get_session()
|
|
101
|
+
payload = {"user": {"refresh_token": self._refresh_token}}
|
|
102
|
+
async with session.post(
|
|
103
|
+
f"{USER_SERVICE_URL}/users/refresh_token.json",
|
|
104
|
+
json=payload,
|
|
105
|
+
) as resp:
|
|
106
|
+
if resp.status != 200:
|
|
107
|
+
self._access_token = None
|
|
108
|
+
self._refresh_token = None
|
|
109
|
+
raise AeraAuthError("Token refresh failed. Re-authentication needed.")
|
|
110
|
+
data = await resp.json()
|
|
111
|
+
self._access_token = data.get("access_token")
|
|
112
|
+
self._refresh_token = data.get("refresh_token")
|
|
113
|
+
_LOGGER.debug("Token refresh successful")
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
async def _request(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
url: str,
|
|
120
|
+
json: dict | None = None,
|
|
121
|
+
retry_on_401: bool = True,
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Make an authenticated API request with auto-refresh on 401."""
|
|
124
|
+
session = await self._get_session()
|
|
125
|
+
headers = self._auth_header()
|
|
126
|
+
async with session.request(method, url, headers=headers, json=json) as resp:
|
|
127
|
+
if resp.status == 401 and retry_on_401:
|
|
128
|
+
_LOGGER.debug("Got 401, attempting token refresh")
|
|
129
|
+
await self.refresh_auth()
|
|
130
|
+
return await self._request(method, url, json=json, retry_on_401=False)
|
|
131
|
+
if resp.status not in (200, 201):
|
|
132
|
+
text = await resp.text()
|
|
133
|
+
raise AeraApiError(f"API error ({resp.status}): {text}")
|
|
134
|
+
return await resp.json()
|
|
135
|
+
|
|
136
|
+
async def get_devices(self) -> list[AeraDevice]:
|
|
137
|
+
"""Fetch all devices associated with the account."""
|
|
138
|
+
data = await self._request("GET", f"{DEVICE_SERVICE_URL}/apiv1/devices.json")
|
|
139
|
+
devices = []
|
|
140
|
+
for item in data:
|
|
141
|
+
device_data = item.get("device", item)
|
|
142
|
+
dsn = device_data.get("dsn", "")
|
|
143
|
+
if dsn in self._devices:
|
|
144
|
+
self._devices[dsn]._data = device_data
|
|
145
|
+
else:
|
|
146
|
+
self._devices[dsn] = AeraDevice(device_data)
|
|
147
|
+
devices.append(self._devices[dsn])
|
|
148
|
+
try:
|
|
149
|
+
await self.get_device_metadata()
|
|
150
|
+
except Exception as ex:
|
|
151
|
+
_LOGGER.debug("Failed to fetch device metadata: %s", ex)
|
|
152
|
+
return devices
|
|
153
|
+
|
|
154
|
+
async def get_device_metadata(self) -> dict[str, dict[str, Any]]:
|
|
155
|
+
"""Fetch user-assigned device metadata (room names, positions).
|
|
156
|
+
|
|
157
|
+
Returns a dict of DSN -> metadata dict. Also applies room names
|
|
158
|
+
to any already-fetched devices.
|
|
159
|
+
"""
|
|
160
|
+
import json as json_lib
|
|
161
|
+
|
|
162
|
+
data = await self._request(
|
|
163
|
+
"GET",
|
|
164
|
+
f"{USER_SERVICE_URL}/api/v1/users/data/{DEVICE_METADATA_KEY}.json",
|
|
165
|
+
)
|
|
166
|
+
datum = data.get("datum", data)
|
|
167
|
+
value_str = datum.get("value", "")
|
|
168
|
+
metadata: dict[str, dict[str, Any]] = {}
|
|
169
|
+
try:
|
|
170
|
+
entries = json_lib.loads(value_str) if value_str else []
|
|
171
|
+
except (json_lib.JSONDecodeError, TypeError):
|
|
172
|
+
entries = []
|
|
173
|
+
for entry in entries:
|
|
174
|
+
dsn = entry.get("dsn", "")
|
|
175
|
+
if dsn:
|
|
176
|
+
metadata[dsn] = entry
|
|
177
|
+
if dsn in self._devices:
|
|
178
|
+
self._devices[dsn].room_name = entry.get("room_name", "")
|
|
179
|
+
return metadata
|
|
180
|
+
|
|
181
|
+
async def get_device_properties(self, device: AeraDevice | str) -> dict[str, Any]:
|
|
182
|
+
"""Fetch all properties for a device. Returns a dict of name -> value."""
|
|
183
|
+
dsn = device.dsn if isinstance(device, AeraDevice) else device
|
|
184
|
+
data = await self._request(
|
|
185
|
+
"GET", f"{DEVICE_SERVICE_URL}/apiv1/dsns/{dsn}/properties.json"
|
|
186
|
+
)
|
|
187
|
+
properties: dict[str, Any] = {}
|
|
188
|
+
for item in data:
|
|
189
|
+
prop = item.get("property", item)
|
|
190
|
+
name = prop.get("name")
|
|
191
|
+
value = prop.get("value")
|
|
192
|
+
if name:
|
|
193
|
+
properties[name] = value
|
|
194
|
+
if dsn in self._devices:
|
|
195
|
+
dev = self._devices[dsn]
|
|
196
|
+
dev.update_properties(properties)
|
|
197
|
+
await self._resolve_fragrance(dev)
|
|
198
|
+
return properties
|
|
199
|
+
|
|
200
|
+
async def _ensure_contentful(self) -> ContentfulClient:
|
|
201
|
+
"""Lazily load the Contentful fragrance data."""
|
|
202
|
+
if self._contentful is None:
|
|
203
|
+
session = await self._get_session()
|
|
204
|
+
self._contentful = ContentfulClient(session)
|
|
205
|
+
if not self._contentful.is_loaded:
|
|
206
|
+
await self._contentful.load_fragrances()
|
|
207
|
+
return self._contentful
|
|
208
|
+
|
|
209
|
+
async def _resolve_fragrance(self, device: AeraDevice) -> None:
|
|
210
|
+
"""Resolve fragrance metadata from Contentful for a device."""
|
|
211
|
+
identifier = device.fragrance_identifier
|
|
212
|
+
if not identifier:
|
|
213
|
+
return
|
|
214
|
+
contentful = await self._ensure_contentful()
|
|
215
|
+
info = contentful.resolve_fragrance(identifier, device.device_type.is_mini)
|
|
216
|
+
device.fragrance_info = info
|
|
217
|
+
|
|
218
|
+
async def set_property(
|
|
219
|
+
self, device: AeraDevice | str, property_name: str, value: Any
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""Set a property value on a device (creates a datapoint)."""
|
|
222
|
+
dsn = device.dsn if isinstance(device, AeraDevice) else device
|
|
223
|
+
payload = {"datapoint": {"value": value}}
|
|
224
|
+
await self._request(
|
|
225
|
+
"POST",
|
|
226
|
+
f"{DEVICE_SERVICE_URL}/apiv1/dsns/{dsn}/properties/{property_name}/datapoints.json",
|
|
227
|
+
json=payload,
|
|
228
|
+
)
|
|
229
|
+
if dsn in self._devices:
|
|
230
|
+
self._devices[dsn].update_properties({property_name: value})
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
async def set_power(self, device: AeraDevice | str, on: bool) -> bool:
|
|
234
|
+
"""Turn device on or off."""
|
|
235
|
+
return await self.set_property(device, PROP_SET_POWER_STATE, 1 if on else 0)
|
|
236
|
+
|
|
237
|
+
async def set_intensity(self, device: AeraDevice | str, level: int) -> bool:
|
|
238
|
+
"""Set fragrance intensity level (1-10 for full-size, 1-5 for Mini)."""
|
|
239
|
+
dsn = device.dsn if isinstance(device, AeraDevice) else device
|
|
240
|
+
dev = self._devices.get(dsn)
|
|
241
|
+
if dev:
|
|
242
|
+
max_level = dev.max_intensity
|
|
243
|
+
level = max(1, min(level, max_level))
|
|
244
|
+
return await self.set_property(device, PROP_SET_INTENSITY_MANUAL, level)
|
|
245
|
+
|
|
246
|
+
async def start_session(
|
|
247
|
+
self, device: AeraDevice | str, duration_minutes: int
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""Start a timed fragrance session."""
|
|
250
|
+
return await self.set_property(device, PROP_SET_SESSION_LENGTH, duration_minutes)
|
|
251
|
+
|
|
252
|
+
async def stop_session(self, device: AeraDevice | str) -> bool:
|
|
253
|
+
"""Stop a running fragrance session (set session length to 0)."""
|
|
254
|
+
return await self.set_property(device, PROP_SET_SESSION_LENGTH, 0)
|
|
255
|
+
|
|
256
|
+
async def get_mini_fragrances(self) -> list[dict[str, str | None]]:
|
|
257
|
+
"""Get all Mini-compatible fragrances with name, code, and QR URL."""
|
|
258
|
+
contentful = await self._ensure_contentful()
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
"name": f.fragrance_name,
|
|
262
|
+
"code": f.fragrance_id,
|
|
263
|
+
"qr_url": f.fragrance_qr,
|
|
264
|
+
}
|
|
265
|
+
for f in contentful.get_mini_fragrances()
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
async def get_schedules(self, device: AeraDevice | str) -> list[dict[str, Any]]:
|
|
269
|
+
"""Fetch all schedules for a device."""
|
|
270
|
+
dsn = device.dsn if isinstance(device, AeraDevice) else device
|
|
271
|
+
data = await self._request(
|
|
272
|
+
"GET", f"{DEVICE_SERVICE_URL}/apiv1/dsns/{dsn}/schedules.json"
|
|
273
|
+
)
|
|
274
|
+
schedules = []
|
|
275
|
+
for item in data:
|
|
276
|
+
schedule = item.get("schedule", item)
|
|
277
|
+
schedules.append(schedule)
|
|
278
|
+
return schedules
|
|
279
|
+
|
|
280
|
+
async def update_schedule(
|
|
281
|
+
self, device: AeraDevice | str, schedule_key: str, schedule_data: dict[str, Any]
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Update a schedule on a device."""
|
|
284
|
+
dsn = device.dsn if isinstance(device, AeraDevice) else device
|
|
285
|
+
payload = {"schedule": schedule_data}
|
|
286
|
+
data = await self._request(
|
|
287
|
+
"PUT",
|
|
288
|
+
f"{DEVICE_SERVICE_URL}/apiv1/dsns/{dsn}/schedules/{schedule_key}.json",
|
|
289
|
+
json=payload,
|
|
290
|
+
)
|
|
291
|
+
return data.get("schedule", data)
|
|
292
|
+
|
|
293
|
+
async def sign_out(self) -> bool:
|
|
294
|
+
"""Sign out from the Ayla service."""
|
|
295
|
+
if not self._access_token:
|
|
296
|
+
return True
|
|
297
|
+
session = await self._get_session()
|
|
298
|
+
headers = self._auth_header()
|
|
299
|
+
payload = {"user": {"access_token": self._access_token}}
|
|
300
|
+
try:
|
|
301
|
+
async with session.post(
|
|
302
|
+
f"{USER_SERVICE_URL}/users/sign_out.json",
|
|
303
|
+
headers=headers,
|
|
304
|
+
json=payload,
|
|
305
|
+
) as resp:
|
|
306
|
+
pass
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
self._access_token = None
|
|
310
|
+
self._refresh_token = None
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
async def close(self) -> None:
|
|
314
|
+
"""Close the session."""
|
|
315
|
+
if self._contentful:
|
|
316
|
+
await self._contentful.close()
|
|
317
|
+
self._contentful = None
|
|
318
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
319
|
+
await self._session.close()
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def is_authenticated(self) -> bool:
|
|
323
|
+
return self._access_token is not None
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def devices(self) -> dict[str, AeraDevice]:
|
|
327
|
+
return self._devices
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Constants extracted from the Aera for Home Android app."""
|
|
2
|
+
|
|
3
|
+
AERA_APP_ID = "android-id-id"
|
|
4
|
+
AERA_APP_SECRET = "android-id-oYOAkxPCU46_E04WxtwfOYatrUI"
|
|
5
|
+
|
|
6
|
+
USER_SERVICE_URL = "https://user-field.aylanetworks.com"
|
|
7
|
+
DEVICE_SERVICE_URL = "https://ads-field.aylanetworks.com"
|
|
8
|
+
|
|
9
|
+
# Properties written TO device (inputs)
|
|
10
|
+
PROP_SET_POWER_STATE = "set_power_state"
|
|
11
|
+
PROP_SET_INTENSITY_MANUAL = "set_intensity_manual"
|
|
12
|
+
PROP_SET_INTENSITY_SCHEDULE = "set_intensity_sched"
|
|
13
|
+
PROP_SET_SESSION_LENGTH = "set_session_length"
|
|
14
|
+
PROP_SET_FRAGRANCE_IDENTIFIER = "set_fragrance_identifier"
|
|
15
|
+
PROP_EJECT_PRESSED = "eject_press_on_app"
|
|
16
|
+
PROP_DEVICE_FW_VERSION = "device_fw_version"
|
|
17
|
+
PROP_QR_SCANNED_TIME = "pump_life_time_qr_scanned"
|
|
18
|
+
|
|
19
|
+
# Properties read FROM device (outputs)
|
|
20
|
+
PROP_POWER_STATE = "power_state"
|
|
21
|
+
PROP_INTENSITY_STATE = "intensity_state"
|
|
22
|
+
PROP_CARTRIDGE_USAGE = "cartridge_usage"
|
|
23
|
+
PROP_CARTRIDGE_PRESENT = "cartridge_present"
|
|
24
|
+
PROP_FRAGRANCE_NAME = "fragrance_name"
|
|
25
|
+
PROP_ERROR_CONDITION = "error_condition"
|
|
26
|
+
PROP_INTERRUPT_SCHEDULE = "interrupt_schedule"
|
|
27
|
+
PROP_SESSION_STATE = "session_state"
|
|
28
|
+
PROP_SESSION_TIME_LEFT = "session_time_left"
|
|
29
|
+
PROP_PUMP_LIFE_TIME = "pump_life_time"
|
|
30
|
+
|
|
31
|
+
DEVICE_METADATA_KEY = "device_data_table"
|
|
32
|
+
|
|
33
|
+
ALL_READABLE_PROPERTIES = [
|
|
34
|
+
PROP_POWER_STATE,
|
|
35
|
+
PROP_INTENSITY_STATE,
|
|
36
|
+
PROP_CARTRIDGE_USAGE,
|
|
37
|
+
PROP_CARTRIDGE_PRESENT,
|
|
38
|
+
PROP_FRAGRANCE_NAME,
|
|
39
|
+
PROP_ERROR_CONDITION,
|
|
40
|
+
PROP_INTERRUPT_SCHEDULE,
|
|
41
|
+
PROP_SESSION_STATE,
|
|
42
|
+
PROP_SESSION_TIME_LEFT,
|
|
43
|
+
PROP_PUMP_LIFE_TIME,
|
|
44
|
+
PROP_SET_INTENSITY_MANUAL,
|
|
45
|
+
PROP_SET_POWER_STATE,
|
|
46
|
+
PROP_SET_SESSION_LENGTH,
|
|
47
|
+
PROP_SET_FRAGRANCE_IDENTIFIER,
|
|
48
|
+
PROP_DEVICE_FW_VERSION,
|
|
49
|
+
PROP_QR_SCANNED_TIME,
|
|
50
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Contentful CMS client for fetching Aera fragrance metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
CONTENTFUL_SPACE = "bsswjwaepi0w"
|
|
14
|
+
CONTENTFUL_TOKEN = "UC4IVgBwitvaugwTZQLSvO28UcUdUumEvpOy4MejPUg"
|
|
15
|
+
CONTENTFUL_BASE_URL = f"https://cdn.contentful.com/spaces/{CONTENTFUL_SPACE}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FragranceInfo:
|
|
20
|
+
"""Fragrance metadata from Contentful."""
|
|
21
|
+
|
|
22
|
+
fragrance_id: str | None
|
|
23
|
+
fragrance_qr: str | None
|
|
24
|
+
fragrance_name: str | None
|
|
25
|
+
firmware_name: str | None
|
|
26
|
+
color: str | None
|
|
27
|
+
mini_fill: float | None
|
|
28
|
+
mini_output: float | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContentfulClient:
|
|
32
|
+
"""Fetches fragrance data from the Aera Contentful CMS."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, session: aiohttp.ClientSession | None = None):
|
|
35
|
+
self._session = session
|
|
36
|
+
self._owns_session = session is None
|
|
37
|
+
self._fragrances: list[FragranceInfo] = []
|
|
38
|
+
self._loaded = False
|
|
39
|
+
|
|
40
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
41
|
+
if self._session is None or self._session.closed:
|
|
42
|
+
self._session = aiohttp.ClientSession()
|
|
43
|
+
self._owns_session = True
|
|
44
|
+
return self._session
|
|
45
|
+
|
|
46
|
+
async def load_fragrances(self) -> list[FragranceInfo]:
|
|
47
|
+
"""Fetch all fragrances from Contentful."""
|
|
48
|
+
session = await self._get_session()
|
|
49
|
+
headers = {"Authorization": f"Bearer {CONTENTFUL_TOKEN}"}
|
|
50
|
+
fragrances: list[FragranceInfo] = []
|
|
51
|
+
skip = 0
|
|
52
|
+
limit = 100
|
|
53
|
+
|
|
54
|
+
while True:
|
|
55
|
+
url = (
|
|
56
|
+
f"{CONTENTFUL_BASE_URL}/entries"
|
|
57
|
+
f"?content_type=fragrance&limit={limit}&skip={skip}"
|
|
58
|
+
)
|
|
59
|
+
async with session.get(url, headers=headers) as resp:
|
|
60
|
+
if resp.status != 200:
|
|
61
|
+
_LOGGER.error("Contentful API error: %s", resp.status)
|
|
62
|
+
break
|
|
63
|
+
data = await resp.json()
|
|
64
|
+
|
|
65
|
+
items = data.get("items", [])
|
|
66
|
+
for item in items:
|
|
67
|
+
fields = item.get("fields", {})
|
|
68
|
+
fragrances.append(
|
|
69
|
+
FragranceInfo(
|
|
70
|
+
fragrance_id=fields.get("fragranceId"),
|
|
71
|
+
fragrance_qr=fields.get("fragranceQr"),
|
|
72
|
+
fragrance_name=fields.get("fragranceName"),
|
|
73
|
+
firmware_name=fields.get("firmwareName"),
|
|
74
|
+
color=fields.get("color"),
|
|
75
|
+
mini_fill=fields.get("miniFill"),
|
|
76
|
+
mini_output=fields.get("miniOutput"),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
total = data.get("total", 0)
|
|
81
|
+
skip += limit
|
|
82
|
+
if skip >= total:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
self._fragrances = fragrances
|
|
86
|
+
self._loaded = True
|
|
87
|
+
_LOGGER.debug("Loaded %d fragrances from Contentful", len(fragrances))
|
|
88
|
+
return fragrances
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_loaded(self) -> bool:
|
|
92
|
+
return self._loaded
|
|
93
|
+
|
|
94
|
+
def get_fragrance_by_id(self, identifier: str) -> FragranceInfo | None:
|
|
95
|
+
"""Look up fragrance by fragranceId (used by Mini devices)."""
|
|
96
|
+
identifier_lower = identifier.lower()
|
|
97
|
+
for f in self._fragrances:
|
|
98
|
+
if f.fragrance_id and f.fragrance_id.lower() == identifier_lower:
|
|
99
|
+
return f
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def get_fragrance_by_name(self, name: str) -> FragranceInfo | None:
|
|
103
|
+
"""Look up fragrance by fragranceName, then firmwareName (used by full-size)."""
|
|
104
|
+
name_lower = name.lower()
|
|
105
|
+
for f in self._fragrances:
|
|
106
|
+
if f.fragrance_name and f.fragrance_name.lower() == name_lower:
|
|
107
|
+
return f
|
|
108
|
+
for f in self._fragrances:
|
|
109
|
+
if f.firmware_name and f.firmware_name.lower() == name_lower:
|
|
110
|
+
return f
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def get_fragrance_by_qr(self, qr_id: str) -> FragranceInfo | None:
|
|
114
|
+
"""Look up fragrance by fragranceQr code."""
|
|
115
|
+
qr_lower = qr_id.lower()
|
|
116
|
+
for f in self._fragrances:
|
|
117
|
+
if f.fragrance_qr and f.fragrance_qr.lower() == qr_lower:
|
|
118
|
+
return f
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def get_mini_fragrances(self) -> list[FragranceInfo]:
|
|
122
|
+
"""Return all fragrances compatible with Aera Mini (those with a fragrance_id)."""
|
|
123
|
+
return [f for f in self._fragrances if f.fragrance_id]
|
|
124
|
+
|
|
125
|
+
def resolve_fragrance(self, identifier: str, is_mini: bool) -> FragranceInfo | None:
|
|
126
|
+
"""Resolve a fragrance identifier using the app's matching logic.
|
|
127
|
+
|
|
128
|
+
For Mini: try fragranceId first, then fragranceName/firmwareName.
|
|
129
|
+
For full-size: try fragranceName/firmwareName only.
|
|
130
|
+
"""
|
|
131
|
+
if is_mini:
|
|
132
|
+
result = self.get_fragrance_by_id(identifier)
|
|
133
|
+
if result:
|
|
134
|
+
return result
|
|
135
|
+
return self.get_fragrance_by_name(identifier)
|
|
136
|
+
|
|
137
|
+
async def close(self) -> None:
|
|
138
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
139
|
+
await self._session.close()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Aera device model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from aera.contentful import FragranceInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeviceType(Enum):
|
|
13
|
+
"""Aera device model types."""
|
|
14
|
+
|
|
15
|
+
AERA1 = "aera1"
|
|
16
|
+
AERA2 = "aera2"
|
|
17
|
+
AERA3 = "aera3"
|
|
18
|
+
AERA3_1 = "aera31"
|
|
19
|
+
AERA_MINI = "aeraMini"
|
|
20
|
+
UNKNOWN = "unknown"
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_oem_model(cls, oem_model: str | None) -> DeviceType:
|
|
24
|
+
if not oem_model:
|
|
25
|
+
return cls.UNKNOWN
|
|
26
|
+
for member in cls:
|
|
27
|
+
if member.value == oem_model:
|
|
28
|
+
return member
|
|
29
|
+
return cls.UNKNOWN
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_full_size(self) -> bool:
|
|
33
|
+
return self in (
|
|
34
|
+
DeviceType.AERA1,
|
|
35
|
+
DeviceType.AERA2,
|
|
36
|
+
DeviceType.AERA3,
|
|
37
|
+
DeviceType.AERA3_1,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_mini(self) -> bool:
|
|
42
|
+
return self == DeviceType.AERA_MINI
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def max_intensity(self) -> int:
|
|
46
|
+
return 10 if self.is_full_size else 5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AeraDevice:
|
|
50
|
+
"""Represents a single Aera fragrance diffuser."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, device_data: dict[str, Any], properties: dict[str, Any] | None = None):
|
|
53
|
+
self._data = device_data
|
|
54
|
+
self._properties: dict[str, Any] = properties or {}
|
|
55
|
+
self._fragrance_info: FragranceInfo | None = None
|
|
56
|
+
self._room_name: str | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def dsn(self) -> str:
|
|
60
|
+
return self._data.get("dsn", "")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def product_name(self) -> str:
|
|
64
|
+
return self._data.get("product_name", "")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def device_name(self) -> str:
|
|
68
|
+
if self._room_name:
|
|
69
|
+
return self._room_name
|
|
70
|
+
return self._data.get("device_name", self.product_name)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def room_name(self) -> str | None:
|
|
74
|
+
return self._room_name
|
|
75
|
+
|
|
76
|
+
@room_name.setter
|
|
77
|
+
def room_name(self, value: str | None) -> None:
|
|
78
|
+
self._room_name = value or None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def oem_model(self) -> str:
|
|
82
|
+
return self._data.get("oem_model", "")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def device_type(self) -> DeviceType:
|
|
86
|
+
return DeviceType.from_oem_model(self.oem_model)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def model(self) -> str:
|
|
90
|
+
return self._data.get("model", "")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def sw_version(self) -> str | None:
|
|
94
|
+
return self._data.get("sw_version")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def mac(self) -> str | None:
|
|
98
|
+
return self._data.get("mac")
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def lan_ip(self) -> str | None:
|
|
102
|
+
return self._data.get("lan_ip")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def connected_at(self) -> str | None:
|
|
106
|
+
return self._data.get("connected_at")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_online(self) -> bool:
|
|
110
|
+
status = self._data.get("connection_status")
|
|
111
|
+
return status == "Online"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_power_on(self) -> bool | None:
|
|
115
|
+
val = self._properties.get("power_state")
|
|
116
|
+
if val is None:
|
|
117
|
+
return None
|
|
118
|
+
return int(val) == 1
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def intensity(self) -> int | None:
|
|
122
|
+
val = self._properties.get("intensity_state")
|
|
123
|
+
if val is None:
|
|
124
|
+
return None
|
|
125
|
+
return int(val)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def max_intensity(self) -> int:
|
|
129
|
+
return self.device_type.max_intensity
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def cartridge_usage(self) -> int | None:
|
|
133
|
+
val = self._properties.get("cartridge_usage")
|
|
134
|
+
if val is None:
|
|
135
|
+
return None
|
|
136
|
+
return int(val)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def fragrance_remaining(self) -> int | None:
|
|
140
|
+
if self.device_type.is_mini:
|
|
141
|
+
return self._mini_fragrance_remaining()
|
|
142
|
+
usage = self.cartridge_usage
|
|
143
|
+
if usage is None:
|
|
144
|
+
return None
|
|
145
|
+
return max(0, 100 - usage)
|
|
146
|
+
|
|
147
|
+
def _mini_fragrance_remaining(self) -> int | None:
|
|
148
|
+
pump_life = self._properties.get("pump_life_time")
|
|
149
|
+
pump_qr = self._properties.get("pump_life_time_qr_scanned")
|
|
150
|
+
if pump_life is None or pump_qr is None:
|
|
151
|
+
return None
|
|
152
|
+
if self._fragrance_info is None:
|
|
153
|
+
return None
|
|
154
|
+
mini_fill = self._fragrance_info.mini_fill
|
|
155
|
+
mini_output = self._fragrance_info.mini_output
|
|
156
|
+
if not mini_fill or not mini_output:
|
|
157
|
+
return None
|
|
158
|
+
usage = round(
|
|
159
|
+
((int(pump_life) - int(pump_qr)) * mini_output / (mini_fill * 3600)) * 100
|
|
160
|
+
)
|
|
161
|
+
return max(0, min(100, 100 - usage))
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def is_cartridge_present(self) -> bool | None:
|
|
165
|
+
val = self._properties.get("cartridge_present")
|
|
166
|
+
if val is None:
|
|
167
|
+
return None
|
|
168
|
+
return int(val) == 1
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def fragrance_name(self) -> str | None:
|
|
172
|
+
if self._fragrance_info and self._fragrance_info.fragrance_name:
|
|
173
|
+
return self._fragrance_info.fragrance_name
|
|
174
|
+
return self._properties.get("fragrance_name") or None
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def fragrance_color(self) -> str | None:
|
|
178
|
+
if self._fragrance_info:
|
|
179
|
+
return self._fragrance_info.color
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def fragrance_identifier(self) -> str | None:
|
|
184
|
+
"""Raw fragrance identifier from device (short code for Mini, full name for full-size)."""
|
|
185
|
+
return (
|
|
186
|
+
self._properties.get("set_fragrance_identifier")
|
|
187
|
+
or self._properties.get("fragrance_name")
|
|
188
|
+
or None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def fragrance_info(self) -> FragranceInfo | None:
|
|
193
|
+
return self._fragrance_info
|
|
194
|
+
|
|
195
|
+
@fragrance_info.setter
|
|
196
|
+
def fragrance_info(self, value: FragranceInfo | None) -> None:
|
|
197
|
+
self._fragrance_info = value
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def error_condition(self) -> int | None:
|
|
201
|
+
val = self._properties.get("error_condition")
|
|
202
|
+
if val is None:
|
|
203
|
+
return None
|
|
204
|
+
return int(val)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def has_error(self) -> bool:
|
|
208
|
+
return self.error_condition is not None and self.error_condition != 0
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def session_active(self) -> bool | None:
|
|
212
|
+
val = self._properties.get("session_state")
|
|
213
|
+
if val is None:
|
|
214
|
+
return None
|
|
215
|
+
return int(val) == 1
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def session_time_remaining(self) -> int | None:
|
|
219
|
+
val = self._properties.get("session_time_left")
|
|
220
|
+
if val is None:
|
|
221
|
+
return None
|
|
222
|
+
return int(val)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def firmware_version(self) -> str | None:
|
|
226
|
+
return self._properties.get("device_fw_version")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def has_session_feature(self) -> bool:
|
|
230
|
+
return self.device_type in (
|
|
231
|
+
DeviceType.AERA3,
|
|
232
|
+
DeviceType.AERA3_1,
|
|
233
|
+
DeviceType.AERA_MINI,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def update_properties(self, properties: dict[str, Any]) -> None:
|
|
237
|
+
self._properties.update(properties)
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
return (
|
|
241
|
+
f"AeraDevice(dsn={self.dsn!r}, name={self.device_name!r}, "
|
|
242
|
+
f"type={self.device_type.name}, online={self.is_online})"
|
|
243
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aeraforhome
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python API for Aera for Home smart fragrance diffusers
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: aioresponses>=0.7; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# aeraforhome-api
|
|
15
|
+
|
|
16
|
+
Unofficial Python client for the Aera for Home smart fragrance diffuser cloud API, reverse-engineered from the Android app (v2.3.6). Covers:
|
|
17
|
+
|
|
18
|
+
- **Authentication**: email/password login via the Ayla Networks IoT platform, automatic token refresh on 401.
|
|
19
|
+
- **Device control**: power on/off, intensity adjustment, timed fragrance sessions, schedule management.
|
|
20
|
+
- **Device state**: fragrance name, remaining percentage, cartridge presence, error conditions.
|
|
21
|
+
- **Fragrance catalog**: Mini fragrance lookup with names, codes, and QR URLs via Contentful CMS.
|
|
22
|
+
|
|
23
|
+
This library is designed to power a Home Assistant integration, but has no dependency on Home Assistant and can be used standalone.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install aeraforhome
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install from source:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from aera import AeraApi
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
api = AeraApi("your-email@example.com", "your-password")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
await api.login()
|
|
48
|
+
|
|
49
|
+
devices = await api.get_devices()
|
|
50
|
+
for device in devices:
|
|
51
|
+
props = await api.get_device_properties(device)
|
|
52
|
+
print(f"{device.device_name}: {device.fragrance_name} ({device.fragrance_remaining}%)")
|
|
53
|
+
|
|
54
|
+
# Control a device
|
|
55
|
+
await api.set_power(devices[0], True)
|
|
56
|
+
await api.set_intensity(devices[0], 5)
|
|
57
|
+
|
|
58
|
+
# Start a 60-minute session
|
|
59
|
+
await api.start_session(devices[0], 60)
|
|
60
|
+
|
|
61
|
+
# Get all Mini-compatible fragrances (for QR scanning)
|
|
62
|
+
fragrances = await api.get_mini_fragrances()
|
|
63
|
+
for f in fragrances:
|
|
64
|
+
print(f"{f['name']} ({f['code']}): {f['qr_url']}")
|
|
65
|
+
|
|
66
|
+
finally:
|
|
67
|
+
await api.close()
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported Devices
|
|
73
|
+
|
|
74
|
+
| Model | Type | Max Intensity |
|
|
75
|
+
|-------|------|---------------|
|
|
76
|
+
| Aera 1 | `aera1` | 10 |
|
|
77
|
+
| Aera 2 | `aera2` | 10 |
|
|
78
|
+
| Aera 3 | `aera3` | 10 |
|
|
79
|
+
| Aera 3.1 | `aera31` | 10 |
|
|
80
|
+
| Aera Mini | `aeraMini` | 5 |
|
|
81
|
+
|
|
82
|
+
## API Methods
|
|
83
|
+
|
|
84
|
+
| Method | Description |
|
|
85
|
+
|--------|-------------|
|
|
86
|
+
| `login()` | Authenticate with email/password |
|
|
87
|
+
| `refresh_auth()` | Refresh the access token |
|
|
88
|
+
| `get_devices()` | Fetch all devices (includes room names) |
|
|
89
|
+
| `get_device_properties(device)` | Fetch current state for a device |
|
|
90
|
+
| `set_power(device, on)` | Turn device on/off |
|
|
91
|
+
| `set_intensity(device, level)` | Set fragrance intensity |
|
|
92
|
+
| `start_session(device, minutes)` | Start a timed session |
|
|
93
|
+
| `stop_session(device)` | Stop a running session |
|
|
94
|
+
| `get_schedules(device)` | Fetch device schedules |
|
|
95
|
+
| `update_schedule(device, key, data)` | Update a schedule |
|
|
96
|
+
| `get_mini_fragrances()` | Get all Mini fragrance names, codes, and QR URLs |
|
|
97
|
+
| `get_device_metadata()` | Fetch user-assigned room names and positions |
|
|
98
|
+
| `sign_out()` | Sign out from the service |
|
|
99
|
+
| `close()` | Close the HTTP session |
|
|
100
|
+
|
|
101
|
+
## Device Properties
|
|
102
|
+
|
|
103
|
+
| Property | Description |
|
|
104
|
+
|----------|-------------|
|
|
105
|
+
| `device_name` | User-assigned room name (falls back to product name) |
|
|
106
|
+
| `device_type` | `DeviceType` enum |
|
|
107
|
+
| `is_online` | Connection status |
|
|
108
|
+
| `is_power_on` | Power state |
|
|
109
|
+
| `intensity` | Current intensity level |
|
|
110
|
+
| `fragrance_name` | Resolved fragrance name (via Contentful for Mini) |
|
|
111
|
+
| `fragrance_color` | Hex color from fragrance catalog |
|
|
112
|
+
| `fragrance_remaining` | Percentage remaining (0-100) |
|
|
113
|
+
| `is_cartridge_present` | Whether a cartridge is inserted (full-size only) |
|
|
114
|
+
| `session_active` | Whether a timed session is running |
|
|
115
|
+
| `session_time_remaining` | Minutes left in session |
|
|
116
|
+
| `has_error` | Whether the device has an error condition |
|
|
117
|
+
|
|
118
|
+
## Scripts
|
|
119
|
+
|
|
120
|
+
- **`example.py`** - Minimal usage example showing device listing and control.
|
|
121
|
+
- **`test_local.py`** - Interactive test script that dumps all device properties including raw values, useful for debugging.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Disclaimer
|
|
131
|
+
|
|
132
|
+
This is an unofficial, reverse-engineered client with no affiliation to Aera, Prolitec, or Ayla Networks. It may break if the upstream API changes.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
aera/__init__.py
|
|
4
|
+
aera/api.py
|
|
5
|
+
aera/const.py
|
|
6
|
+
aera/contentful.py
|
|
7
|
+
aera/device.py
|
|
8
|
+
aeraforhome.egg-info/PKG-INFO
|
|
9
|
+
aeraforhome.egg-info/SOURCES.txt
|
|
10
|
+
aeraforhome.egg-info/dependency_links.txt
|
|
11
|
+
aeraforhome.egg-info/requires.txt
|
|
12
|
+
aeraforhome.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aera
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aeraforhome"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python API for Aera for Home smart fragrance diffusers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"aiohttp>=3.9.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=7.0",
|
|
19
|
+
"pytest-asyncio>=0.21",
|
|
20
|
+
"aioresponses>=0.7",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["aera*"]
|