Skip to content

Commit

Permalink
Merge pull request #270 from natekspencer/new-lr4-fields
Browse files Browse the repository at this point in the history
Add additional Litter-Robot 4 fields
  • Loading branch information
natekspencer authored Jan 17, 2025
2 parents 93d1423 + 7430f70 commit 074bbc7
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 14 deletions.
143 changes: 131 additions & 12 deletions pylitterbot/robot/litterrobot4.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from ..activity import Activity, Insight
from ..enums import LitterBoxStatus, LitterRobot4Command
from ..exceptions import InvalidCommandException, LitterRobotException
from ..utils import encode, to_timestamp, utcnow
from ..utils import encode, to_enum, to_timestamp, utcnow
from .litterrobot import LitterRobot
from .models import LITTER_ROBOT_4_MODEL

Expand Down Expand Up @@ -72,15 +72,96 @@ class BrightnessLevel(IntEnum):
NightLightLevel = BrightnessLevel


@unique
class FirmwareUpdateStatus(Enum):
"""Firmware update status."""

NONE = "NONE"
TRIGGERED = "TRIGGERED"
PICTRIGGERED = "PICTRIGGERED"
LASERBOARDTRIGGERED = "LASERBOARDTRIGGERED"
ESPTRIGGERED = "ESPTRIGGERED"
STARTED = "STARTED"
IN_PROGRESS = "IN_PROGRESS"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELED = "CANCELED"
DELETED = "DELETED"
REJECTED = "REJECTED"
TIMED_OUT = "TIMED_OUT"
REMOVED = "REMOVED"
COMPLETED = "COMPLETED"
CANCELLATION_IN_PROGRESS = "CANCELLATION_IN_PROGRESS"
DELETION_IN_PROGRESS = "DELETION_IN_PROGRESS"


@unique
class HopperStatus(Enum):
"""Hopper status."""

ENABLED = "ENABLED"
DISABLED = "DISABLED"
MOTOR_FAULT_SHORT = "MOTOR_FAULT_SHORT"
MOTOR_OT_AMPS = "MOTOR_OT_AMPS"
MOTOR_DISCONNECTED = "MOTOR_DISCONNECTED"
EMPTY = "EMPTY"


@unique
class LitterLevelState(Enum):
"""Litter level state."""

OVERFILL = "OVERFILL"
OPTIMAL = "OPTIMAL"
REFILL = "REFILL"
LOW = "LOW"
EMPTY = "EMPTY"


@unique
class NightLightMode(Enum):
"""Night light mode of a Litter-Robot 4 unit."""

ON = "ON"
OFF = "OFF"
ON = "ON"
AUTO = "AUTO"


@unique
class SurfaceType(Enum):
"""Surface type."""

TILE = "TILE"
CARPET = "CARPET"
UNKNOWN = "UNKNOWN"


@unique
class UsbFaultStatus(Enum):
"""USB fault status."""

NONE = "NONE"
CLEAR = "CLEAR"
SET = "SET"


@unique
class WifiModeStatus(Enum):
"""Wi-Fi mode status."""

NONE = "NONE"
OFF = "OFF"
OFF_WAITING = "OFF_WAITING"
OFF_CONNECTED = "OFF_CONNECTED"
OFF_FAULT = "OFF_FAULT"
HOTSPOT_WAITING = "HOTSPOT_WAITING"
HOTSPOT_CONNECTED = "HOTSPOT_CONNECTED"
HOTSPOT_FAULT = "HOTSPOT_FAULT"
ROUTER_WAITING = "ROUTER_WAITING"
ROUTER_CONNECTED = "ROUTER_CONNECTED"
ROUTER_FAULT = "ROUTER_FAULT"


class LitterRobot4(LitterRobot): # pylint: disable=abstract-method
"""Data and methods for interacting with a Litter-Robot 4 automatic, self-cleaning litter box."""

Expand Down Expand Up @@ -140,11 +221,21 @@ def firmware_update_triggered(self) -> bool:
"""Return `True` if a firmware update has been triggered."""
return self._data.get("isFirmwareUpdateTriggered") is True

@property
def hopper_status(self) -> HopperStatus | None:
"""Return the hopper status."""
return to_enum(self._data.get("hopperStatus"), HopperStatus)

@property
def is_drawer_full_indicator_triggered(self) -> bool:
"""Return `True` if the drawer full indicator has been triggered."""
return self._data.get("isDFIFull") is True

@property
def is_hopper_removed(self) -> bool | None:
"""Return `True` if the hopper is removed."""
return self._data.get("isHopperRemoved") is True

@property
def is_online(self) -> bool:
"""Return `True` if the robot is online."""
Expand All @@ -162,7 +253,12 @@ def is_waste_drawer_full(self) -> bool:

@property
def litter_level(self) -> float:
"""Return the litter level.
"""Return the litter level."""
return cast(float, self._data.get("litterLevelPercentage", 0)) * 100

@property
def litter_level_calculated(self) -> float:
"""Return the calculated litter level.
The litterLevel field from the API is a millimeter distance to the
top center time of flight (ToF) sensor and is interpreted as:
Expand All @@ -184,25 +280,25 @@ def litter_level(self) -> float:
self._litter_level = new_level
return max(round(100 - (self._litter_level - 440) / 0.6, -1), 0)

@property
def litter_level_state(self) -> LitterLevelState | None:
"""Return the litter level state."""
return to_enum(self._data.get("litterLevelState"), LitterLevelState)

@property
def night_light_brightness(self) -> int:
"""Return the night light brightness."""
return int(self._data.get("nightLightBrightness", 0))

@property
def night_light_level(self) -> BrightnessLevel | None:
"""Return the night light brightness."""
if (brightness := self.night_light_brightness) in list(BrightnessLevel):
return BrightnessLevel(brightness)
return None
"""Return the night light level."""
return to_enum(self.night_light_brightness, BrightnessLevel, False)

@property
def night_light_mode(self) -> NightLightMode | None:
"""Return the night light mode setting."""
mode = self._data.get("nightLightMode", None)
if mode in (mode.value for mode in NightLightMode):
return NightLightMode(mode)
return None
return to_enum(self._data.get("nightLightMode"), NightLightMode)

@property
def night_light_mode_enabled(self) -> bool:
Expand All @@ -227,6 +323,11 @@ def pet_weight(self) -> float:
"""Return the last recorded pet weight in pounds (lbs)."""
return cast(float, self._data.get("catWeight", 0))

@property
def scoops_saved_count(self) -> int:
"""Return the scoops saved count."""
return cast(int, self._data.get("scoopsSavedCount", 0))

@property
def sleep_mode_enabled(self) -> bool:
"""Return `True` if sleep mode is enabled."""
Expand Down Expand Up @@ -277,11 +378,26 @@ def status_code(self) -> str | None:
else self._data.get("robotStatus")
)

@property
def surface_type(self) -> SurfaceType | None:
"""Return the surface type."""
return to_enum(self._data.get("surfaceType"), SurfaceType)

@property
def usb_fault_status(self) -> UsbFaultStatus | None:
"""Return the USB fault status."""
return to_enum(self._data.get("USBFaultStatus"), UsbFaultStatus)

@property
def waste_drawer_level(self) -> float:
"""Return the approximate waste drawer level."""
return cast(float, self._data.get("DFILevelPercent", 0))

@property
def wifi_mode_status(self) -> WifiModeStatus | None:
"""Return the Wi-Fi mode status."""
return to_enum(self._data.get("wifiModeStatus"), WifiModeStatus)

def _revalidate_sleep_info(self) -> None:
"""Revalidate sleep info."""
if (
Expand Down Expand Up @@ -374,7 +490,10 @@ async def refresh(self) -> None:
self._update_data(data.get("data", {}).get("getLitterRobot4BySerial", {}))

async def reset(self) -> bool:
"""Perform a reset on the Litter-Robot."""
"""Perform a reset on the Litter-Robot.
Clears errors and may trigger a cycle. Make sure the globe is clear before proceeding.
"""
return await self._dispatch_command(LitterRobot4Command.SHORT_RESET_PRESS)

async def set_name(self, name: str) -> bool:
Expand Down
4 changes: 4 additions & 0 deletions pylitterbot/robot/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,9 @@
surfaceType
hopperStatus
scoopsSavedCount
isHopperRemoved
optimalLitterLevel
litterLevelPercentage
litterLevelState
}}
"""
18 changes: 17 additions & 1 deletion pylitterbot/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from base64 import b64decode, b64encode
from collections.abc import Iterable, Mapping
from datetime import datetime, time, timezone
from typing import Any, TypeVar, cast, overload
from enum import Enum
from typing import Any, Type, TypeVar, cast, overload
from urllib.parse import urljoin as _urljoin
from warnings import warn

_LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T")
_E = TypeVar("_E", bound=Enum)

ENCODING = "utf-8"
REDACTED = "**REDACTED**"
Expand Down Expand Up @@ -151,3 +153,17 @@ def first_value(
if key in data and ((value := data[key]) is not None or return_none):
return value
return default


def to_enum(value: Any, typ: Type[_E], log_warning: bool = True) -> _E | None:
"""Get the corresponding enum member from a value."""
if value is None:
return None
try:
return typ(value)
except ValueError:
if log_warning:
logging.warning("Value '%s' not found in enum %s", value, typ.__name__)
except (AttributeError, TypeError):
logging.error("Provided class %s is not a valid Enum", typ)
return None
12 changes: 12 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@
"isOnboarded": True,
"lastSeen": "2022-07-20T00:13:00.000000Z",
"setupDateTime": "2022-07-16T21:40:00.000000Z",
"wifiModeStatus": "ROUTER_CONNECTED",
"isUSBPowerOn": True,
"USBFaultStatus": "CLEAR",
"isDFIPartialFull": False,
"isLaserDirty": False,
"surfaceType": "TILE",
"hopperStatus": None,
"scoopsSavedCount": 3769,
"isHopperRemoved": None,
"optimalLitterLevel": 450,
"litterLevelPercentage": 0.4,
"litterLevelState": "OPTIMAL",
}

FEEDER_ROBOT_DATA: dict[str, Any] = {
Expand Down
7 changes: 6 additions & 1 deletion tests/test_litterrobot4.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,27 +356,32 @@ async def test_litter_robot_4_cleaning(mock_account: Account) -> None:
robot = LitterRobot4(data=LITTER_ROBOT_4_DATA, account=mock_account)
assert robot.status == LitterBoxStatus.READY
assert robot.litter_level == 40
assert robot.litter_level_calculated == 40

# simulate update to cleaning
robot._update_data({"robotStatus": "ROBOT_CLEAN"}, partial=True)
status = LitterBoxStatus.CLEAN_CYCLE
assert robot.status == status
assert robot.status_code == "CCP"
assert robot.litter_level == 40
assert robot.litter_level_calculated == 40

# simulate litter level read mid-cycle
robot._update_data({"litterLevel": LITTER_LEVEL_EMPTY}, partial=True)
assert robot.litter_level == 40
assert robot.litter_level_calculated == 40

# simulate stopped cleaning
robot._update_data({"robotStatus": "ROBOT_IDLE"}, partial=True)
assert robot.status == LitterBoxStatus.READY
assert robot.litter_level == 40
assert robot.litter_level_calculated == 40

# simulate litter level read after clean
robot._update_data({"litterLevel": 481}, partial=True)
robot._update_data({"litterLevel": 481, "litterLevelPercentage": 0.3}, partial=True)
assert robot.status == LitterBoxStatus.READY
assert robot.litter_level == 30
assert robot.litter_level_calculated == 30


@pytest.mark.parametrize(
Expand Down

0 comments on commit 074bbc7

Please sign in to comment.