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.
@@ -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,6 @@
1
+ aiohttp>=3.9.0
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
6
+ aioresponses>=0.7
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+