From 406eea9d9e1062d8722aef1768c2d954f6225941 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 10 Jun 2026 19:39:42 -0400 Subject: [PATCH 1/3] feature: Reorganize controls, add API docs --- README.md | 3 +- .../AromaLinkDeviceCoordinator.py | 119 ++- .../aromalink_ha_integration/button.py | 41 +- .../aromalink_ha_integration/number.py | 13 +- .../aromalink_ha_integration/sensor.py | 52 +- .../aromalink_ha_integration/switch.py | 89 ++- docs/API.md | 688 ++++++++++++++++++ 7 files changed, 933 insertions(+), 72 deletions(-) create mode 100644 docs/API.md diff --git a/README.md b/README.md index f1694c4..ef831d8 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ Parameters: The integration creates: -- Switch entities for diffuser power +- **Power switch** — Controls oil pumping. Requires valid work/pause durations (> 0) to activate. +- **Fan switch** — Controls the exhaust fan independently of oil pumping. Use to accelerate scent distribution without running the diffuser. - Button entities for run/save actions - Number entities for work duration, pause duration, and polling interval - Sensor entities for runtime and device statistics diff --git a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py index 4b98543..dbbc7ae 100644 --- a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py +++ b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py @@ -52,6 +52,7 @@ def _default_device_data(self): "workStatus": None, "workRemainTime": None, "pauseRemainTime": None, + "fan": False, "raw_device_data": {}, "device_id": self.device_id, "device_name": self.device_name, @@ -707,30 +708,60 @@ async def _async_update_data(self): try: web_list_data = await self._fetch_web_list_state(jsessionid) + result = None if web_list_data is not None: - return self._apply_recent_switch_state( + result = self._apply_recent_switch_state( self._merge_device_data(previous_data, web_list_data) ) + else: + app_data = await self._fetch_app_device_info() + if app_data is not None: + result = self._apply_recent_switch_state( + self._merge_device_data(previous_data, app_data) + ) - app_data = await self._fetch_app_device_info() - if app_data is not None: - return self._apply_recent_switch_state( - self._merge_device_data(previous_data, app_data) + if result is None: + _LOGGER.warning( + "Failed to fetch runtime state for device %s from web list and app newWork endpoints.", + self.device_id, ) + raise UpdateFailed("Failed to fetch device runtime state") - _LOGGER.warning( - "Failed to fetch runtime state for device %s from web list and app newWork endpoints.", - self.device_id, - ) - raise UpdateFailed("Failed to fetch device runtime state") + # Warn on stale cached state (statisticsUpdateTime > 5 min old) + stats_time = result.get("raw_device_data", {}).get("statisticsUpdateTime") + if stats_time is not None: + try: + update_age_seconds = (time.time() * 1000 - int(stats_time)) / 1000 + if update_age_seconds > 300: + _LOGGER.debug( + "Device %s serving stale state (%.0fs old). onlineStatus=1 but data may be cached.", + self.device_id, update_age_seconds + ) + except (TypeError, ValueError): + pass + + return result except UpdateFailed: raise except Exception as e: _LOGGER.error(f"Error fetching device {self.device_id} info: {e}") raise UpdateFailed(f"Error: {e}") + def _has_valid_durations(self): + """Return True when work and pause durations are both > 0.""" + work = self._work_duration or 0 + pause = self._pause_duration or 0 + return work > 0 and pause > 0 + async def turn_on_off(self, state_to_set): - """Turn the diffuser on or off.""" + """Turn the diffuser on or off. Requires valid durations to turn on.""" + if state_to_set and not self._has_valid_durations(): + _LOGGER.warning( + "Cannot turn on device %s: work_duration=%d, pause_duration=%d (both must be > 0)", + self.device_id, self._work_duration or 0, self._pause_duration or 0 + ) + return False + await self.auth_coordinator._ensure_login() jsessionid = self.auth_coordinator.jsessionid @@ -807,6 +838,55 @@ async def turn_on_off(self, state_to_set): _LOGGER.error(f"Control error for device {self.device_id}: {e}") return False + async def set_fan(self, state_to_set): + """Turn the exhaust fan on or off.""" + await self.auth_coordinator._ensure_login() + jsessionid = self.auth_coordinator.jsessionid + + url = "https://www.aroma-link.com/device/switch" + data = { + "deviceId": self.device_id, + "fan": 1 if state_to_set else 0 + } + + await self._prime_device_session(jsessionid) + headers = self._build_headers( + referer=f"https://www.aroma-link.com/device/command/{self.device_id}", + jsessionid=jsessionid, + content_type="application/x-www-form-urlencoded; charset=UTF-8", + ) + + try: + self._log_request("POST", url, extra=f"fan={'1' if state_to_set else '0'}") + async with self.auth_coordinator.session.post( + url, + data=data, + headers=headers, + timeout=10, + ssl=AROMA_LINK_SSL, + ) as response: + self._log_response("POST", url, response.status) + if response.status == 200: + _LOGGER.info( + f"Successfully set fan to {'on' if state_to_set else 'off'} for device {self.device_id}") + optimistic_data = self._merge_device_data( + self.data, + {"fan": state_to_set}, + ) + self.async_set_updated_data(optimistic_data) + return True + elif response.status in [401, 403]: + _LOGGER.warning(f"Authentication error on set_fan ({response.status}).") + self.auth_coordinator.jsessionid = None + return False + else: + _LOGGER.error( + f"Failed to control fan for device {self.device_id}: {response.status}") + return False + except Exception as e: + _LOGGER.error(f"Fan control error for device {self.device_id}: {e}") + return False + async def set_scheduler(self, work_duration=None, pause_duration=None, week_days=None): """Set the scheduler for the diffuser.""" await self.auth_coordinator._ensure_login() @@ -911,22 +991,25 @@ async def set_scheduler(self, work_duration=None, pause_duration=None, week_days async def run_diffuser(self, work_duration=None, pause_duration=None): """Run the diffuser for a specific time.""" - # Use default values if specific ones aren't provided current_work_duration = work_duration if work_duration is not None else self._work_duration current_pause_duration = pause_duration if pause_duration is not None else self._pause_duration - buffertime = current_work_duration + 5 # Add buffer time + + if current_work_duration <= 0 or current_pause_duration <= 0: + _LOGGER.warning( + "Cannot run device %s: work_duration=%d, pause_duration=%d (both must be > 0)", + self.device_id, current_work_duration, current_pause_duration + ) + return False + + buffertime = current_work_duration + 5 _LOGGER.info( f"Setting up device {self.device_id} to run for {current_work_duration} seconds with {current_work_duration} second diffusion cycles and {current_pause_duration} second pauses") - # Set scheduler if not await self.set_scheduler(current_work_duration, current_pause_duration): - _LOGGER.error( - f"Failed to set scheduler for device {self.device_id}") + _LOGGER.error(f"Failed to set schedule for device {self.device_id}") return False - await asyncio.sleep(1) # Allow time for scheduler settings to apply - if not await self.turn_on_off(True): _LOGGER.error(f"Failed to turn on device {self.device_id}") return False diff --git a/custom_components/aromalink_ha_integration/button.py b/custom_components/aromalink_ha_integration/button.py index 5ba0441..ad77813 100644 --- a/custom_components/aromalink_ha_integration/button.py +++ b/custom_components/aromalink_ha_integration/button.py @@ -1,7 +1,7 @@ """Button platform for Aroma-Link.""" import logging from homeassistant.components.button import ButtonEntity -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .const import DOMAIN @@ -15,20 +15,20 @@ async def async_setup_entry(hass, entry, async_add_entities): entities = [] for device_id, coordinator in device_coordinators.items(): device_info = coordinator.get_device_info() - entities.append(AromaLinkRunButton(coordinator, entry, device_id, device_info["name"])) + entities.append(AromaLinkRunOnceButton(coordinator, entry, device_id, device_info["name"])) entities.append(AromaLinkSaveSettingsButton(coordinator, entry, device_id, device_info["name"])) async_add_entities(entities) -class AromaLinkRunButton(ButtonEntity): - """Representation of an Aroma-Link run button.""" +class AromaLinkRunOnceButton(ButtonEntity): + """Representation of an Aroma-Link run once button.""" def __init__(self, coordinator, entry, device_id, device_name): """Initialize the button.""" self._coordinator = coordinator self._entry = entry self._device_id = device_id - self._name = f"{device_name} Run" + self._name = f"{device_name} Run Once" self._unique_id = f"{entry.data['username']}_{device_id}_run" @property @@ -53,11 +53,16 @@ def device_info(self): async def async_press(self): """Run the diffuser for a fixed time.""" - work_duration = self._coordinator.work_duration - pause_duration = self._coordinator.pause_duration - - _LOGGER.info(f"Button pressed. Running diffuser with {work_duration}s work and {pause_duration}s pause settings") - + work_duration = self._coordinator.work_duration or 0 + pause_duration = self._coordinator.pause_duration or 0 + + if work_duration <= 0 or pause_duration <= 0: + _LOGGER.warning( + "Cannot run %s: work_duration=%d, pause_duration=%d (both must be > 0)", + self._device_id, work_duration, pause_duration + ) + return + await self._coordinator.run_diffuser(work_duration, pause_duration=pause_duration) class AromaLinkSaveSettingsButton(ButtonEntity): @@ -71,6 +76,7 @@ def __init__(self, coordinator, entry, device_id, device_name): self._name = f"{device_name} Save Settings" self._unique_id = f"{entry.data['username']}_{device_id}_save_settings" self._attr_icon = "mdi:content-save" + self._attr_entity_category = EntityCategory.CONFIG # Settings card, below main controls @property def name(self): @@ -94,11 +100,16 @@ def device_info(self): async def async_press(self): """Save the current work duration and pause duration settings.""" - work_duration = self._coordinator.work_duration - pause_duration = self._coordinator.pause_duration - - _LOGGER.info(f"Saving settings: work_duration={work_duration}s, pause_duration={pause_duration}s") - + work_duration = self._coordinator.work_duration or 0 + pause_duration = self._coordinator.pause_duration or 0 + + if work_duration <= 0 or pause_duration <= 0: + _LOGGER.warning( + "Cannot save settings for %s: work_duration=%d, pause_duration=%d (both must be > 0)", + self._device_id, work_duration, pause_duration + ) + return + result = await self._coordinator.set_scheduler(work_duration, pause_duration) if result: _LOGGER.info(f"Settings saved successfully for {self._coordinator.device_name}") diff --git a/custom_components/aromalink_ha_integration/number.py b/custom_components/aromalink_ha_integration/number.py index bfbc074..139619b 100644 --- a/custom_components/aromalink_ha_integration/number.py +++ b/custom_components/aromalink_ha_integration/number.py @@ -1,7 +1,7 @@ """Number platform for Aroma-Link.""" import logging from homeassistant.components.number import NumberEntity -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .const import ( DOMAIN, @@ -136,6 +136,7 @@ def __init__(self, coordinator, entry, device_id, device_name): self._attr_native_unit_of_measurement = "seconds" self._attr_icon = "mdi:spray" self._attr_mode = "box" # Make it a number input field instead of a slider + self._attr_entity_category = EntityCategory.CONFIG # Settings card, below main controls @property def name(self): @@ -151,7 +152,7 @@ def unique_id(self): def native_value(self): """Return the current value.""" return self._coordinator.work_duration - + async def async_set_native_value(self, value): """Set the work duration.""" self._coordinator.work_duration = int(value) @@ -167,11 +168,6 @@ def device_info(self): model="Diffuser", ) - async def async_set_native_value(self, value): - """Set the work duration.""" - self._coordinator.work_duration = int(value) - self.async_write_ha_state() - class AromaLinkPauseDurationNumber(NumberEntity): """Representation of an Aroma-Link pause duration setting.""" @@ -187,7 +183,8 @@ def __init__(self, coordinator, entry, device_id, device_name): self._attr_native_step = 5 # 5 second steps self._attr_native_unit_of_measurement = "seconds" self._attr_icon = "mdi:timer-pause" - self._attr_mode = "box" # Make it a number input field instead of a slider + self._attr_mode = "box" + self._attr_entity_category = EntityCategory.CONFIG # Settings card, below main controls @property def name(self): diff --git a/custom_components/aromalink_ha_integration/sensor.py b/custom_components/aromalink_ha_integration/sensor.py index d02a7fc..6cfb6f0 100644 --- a/custom_components/aromalink_ha_integration/sensor.py +++ b/custom_components/aromalink_ha_integration/sensor.py @@ -22,7 +22,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entities.append(AromaLinkWorkStatusSensor(coordinator, entry, device_id, device_info["name"])) entities.append(AromaLinkWorkRemainingTimeSensor(coordinator, entry, device_id, device_info["name"])) entities.append(AromaLinkPauseRemainingTimeSensor(coordinator, entry, device_id, device_info["name"])) - entities.append(AromaLinkOnCountSensor(coordinator, entry, device_id, device_info["name"])) + entities.append(AromaLinkTotalRunTimeSensor(coordinator, entry, device_id, device_info["name"])) entities.append(AromaLinkPumpCountSensor(coordinator, entry, device_id, device_info["name"])) async_add_entities(entities) @@ -163,25 +163,25 @@ def native_value(self): return 0 return None -class AromaLinkOnCountSensor(AromaLinkSensorBase): - """Sensor showing how many times the device has been turned on.""" +class AromaLinkTotalRunTimeSensor(AromaLinkSensorBase): + """Sensor showing total accumulated run time in hours.""" def __init__(self, coordinator, entry, device_id, device_name): - """Initialize the on count sensor.""" + """Initialize the total run time sensor.""" super().__init__( - coordinator, - entry, - device_id, - device_name, - "On Count", - icon="mdi:counter", - unit="activations" + coordinator, + entry, + device_id, + device_name, + "Total Run Time", + icon="mdi:timer-sand-complete", + unit=UnitOfTime.HOURS ) @property def native_value(self): - """Return the on count value.""" - return self._get_raw_count( + """Return total run time in hours (runCount is seconds).""" + raw = self._get_raw_count( "onCount", "runCount", "on_count", @@ -191,9 +191,13 @@ def native_value(self): "startCount", "start_count", ) + if raw is None: + return None + # runCount accumulates seconds of work time; convert to hours. + return round(raw / 3600, 2) class AromaLinkPumpCountSensor(AromaLinkSensorBase): - """Sensor showing the number of times the pump has operated (diffusions).""" + """Sensor showing total diffusion time in hours (airPumpCount × work_duration).""" def __init__(self, coordinator, entry, device_id, device_name): """Initialize the pump count sensor.""" @@ -202,15 +206,15 @@ def __init__(self, coordinator, entry, device_id, device_name): entry, device_id, device_name, - "Pump Count", - icon="mdi:shimmer", - unit="diffusions" + "Total Diffusion Time", + icon="mdi:spray-bottle", + unit=UnitOfTime.HOURS ) @property def native_value(self): - """Return the pump count value.""" - return self._get_raw_count( + """Return total diffusion time in hours (pump_count × work_duration / 3600).""" + pump_count = self._get_raw_count( "pumpCount", "airPumpCount", "pump_count", @@ -218,3 +222,13 @@ def native_value(self): "pumpTimes", "pump_times", ) + if pump_count is None: + return None + + work_duration = self.coordinator.work_duration or 0 + if work_duration <= 0: + return None + + # airPumpCount counts activations; multiply by work duration to get total diffusion time. + total_seconds = pump_count * work_duration + return round(total_seconds / 3600, 2) diff --git a/custom_components/aromalink_ha_integration/switch.py b/custom_components/aromalink_ha_integration/switch.py index 16c717b..a5d001e 100644 --- a/custom_components/aromalink_ha_integration/switch.py +++ b/custom_components/aromalink_ha_integration/switch.py @@ -1,32 +1,36 @@ """Switch platform for Aroma-Link.""" +import logging from homeassistant.components.switch import SwitchEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN, CONF_DEVICE_ID +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass, entry, async_add_entities): - """Set up Aroma-Link switch based on a config entry.""" + """Set up Aroma-Link switches based on a config entry.""" data = hass.data[DOMAIN][entry.entry_id] device_coordinators = data["device_coordinators"] - + entities = [] for device_id, coordinator in device_coordinators.items(): device_info = coordinator.get_device_info() - entities.append(AromaLinkSwitch(coordinator, entry, device_id, device_info["name"])) - + entities.append(AromaLinkPowerSwitch(coordinator, entry, device_id, device_info["name"])) + entities.append(AromaLinkFanSwitch(coordinator, entry, device_id, device_info["name"])) + async_add_entities(entities) -class AromaLinkSwitch(CoordinatorEntity, SwitchEntity): - """Representation of an Aroma-Link switch.""" +class AromaLinkPowerSwitch(CoordinatorEntity, SwitchEntity): + """Representation of an Aroma-Link power switch (oil pumping).""" def __init__(self, coordinator, entry, device_id, device_name): """Initialize the switch.""" super().__init__(coordinator) self._entry = entry self._device_id = device_id - self._name = f"{device_name} Power" - self._unique_id = f"{entry.data['username']}_{device_id}_switch" + self._name = f"{device_name} Active" + self._unique_id = f"{entry.data['username']}_{device_id}_power" @property def name(self): @@ -40,9 +44,18 @@ def unique_id(self): @property def is_on(self): - """Return true if the switch is on.""" + """Return true if the device is powered on and pumping.""" return self.coordinator.data.get("state", False) + @property + def available(self): + """Device only pumps when work/pause durations are configured > 0.""" + work = self.coordinator.work_duration or 0 + pause = self.coordinator.pause_duration or 0 + if work <= 0 or pause <= 0: + return False + return super().available + @property def device_info(self): """Return device information about this Aroma-Link device.""" @@ -54,9 +67,63 @@ def device_info(self): ) async def async_turn_on(self, **kwargs): - """Turn the switch on.""" + """Turn the device on (start pumping oil).""" + work = self.coordinator.work_duration or 0 + pause = self.coordinator.pause_duration or 0 + if work <= 0 or pause <= 0: + _LOGGER.warning( + "Cannot turn on %s: work_duration=%d, pause_duration=%d (both must be > 0)", + self._device_id, work, pause + ) + return await self.coordinator.turn_on_off(True) async def async_turn_off(self, **kwargs): - """Turn the switch off.""" + """Turn the device off.""" await self.coordinator.turn_on_off(False) + + +class AromaLinkFanSwitch(CoordinatorEntity, SwitchEntity): + """Representation of an Aroma-Link exhaust fan switch.""" + + def __init__(self, coordinator, entry, device_id, device_name): + """Initialize the fan switch.""" + super().__init__(coordinator) + self._entry = entry + self._device_id = device_id + self._name = f"{device_name} Fan" + self._unique_id = f"{entry.data['username']}_{device_id}_fan" + self._attr_icon = "mdi:fan" + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID for this entity.""" + return self._unique_id + + @property + def is_on(self): + """Return true if the exhaust fan is on.""" + return self.coordinator.data.get("fan", False) + + @property + def device_info(self): + """Return device information about this Aroma-Link device.""" + return DeviceInfo( + identifiers={(DOMAIN, f"{self._entry.data['username']}_{self._device_id}")}, + name=self.coordinator.device_name, + manufacturer="Aroma-Link", + model="Diffuser", + ) + + async def async_turn_on(self, **kwargs): + """Turn the exhaust fan on.""" + await self.coordinator.set_fan(True) + + async def async_turn_off(self, **kwargs): + """Turn the exhaust fan off.""" + await self.coordinator.set_fan(False) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d9e6c60 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,688 @@ +# Aroma-Link API Reference + +Documented from reverse-engineering `www.aroma-link.com`. Base URLs use `https://` for web endpoints and `http://` for app endpoints (the server redirects HTTP to HTTPS transparently). + +All times in seconds unless noted. Device ID used in examples: `406387`, User ID: `175527`. + +--- + +## Table of Contents + +- [Authentication](#authentication) + - [Web Session (JSESSIONID)](#web-session-jsessionid) + - [App Token Flow](#app-token-flow) +- [Device State Endpoints](#device-state-endpoints) + - [GET /device/list/v2 — Device List v2](#get-devicelistv2--device-list-v2) + - [GET /device/list — Device List v1](#get-devicelist--device-list-v1) + - [GET /device/deviceInfo/now/{id} — Real-time Info (Unreliable)](#get-devicedevicenowid--real-time-info-unreliable) + - [GET /v1/app/device/newWork/{id} — App Device State](#get-v1appdeviceworkid--app-device-state) +- [Device Control Endpoints](#device-control-endpoints) + - [POST /device/switch — Power On/Off](#post-deviceswitch--power-on-off) + - [POST /v1/app/data/newSwitch — App Power Switch](#post-v1appdatanewswitch--app-power-switch) +- [Scheduler Endpoints](#scheduler-endpoints) + - [GET /device/workTime/{id} — Work Time Settings](#get-deviceworktimeid--work-time-settings) + - [POST /device/workSet — Set Scheduler](#post-deviceworkset--set-scheduler) +- [User Endpoints](#user-endpoints) + - [GET /v1/app/user/{userId} — User Profile](#get-v1appuserid--user-profile) +- [Common Types & Enums](#common-types--enums) +- [Version Capability Matrix](#version-capability-matrix) +- [Known Issues & Observations](#known-issues--observations) + +--- + +## Authentication + +Two independent auth systems coexist. They are not interchangeable — web endpoints require JSESSIONID cookies, app endpoints require a JWT access token header. + +### Web Session (JSESSIONID) + +The website uses a cookie-based session. Login returns `code: 0` on success and sets an HttpOnly `JSESSIONID` cookie. + +**Step 1 — GET the login page (optional but recommended for initial cookies)** + +``` +GET https://www.aroma-link.com/ +``` + +No special headers needed. The server may set a preliminary session cookie. + +**Step 2 — POST login** + +``` +POST https://www.aroma-link.com/login +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 +X-Requested-With: XMLHttpRequest +Referer: https://www.aroma-link.com/ + +username=smellyuser&password=SuperSecret123 +``` + +> **Important**: Field names are `username` and `password` (lowercase). The earlier form used `userName` which returns a 500 HTML error page. Password is sent in **raw plaintext** — not hashed. + +**Success response:** + +```json +{"code": 0, "msg": "SUCCESS"} +``` + +Server sets cookie: `Set-Cookie: JSESSIONID=; Path=/; HttpOnly` + +**Failure response:** + +```json +{"code": 500, "msg": "Incorrect account or password"} +``` + +Returns HTML 500 page body (not JSON). Check status + parse for `"code"` to distinguish. + +**Using the session:** + +All subsequent web requests include: + +``` +Cookie: languagecode=EN; JSESSIONID= +User-Agent: Mozilla/5.0 ... +X-Requested-With: XMLHttpRequest +Referer: https://www.aroma-link.com/device/list +``` + +The `languagecode` cookie defaults to `EN`. Session cookies expire server-side (observed timeout ~15-30 min of inactivity). Re-login when endpoints return empty/non-JSON responses. + +### App Token Flow + +App authentication is a 3-step flow. The token endpoint accepts MD5-hashed passwords. + +**Step 1 — newLogin** + +``` +POST http://www.aroma-link.com/v1/app/user/newLogin +Content-Type: multipart/form-data + +userName=smellyuser&password= +``` + +Password is the **MD5 hex digest** of the plaintext password (e.g., `SuperSecret123` → ``). + +**Success response:** + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "isSuper": 0, + "userId": 175527, + "isShowDel": 0, + "email": "" + } +} +``` + +**Step 2 — Get access token** + +``` +POST http://www.aroma-link.com/v2/app/token +Content-Type: multipart/form-data + +userName=smellyuser&password= +``` + +**Success response:** + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInppcCI6IkRFRiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInppcCI6IkRFRiJ9...", + "accessTokenValidity": 212800000, + "refreshTokenValidity": 299200000, + "id": 175527, + "email": null, + "resources": null + } +} +``` + +The `accessToken` is a JWT with header `{"alg":"HS256","zip":"DEF"}` — payload is DEFLATE-compressed. Token validity values are in milliseconds (~212800s ≈ 2.47 days for access, ~299200s ≈ 3.46 days for refresh). + +**Step 3 (optional) — Refresh token** + +``` +POST http://www.aroma-link.com/v2/app/refresh/token +Content-Type: multipart/form-data + +refreshToken= +``` + +Returns same shape as Step 2 with new tokens. + +**Using app auth:** + +Send the access token via header on subsequent requests: + +``` +Access-Token: +User-Agent: Mozilla/5.0 ... +``` + +> **WARNING**: App endpoints consistently return `code: 13002` ("Unauthorized or Token has expired") in testing, even immediately after token issuance. The JWT uses DEFLATE compression which may indicate a non-standard validation path on the server. Web auth (JSESSIONID) is the reliable authentication method. + +--- + +## Device State Endpoints + +### GET /device/list/v2 — Device List v2 + +**Primary state source.** Returns all devices for the authenticated user. Most fields, but **no count fields** (`runCount`, `airPumpCount` absent). + +``` +GET https://www.aroma-link.com/device/list/v2?limit=10&offset=0&selectUserId=&groupId=&deviceName=&imei=&deviceNo=&workStatus=&continentId=&countryId=&areaId=&sort=&order= +Cookie: languagecode=EN; JSESSIONID= +``` + +**Response:** + +```json +{ + "rows": [ + { + "deviceId": 406387, + "deviceName": "Gemini", + "typeCode": "A6/Pro300", + "virtualImei": "110083185212163", + "deviceNo": "289C6E53B9D4", + "userId": 175527, + "username": "smellyuser", + "groupId": 167001, + "groupName": "default group", + "continentName": "North America", + "countryName": "Canada", + "areaName": "Toronto", + "activeTime": null, + "netType": "WIFI", + "version": "V1.0.20201213", + "onlineStatus": 1, + "onlineErrorStatus": 0, + "workStatus": 0, + "workInfo": "Sunday:\r\nFirst:00:00-23:59 W 60/ P 90 A Level\r\n...", + "localTime": "2026-06-10 15:31:03", + "oilCount": 0, + "remainOil": null, + "setCount": 31, + "salesCount": 0, + "errorCount": 0, + "isLock": 0, + "isError": 0, + "deviceType": "01", + "timeZone": "UTC-4", + "errorDesc": null, + "email": "", + "type": 0, + "hasWeight": 0, + "ojiShowType": 0, + "hasOjiWarn": 0 + } + ] +} +``` + +**Key fields:** + +| Field | Type | Description | +|---|---|---| +| `workStatus` | int | 0=Off/Idle, 1=Diffusing (active), 2=Paused (between cycles) | +| `onlineStatus` | int | 0=Offline, 1=Online | +| `localTime` | string | Device-local timestamp `YYYY-MM-DD HH:MM:SS` | +| `workInfo` | string | Human-readable schedule summary per day | +| `oilCount` | int | Oil level indicator. 0 on models without oil sensor (A6/Pro300) | +| `hasWeight` | int | 1 if device has a weight/oil sensor, 0 otherwise | + +**Filtering:** Query params allow filtering by `workStatus`, `groupId`, `deviceName`, etc. Empty values mean "no filter". + +### GET /device/list — Device List v1 + +Legacy endpoint. Fewer descriptive fields but **includes count fields** (`runCount`, `airPumpCount`) that V2 omits. + +``` +GET https://www.aroma-link.com/device/list?limit=10&offset=0 +Cookie: languagecode=EN; JSESSIONID= +``` + +**Response:** + +```json +{ + "rows": [ + { + "deviceId": 406387, + "deviceName": "Gemini", + "username": "smellyuser", + "deviceType": "01", + "version": "V1.0.20201213", + "timeZone": "UTC-4", + "localTime": null, + "continentId": 118, + "countryId": 688, + "areaId": 11618, + "onlineStatus": 1, + "workStatus": 0, + "deviceNo": "289C6E53B9D4", + "activeTime": null, + "groupName": "default group", + "groupId": 167001, + "runCount": 1569411, + "airPumpCount": 8531, + "oilCount": 0, + "remainOil": null, + "typeId": null, + "virtualImei": "110083185212163", + "isLock": 0, + "userId": 175527, + "netType": "WIFI", + "hasFan": null, + "hasLamp": null, + "hasWeight": null, + "hasBattery": null, + "hasPump": null, + "typeCode": "A6/Pro300", + "setCount": 31, + "salesCount": 0, + "errorCount": 0, + "workInfo": null, + "statisticsUpdateTime": 1781119858000, + "useMode": 0, + "deviceArea": 0, + "isError": 0 + } + ] +} +``` + +**Count fields:** + +| Field | Type | Description | Verified Behavior | +|---|---|---|---| +| `runCount` | int | Accumulated work time in **seconds** | +10 after a single 10s diffusion cycle | +| `airPumpCount` | int | Number of pump activations (diffusions) | +1 per completed diffusion cycle | + +> **Important**: `runCount` is NOT an activation counter. It tracks total seconds the device has been in work/diffusing state across its lifetime. A value of 1,569,411 = ~26 hours of cumulative operation. + +### GET /device/deviceInfo/now/{id} — Real-time Info (Unreliable) + +Returns `code: 503` consistently. Likely deprecated or broken server-side. Do not rely on this endpoint. + +``` +GET https://www.aroma-link.com/device/deviceInfo/now/406387 +Cookie: languagecode=EN; JSESSIONID= +``` + +**Response:** `{"code": 503, "msg": "OK"}` — no data payload. + +### GET /v1/app/device/newWork/{id} — App Device State + +App-only endpoint for device state. Returns richer real-time data including `powerState`, `pumpCount`. Requires valid app access token (unreliable in testing). + +``` +GET http://www.aroma-link.com/v1/app/device/newWork/406387?isOpenPage=0&userId=175527 +Access-Token: +``` + +The `isOpenPage` parameter controls response detail level: +- `0` — basic state +- `1` — enriched with additional fields (`powerState`, detailed schedule) + +**Expected fields (from plugin parsing logic, not confirmed live):** +`powerState`, `workStatus`, `pumpCount`, `onOff`, `switchStatus`, `isOpen`, `isOn`, `workRemainTime`, `pauseRemainTime` + +--- + +## Device Control Endpoints + +### POST /device/switch — Power On/Off and Exhaust Fan Control + +Reliable web endpoint. Sends commands to control device power (oil pumping) and the exhaust fan. Returns immediately; state changes propagate asynchronously (device reporting is delayed/stale). + +``` +POST https://www.aroma-link.com/device/switch +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 +Cookie: languagecode=EN; JSESSIONID= +X-Requested-With: XMLHttpRequest +Referer: https://www.aroma-link.com/device/command/406387 + +deviceId=406387&onOff=1&fan=1 +``` + +**Parameters:** + +| Param | Type | Values | Description | +|---|---|---|---| +| `deviceId` | int | — | Target device ID | +| `onOff` | int | 0 or 1 | 0=Power Off, 1=Power On (pumps oil when active) | +| `fan` | int | 0 or 1 | 0=Exhaust fan off, 1=Exhaust fan on | + +**Success response:** `{"code": 200, "msg": "OK"}` + +**How it works:** + +- `onOff=1` powers the device and starts pumping oil using configured work/pause durations. Requires `workDuration > 0` and `pauseDuration > 0`. +- `fan=1` turns on the exhaust fan to accelerate diffused scent out of the unit — this is purely for better scent distribution, not diffusion itself. +- Both parameters are independent: you can pump oil without the fan, run the fan without pumping, or use both together. + +**Example flow:** +``` +# Power on device (pumps oil with configured durations) +POST /device/switch?deviceId=406387&onOff=1 + +# Turn on exhaust fan for better scent distribution +POST /device/switch?deviceId=406387&fan=1 + +# Turn off exhaust fan only (keep pumping) +POST /device/switch?deviceId=406387&fan=0 + +# Power off completely +POST /device/switch?deviceId=406387&onOff=0 +``` + +**State reporting:** Device state updates are delayed and may not reflect real-time operation. `runCount` increases only after completed work cycles, not during active diffusion. + +### POST /v1/app/data/newSwitch — App Power Switch + +App-only switch endpoint. Returns `code: 13002` in testing due to app auth issues. + +``` +POST http://www.aroma-link.com/v1/app/data/newSwitch +Content-Type: multipart/form-data +Access-Token: + +deviceId=406387&onOff=1&userId=175527 +``` + +--- + +## Schedule and Operation Mode Endpoints + +The device has three independent features: + +1. **Power (`onOff`)** — Controls oil pumping. When `onOff=1`, the device pumps oil using configured work/pause durations. Requires `workDuration > 0` and `pauseDuration > 0`. +2. **Exhaust Fan (`fan`)** — Physical fan that accelerates diffused scent out of the unit for better distribution. Independent of pumping. +3. **Schedules** — Weekly automation that controls when to pump oil (`onOff=1`/`onOff=0`) and applies work/pause durations based on active time slots. + +A schedule slot is NOT just "when to run." When a slot becomes active, it: +- Sets the power state (`onOff=1` or `onOff=0`) +- Applies its configured work/pause durations + +Without an active schedule, you can still manually control power via `/device/switch?onOff=1`. The exhaust fan is always independent and controlled separately. + +### GET /device/workTime/{id} — Work Time Settings + +Returns time slot configuration for a given day of week. Each device has 5 configurable slots per day, only one can be enabled at a time. + +``` +GET https://www.aroma-link.com/device/workTime/406387?week=0 +Cookie: languagecode=EN; JSESSIONID= +``` + +**Parameters:** + +| Param | Type | Values | Description | +|---|---|---|---| +| `week` | int | 0-6 | Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) | + +**Response:** + +```json +{ + "code": 200, + "msg": "OK", + "data": [ + { + "settingId": 419605, + "deviceId": 406387, + "weekDay": 0, + "startHour": "00:00", + "endHour": "23:59", + "workSec": 60, + "pauseSec": 90, + "consistenceLevel": 1, + "createTime": 1781118798890, + "updateTime": null, + "enabled": 1, + "dataId": 0, + "createUserId": null, + "updateUserId": null, + "workInfo": "First:00:00-23:59 W 60/ P 90 A Level\r\n", + "condition1": "08005036d91f90e74ff124a0def37ad8", + "manyPumpEnabled": null, + "selectPump": null + }, + { + "settingId": 419607, + "startHour": "00:00", + "endHour": "24:00", + "workSec": 10, + "pauseSec": 900, + "enabled": 0, + ... + } + ] +} +``` + +**Slot fields:** + +| Field | Type | Description | +|---|---|---| +| `enabled` | int | 1=active slot, 0=disabled placeholder | +| `workSec` | int | Work duration in seconds — how long to pump oil per cycle when active | +| `pauseSec` | int | Pause duration in seconds — gap between pumping cycles | +| `startHour` / `endHour` | string | Time window for this slot (`HH:MM`) | +| `consistenceLevel` | int | Concentration level (1=A Level, 2=B Level, etc.) | + +> **Important**: These durations control oil pumping. If both are > 0 and the device is powered on (`onOff=1`), it will pump scent for `workSec`, pause for `pauseSec`, and repeat. + +Only one slot per day should have `enabled=1`. The remaining 4 slots are disabled placeholders. + +### POST /device/workSet — Set Scheduler + +Sets the work/pause schedule for all days. Sends a full payload with 5 time slots per day across all 7 days (though only the first enabled slot matters). + +``` +POST https://www.aroma-link.com/device/workSet +Content-Type: application/json;charset=UTF-8 +Cookie: languagecode=EN; JSESSIONID= +X-Requested-With: XMLHttpRequest +Referer: https://www.aroma-link.com/device/command/406387 +``` + +**Request body:** + +```json +{ + "deviceId": "406387", + "type": "workTime", + "week": [0, 1, 2, 3, 4, 5, 6], + "workTimeList": [ + { + "startTime": "00:00", + "endTime": "23:59", + "enabled": 1, + "consistenceLevel": "1", + "workDuration": "60", + "pauseDuration": "90" + }, + { + "startTime": "00:00", + "endTime": "24:00", + "enabled": 0, + "consistenceLevel": "1", + "workDuration": "10", + "pauseDuration": "90" + }, + { + "startTime": "00:00", + "endTime": "24:00", + "enabled": 0, + "consistenceLevel": "1", + "workDuration": "10", + "pauseDuration": "90" + }, + { + "startTime": "00:00", + "endTime": "24:00", + "enabled": 0, + "consistenceLevel": "1", + "workDuration": "10", + "pauseDuration": "90" + }, + { + "startTime": "00:00", + "endTime": "24:00", + "enabled": 0, + "consistenceLevel": "1", + "workDuration": "10", + "pauseDuration": "90" + } + ] +} +``` + +**Parameters:** + +| Field | Type | Description | +|---|---|---| +| `deviceId` | string | Target device ID (sent as string) | +| `type` | string | Always `"workTime"` | +| `week` | int[] | Days of week to apply: `[0..6]` for all days | +| `workTimeList` | array | Exactly 5 slot objects. Only the first with `enabled=1` takes effect per day | + +**Per-slot fields:** + +| Field | Type | Description | +|---|---|---| +| `startTime` / `endTime` | string | Time window (`HH:MM`) — when this schedule is active | +| `enabled` | int | 1 or 0 | +| `consistenceLevel` | string | Concentration level as string `"1"`-`"4"` | +| `workDuration` | string | Work cycle duration in seconds (sent as **string**) — applied when slot is active | +| `pauseDuration` | string | Pause duration in seconds (sent as **string**) — applied when slot is active | + +**Success response:** `{"code": 200, "msg": ""}` + +> When a schedule slot becomes active, it automatically sets `onOff=1`, applies its work/pause durations, and starts oil pumping. All slots must be disabled (`enabled=0`) to stop scheduled operation — otherwise the device will auto-toggle power based on time windows. + +--- + +## User Endpoints + +### GET /v1/app/user/{userId} — User Profile + +App-only user profile lookup. Requires valid app token (unreliable). + +``` +GET http://www.aroma-link.com/v1/app/user/175527?email=smellyuser&language=EN +Access-Token: +``` + +--- + +## Common Types & Enums + +### workStatus + +| Value | Meaning | Behavior | +|---|---|---| +| `0` | Off / Idle | Device is powered off or not actively cycling | +| `1` | Diffusing | Pump is active, currently diffusing oil | +| `2` | Paused | Between work cycles — device will resume after `pauseSec` elapses | + +Cycle progression when power is on and durations are set: `0 → 1 (workSec) → 2 (pauseSec) → 1 (workSec) → ...` + +> **Note**: State reporting is delayed. The API may return stale values even while the device is actively cycling. Use `runCount` changes between polls to confirm actual operation. + +### onlineStatus + +| Value | Meaning | +|---|---| +| `0` | Device offline / unreachable | +| `1` | Device online and reporting | + +### consistenceLevel (Concentration) + +| Value | Label | +|---|---| +| `"1"` | A Level (lightest) | +| `"2"` | B Level | +| `"3"` | C Level | +| `"4"` | D Level (strongest) | + +### Response Codes + +| Code | Meaning | Context | +|---|---|---| +| `0` | Success | Web login only | +| `200` | OK | All other web + app endpoints | +| `503` | Server error | `/device/deviceInfo/now/*` (endpoint broken) | +| `13002` | Unauthorized / Token expired | App endpoints when token is invalid | + +--- + +## Version Capability Matrix + +| Capability | V1 List | V2 List | App newWork | Web Switch | App Switch | WorkTime GET | WorkSet POST | +|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Device basic info (name, type, online) | Yes | Yes | Yes* | — | — | — | — | +| `workStatus` (0/1/2) | Yes | Yes | Yes* | — | — | — | — | +| `runCount` (accumulated work seconds) | **Yes** | No | Maybe* | — | — | — | — | +| `airPumpCount` (pump activations) | **Yes** | No | Maybe* | — | — | — | — | +| Schedule info (`workInfo`) | No | Yes | Yes* | — | — | Yes | — | +| Power control | — | — | — | Yes | Yes† | — | — | +| Scheduler read | — | — | — | — | — | Yes | — | +| Scheduler write | — | — | — | — | — | — | Yes | + +\* App endpoints not confirmed working (return 13002 in testing) +† App switch returns 13002; use web `/device/switch` instead + +**Recommendation**: Use V2 for device listing + state polling, V1 only when count fields are needed. Control exclusively through web endpoints (`/device/switch`, `/device/workSet`). + +--- + +## Known Issues & Observations + +### App Auth Token Rejection +All app endpoints return `code: 13002` ("Unauthorized or Token has expired") immediately after token issuance, despite the JWT being freshly minted. The JWT uses DEFLATE-compressed payload (`"zip":"DEF"` in header) which may indicate a non-standard validation path. **Workaround**: use web JSESSIONID auth exclusively. + +### Switch Command Latency +After sending `POST /device/switch?onOff=1`, the device takes 15-20 seconds before `workStatus` transitions from 0 to 1. This is not a network delay — it's device-side acknowledgment time. Polling at 60s intervals may miss the initial transition entirely if polling aligns unlucky. + +### State Reporting Cadence +- `workStatus` updates near-real-time (observed cycling between 1↔2 within seconds) +- `runCount` and `airPumpCount` only update when the device pushes data upstream on its own schedule — cannot be forced via API +- A device may show `onlineStatus=1` while serving stale cached state (`statisticsUpdateTime` hours old). The mobile app uses a different communication path that can trigger fresh reporting. + +### Exhaust Fan Control +The fan parameter controls a physical exhaust fan for scent distribution, independent of oil pumping: +- `onOff=1` powers the device and starts pumping oil (requires work/pause durations > 0) +- `fan=1` turns on the exhaust fan to accelerate diffused scent out of the unit +- Both are independent — you can pump without fan, run fan without pumping, or use both + +### Schedules vs Manual Control +Schedules and manual power control are separate features: +- **Manual** (`onOff=1`/`onOff=0`) — controlled directly via `/device/switch`, starts/stops oil pumping immediately +- **Schedules** — weekly automation that auto-toggles `onOff=1`/`onOff=0` and applies work/pause durations when time slots are active +- A schedule is NOT just "when to run" — it controls power state AND sets work/pause values + +### workStatus = 2 (Paused) Ambiguity +When `workStatus=2`, the device is between diffusion cycles but still powered. State reporting is delayed and may not reflect real-time operation — use `runCount` changes between polls to confirm actual cycling activity. + +### runCount is Seconds, Not Activations +Despite the name, `runCount` accumulates seconds of work time, not number of activations. After a 10s diffusion cycle: `runCount += 10`. Use `airPumpCount` for actual pump activation counts (+1 per cycle). + +### deviceInfo/now is Dead +`GET /device/deviceInfo/now/{id}` returns HTTP 200 with `code: 503` and no data. This endpoint should not be used as a state source. + +### Oil Count Unreliable on Some Models +Devices without oil sensors (A6/Pro300 has `hasWeight=0`) always report `oilCount=0`. Do not use this field to detect low oil on all device types. + +### Session Cookie Expiry +JSESSIONID cookies expire after a period of inactivity (~15-30 min observed). When endpoints return empty responses or non-JSON content, the session has likely expired and requires re-login. From 895cb02cf9197486300c6cd6490031e916debda0 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 10 Jun 2026 21:21:51 -0400 Subject: [PATCH 2/3] obey coderabbit --- .../AromaLinkDeviceCoordinator.py | 2 +- custom_components/aromalink_ha_integration/sensor.py | 8 ++++++-- custom_components/aromalink_ha_integration/switch.py | 8 ++------ docs/API.md | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py index dbbc7ae..c050e80 100644 --- a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py +++ b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py @@ -745,7 +745,7 @@ async def _async_update_data(self): raise except Exception as e: _LOGGER.error(f"Error fetching device {self.device_id} info: {e}") - raise UpdateFailed(f"Error: {e}") + raise UpdateFailed(f"Error: {e}") from e def _has_valid_durations(self): """Return True when work and pause durations are both > 0.""" diff --git a/custom_components/aromalink_ha_integration/sensor.py b/custom_components/aromalink_ha_integration/sensor.py index 6cfb6f0..afb1c59 100644 --- a/custom_components/aromalink_ha_integration/sensor.py +++ b/custom_components/aromalink_ha_integration/sensor.py @@ -197,7 +197,7 @@ def native_value(self): return round(raw / 3600, 2) class AromaLinkPumpCountSensor(AromaLinkSensorBase): - """Sensor showing total diffusion time in hours (airPumpCount × work_duration).""" + """Sensor showing total diffusion time in hours (airPumpCount * work_duration).""" def __init__(self, coordinator, entry, device_id, device_name): """Initialize the pump count sensor.""" @@ -213,7 +213,7 @@ def __init__(self, coordinator, entry, device_id, device_name): @property def native_value(self): - """Return total diffusion time in hours (pump_count × work_duration / 3600).""" + """Return total diffusion time in hours (pump_count * work_duration / 3600).""" pump_count = self._get_raw_count( "pumpCount", "airPumpCount", @@ -227,6 +227,10 @@ def native_value(self): work_duration = self.coordinator.work_duration or 0 if work_duration <= 0: + _LOGGER.debug( + "Omitting historical diffusion time for %s: invalid work_duration=%d", + self._device_id, work_duration + ) return None # airPumpCount counts activations; multiply by work duration to get total diffusion time. diff --git a/custom_components/aromalink_ha_integration/switch.py b/custom_components/aromalink_ha_integration/switch.py index a5d001e..e57dc7c 100644 --- a/custom_components/aromalink_ha_integration/switch.py +++ b/custom_components/aromalink_ha_integration/switch.py @@ -49,11 +49,7 @@ def is_on(self): @property def available(self): - """Device only pumps when work/pause durations are configured > 0.""" - work = self.coordinator.work_duration or 0 - pause = self.coordinator.pause_duration or 0 - if work <= 0 or pause <= 0: - return False + """Return true when the coordinator reports this device reachable.""" return super().available @property @@ -67,7 +63,7 @@ def device_info(self): ) async def async_turn_on(self, **kwargs): - """Turn the device on (start pumping oil).""" + """Turn the device on when work/pause durations are configured.""" work = self.coordinator.work_duration or 0 pause = self.coordinator.pause_duration or 0 if work <= 0 or pause <= 0: diff --git a/docs/API.md b/docs/API.md index d9e6c60..825ee62 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,12 +14,12 @@ All times in seconds unless noted. Device ID used in examples: `406387`, User ID - [Device State Endpoints](#device-state-endpoints) - [GET /device/list/v2 — Device List v2](#get-devicelistv2--device-list-v2) - [GET /device/list — Device List v1](#get-devicelist--device-list-v1) - - [GET /device/deviceInfo/now/{id} — Real-time Info (Unreliable)](#get-devicedevicenowid--real-time-info-unreliable) - - [GET /v1/app/device/newWork/{id} — App Device State](#get-v1appdeviceworkid--app-device-state) + - [GET /device/deviceInfo/now/{id} — Real-time Info (Unreliable)](#get-devicedeviceinfonowid--real-time-info-unreliable) + - [GET /v1/app/device/newWork/{id} — App Device State](#get-v1appdevicenewworkid--app-device-state) - [Device Control Endpoints](#device-control-endpoints) - - [POST /device/switch — Power On/Off](#post-deviceswitch--power-on-off) + - [POST /device/switch — Power On/Off and Exhaust Fan Control](#post-deviceswitch--power-onoff-and-exhaust-fan-control) - [POST /v1/app/data/newSwitch — App Power Switch](#post-v1appdatanewswitch--app-power-switch) -- [Scheduler Endpoints](#scheduler-endpoints) +- [Schedule and Operation Mode Endpoints](#schedule-and-operation-mode-endpoints) - [GET /device/workTime/{id} — Work Time Settings](#get-deviceworktimeid--work-time-settings) - [POST /device/workSet — Set Scheduler](#post-deviceworkset--set-scheduler) - [User Endpoints](#user-endpoints) From 391836af980ab320265b4f3212cb26f35a8d0e73 Mon Sep 17 00:00:00 2001 From: Daly Mauldin Date: Tue, 7 Jul 2026 05:07:45 +0000 Subject: [PATCH 3/3] fix: use configurable ssl in set_fan, keep power switch unique_id stable Merging master brought in #32's removal of the AROMA_LINK_SSL constant, which set_fan still referenced - swap it for the auth coordinator's ssl property. Also restore the power switch unique_id (username_deviceId_switch) so existing entity registry entries, automations, and history survive the Power/Fan split, and name it 'Power' to match the README. Co-Authored-By: Claude Fable 5 --- .../aromalink_ha_integration/AromaLinkDeviceCoordinator.py | 2 +- custom_components/aromalink_ha_integration/switch.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py index 224e1c7..7cd2f30 100644 --- a/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py +++ b/custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py @@ -862,7 +862,7 @@ async def set_fan(self, state_to_set): data=data, headers=headers, timeout=10, - ssl=AROMA_LINK_SSL, + ssl=self.auth_coordinator.ssl, ) as response: self._log_response("POST", url, response.status) if response.status == 200: diff --git a/custom_components/aromalink_ha_integration/switch.py b/custom_components/aromalink_ha_integration/switch.py index e57dc7c..5f03967 100644 --- a/custom_components/aromalink_ha_integration/switch.py +++ b/custom_components/aromalink_ha_integration/switch.py @@ -29,8 +29,10 @@ def __init__(self, coordinator, entry, device_id, device_name): super().__init__(coordinator) self._entry = entry self._device_id = device_id - self._name = f"{device_name} Active" - self._unique_id = f"{entry.data['username']}_{device_id}_power" + self._name = f"{device_name} Power" + # Keep the pre-split unique_id so existing registry entries, + # automations, and history carry over to this entity. + self._unique_id = f"{entry.data['username']}_{device_id}_switch" @property def name(self):