Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 102 additions & 19 deletions custom_components/aromalink_ha_integration/AromaLinkDeviceCoordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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,
Expand Down Expand Up @@ -706,30 +707,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}")
raise UpdateFailed(f"Error: {e}") from 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

Expand Down Expand Up @@ -806,6 +837,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=self.auth_coordinator.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
Comment thread
ndizazzo marked this conversation as resolved.

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()
Expand Down Expand Up @@ -910,22 +990,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
Expand Down
41 changes: 26 additions & 15 deletions custom_components/aromalink_ha_integration/button.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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}")
Expand Down
13 changes: 5 additions & 8 deletions custom_components/aromalink_ha_integration/number.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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."""

Expand All @@ -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):
Expand Down
Loading
Loading