Skip to content

Commit

Permalink
Support for the PUREi9 Robot Vacuum (#145)
Browse files Browse the repository at this point in the history
* Implemented support for the PUREi9 vacuum

* Cleaned up unessessary changes

* Cleaned up unessessary changes

* Fixed bug introduces by cleanup

* Fixed error that kept vacuum battery from updating

* Added labels to all 14 robot states

* Reimplemented battery state similar to speed range to allow for model dependent ranges
  • Loading branch information
joakimjalden committed Sep 25, 2024
1 parent dc56ed2 commit 5434f30
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 5 deletions.
8 changes: 7 additions & 1 deletion custom_components/wellbeing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
from .const import DOMAIN

_LOGGER: logging.Logger = logging.getLogger(__package__)
PLATFORMS = [Platform.SENSOR, Platform.FAN, Platform.BINARY_SENSOR, Platform.SWITCH]
PLATFORMS = [
Platform.SENSOR,
Platform.FAN,
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.VACUUM,
]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
Expand Down
83 changes: 79 additions & 4 deletions custom_components/wellbeing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class Model(str, Enum):
AX5 = "AX5"
AX7 = "AX7"
AX9 = "AX9"
PUREi9 = "PUREi9"


class WorkMode(str, Enum):
OFF = "PowerOff"
Expand Down Expand Up @@ -109,6 +111,13 @@ def __init__(self, name, attr) -> None:
super().__init__(name, attr)


class ApplianceVacuum(ApplianceEntity):
entity_type: int = Platform.VACUUM

def __init__(self, name, attr) -> None:
super().__init__(name, attr)


class ApplianceBinary(ApplianceEntity):
entity_type: int = Platform.BINARY_SENSOR

Expand Down Expand Up @@ -167,7 +176,7 @@ def _create_entities(data):
name="State",
attr="State",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC
entity_category=EntityCategory.DIAGNOSTIC,
),
ApplianceBinary(
name="PM Sensor State",
Expand All @@ -191,6 +200,18 @@ def _create_entities(data):
),
]

purei9_entities = [
ApplianceSensor(
name="Dustbin Status",
attr="dustbinStatus",
device_class=SensorDeviceClass.ENUM,
),
ApplianceVacuum(
name="Vacuum",
attr="robotStatus",
),
]

common_entities = [
ApplianceFan(
name="Fan Speed",
Expand Down Expand Up @@ -266,7 +287,13 @@ def _create_entities(data):
ApplianceBinary(name="Safety Lock", attr="SafetyLock", device_class=BinarySensorDeviceClass.LOCK),
]

return common_entities + a9_entities + a7_entities + pure500_entities
return (
common_entities
+ a9_entities
+ a7_entities
+ pure500_entities
+ purei9_entities
)

def get_entity(self, entity_type, entity_attr):
return next(
Expand All @@ -284,7 +311,12 @@ def set_mode(self, mode: WorkMode):

def setup(self, data, capabilities):
self.firmware = data.get("FrmVer_NIU")
self.mode = WorkMode(data.get("Workmode"))
if "Workmode" in data:
self.mode = WorkMode(data.get("Workmode"))
if "powerMode" in data:
self.power_mode = data.get("powerMode")
if "batteryStatus" in data:
self.battery_status = data.get("batteryStatus")

self.capabilities = capabilities
self.entities = [entity.setup(data) for entity in Appliance._create_entities(data) if entity.attr in data]
Expand Down Expand Up @@ -326,6 +358,23 @@ def speed_range(self) -> tuple[int, int]:

return 0, 0

@property
def battery_range(self) -> tuple[int, int]:
if self.model == Model.PUREi9:
return 2, 6 # Do not include lowest value of 1 to make this mean empty (0%) battery

return 0, 0

@property
def vacuum_fan_speeds(self) -> dict[int, str]:
if self.model == Model.PUREi9:
return {
1: "Quiet",
2: "Smart",
3: "Power",
}
return {}


class Appliances:
def __init__(self, appliances) -> None:
Expand Down Expand Up @@ -359,7 +408,10 @@ async def async_get_appliances(self) -> Appliances:
_LOGGER.debug(f"Appliance initial: {appliance.initial_data}")
_LOGGER.debug(f"Appliance state: {appliance.state}")

if appliance.device_type != "AIR_PURIFIER":
if (
appliance.device_type != "AIR_PURIFIER"
and appliance.device_type != "ROBOTIC_VACUUM_CLEANER"
):
continue

app = Appliance(appliance_name, appliance_id, model_name)
Expand All @@ -370,12 +422,35 @@ async def async_get_appliances(self) -> Appliances:
data = appliance.state
data["status"] = appliance.state_data.get("status", "unknown")
data["connectionState"] = appliance.state_data.get("connectionState", "unknown")

app.setup(data, appliance.capabilities_data)

found_appliances[app.pnc_id] = app

return Appliances(found_appliances)

async def command_vacuum(self, pnc_id: str, cmd: str):
data = {"CleaningCommand": cmd}
appliance = self._api_appliances.get(pnc_id, None)
if appliance is None:
_LOGGER.error(
f"Failed to send vacuum command for appliance with id {pnc_id}"
)
return
result = await appliance.send_command(data)
_LOGGER.debug(f"Vacuum command: {result}")

async def set_vacuum_power_mode(self, pnc_id: str, mode: int):
data = {
"powerMode": mode
} # Not the right formatting. Disable FAN_SPEEDS until this is figured out
appliance = self._api_appliances.get(pnc_id, None)
if appliance is None:
_LOGGER.error(f"Failed to set feature {feature} for appliance with id {pnc_id}")
return
result = await appliance.send_command(data)
_LOGGER.debug(f"Set Vacuum Power Mode: {result}")

async def set_fan_speed(self, pnc_id: str, level: int):
data = {"Fanspeed": level}
appliance = self._api_appliances.get(pnc_id, None)
Expand Down
151 changes: 151 additions & 0 deletions custom_components/wellbeing/vacuum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Vacuum platform for Wellbeing."""

import asyncio
import logging
import math

from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
from homeassistant.components.vacuum import (
STATE_CLEANING,
STATE_PAUSED,
STATE_RETURNING,
STATE_IDLE,
STATE_DOCKED,
STATE_ERROR,
)
from homeassistant.const import Platform
from homeassistant.util.percentage import ranged_value_to_percentage

from . import WellbeingDataUpdateCoordinator
from .const import DOMAIN
from .entity import WellbeingEntity

_LOGGER: logging.Logger = logging.getLogger(__package__)

SUPPORTED_FEATURES = (
VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.BATTERY
)

VACUUM_STATES = {
1: STATE_CLEANING, # Regular Cleaning
2: STATE_PAUSED,
3: STATE_CLEANING, # Stop cleaning
4: STATE_PAUSED, # Pause Spot cleaning
5: STATE_RETURNING,
6: STATE_PAUSED, # Paused returning
7: STATE_RETURNING, # Returning for pitstop
8: STATE_PAUSED, # Paused returning for pitstop
9: STATE_DOCKED, # Charging
10: STATE_IDLE,
11: STATE_ERROR,
12: STATE_DOCKED, # Pitstop
13: STATE_IDLE, # Manual stearing
14: STATE_IDLE, # Firmware upgrading
}

VACUUM_CHARGING_STATE = 9 # For selecting battery icon


async def async_setup_entry(hass, entry, async_add_devices):
"""Setup vacuum platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
appliances = coordinator.data.get("appliances", None)

if appliances is not None:
for pnc_id, appliance in appliances.appliances.items():
async_add_devices(
[
WellbeingVacuum(
coordinator, entry, pnc_id, entity.entity_type, entity.attr
)
for entity in appliance.entities
if entity.entity_type == Platform.VACUUM
]
)


class WellbeingVacuum(WellbeingEntity, StateVacuumEntity):
"""wellbeing Sensor class."""

def __init__(
self,
coordinator: WellbeingDataUpdateCoordinator,
config_entry,
pnc_id,
entity_type,
entity_attr,
):
super().__init__(coordinator, config_entry, pnc_id, entity_type, entity_attr)
self._fan_speeds = self.get_appliance.vacuum_fan_speeds

@property
def _battery_range(self) -> tuple[int, int]:
return self.get_appliance.battery_range

@property
def supported_features(self) -> int:
return SUPPORTED_FEATURES

@property
def state(self):
"""Return the state of the vacuum."""
return VACUUM_STATES.get(self.get_entity.state, STATE_ERROR)

@property
def battery_level(self):
"""Return the battery level of the vacuum."""
return ranged_value_to_percentage(self._battery_range, self.get_appliance.battery_status)

@property
def battery_icon(self):
"""Return the battery icon of the vacuum based on the battery level."""
level = self.battery_level
charging = self.get_entity.state == VACUUM_CHARGING_STATE
level = 10*round(level / 10) # Round level to nearest 10 for icon selection
# Special cases given available icons
if level == 100 and charging:
return "mdi:battery-charging-100"
if level == 100 and not charging:
return "mdi:battery"
if level == 0 and charging:
return "mdi:battery-charging-outline"
if level == 0 and not charging:
return "mdi:battery-alert-variant-outline"
# General case
if level > 0 and level < 100:
return "mdi:battery-" + ("charging-" if charging else "") + f"{level}"
else:
return "mdi:battery-unknown"

@property
def fan_speed(self):
"""Return the fan speed of the vacuum cleaner."""
return self._fan_speeds.get(self.get_appliance.power_mode, "Unknown")

@property
def fan_speed_list(self):
"""Get the list of available fan speed steps of the vacuum cleaner."""
return list(self._fan_speeds.values())

async def async_start(self):
await self.api.command_vacuum(self.pnc_id, "play")

async def async_stop(self):
await self.api.command_vacuum(self.pnc_id, "stop")

async def async_pause(self):
await self.api.command_vacuum(self.pnc_id, "pause")

async def async_return_to_base(self):
await self.api.command_vacuum(self.pnc_id, "home")

async def async_set_fan_speed(self, fan_speed: str):
"""Set the fan speed of the vacuum cleaner."""
for mode, name in FAN_SPEEDS.items():
if name == fan_speed:
await self.api.set_vacuum_power_mode(self.pnc_id, mode)
break

0 comments on commit 5434f30

Please sign in to comment.