8000 Add Roomba J9 compatibility by rokam · Pull Request #145913 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Add Roomba J9 compatibility #145913

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
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
15 changes: 15 additions & 0 deletions homeassistant/components/roomba/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ def battery_stats(self):
"""Return the battery stats."""
return self.vacuum_state.get("bbchg3", {})

@property
def tank_level(self) -> int | None:
"""Return the tank level."""
return self.vacuum_state.get("tankLvl")

@property
def has_dock(self) -> bool:
"""Return True if the vacuum cleaner has a dock."""
return len(self.vacuum_state.get("dock", {})) > 0

@property
def dock_tank_level(self) -> int | None:
"""Return the dock tank level."""
return self.vacuum_state.get("dock", {}).get("tankLvl")

@property
def last_mission(self):
"""Return last mission start time."""
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/roomba/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
},
"last_mission": {
"default": "mdi:calendar-clock"
},
"tank_level": {
"default": "mdi:water"
},
"dock_tank_level": {
"default": "mdi:water"
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions homeassistant/components/roomba/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class RoombaSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[IRobotEntity], StateType]


DOCK_SENSORS: list[RoombaSensorEntityDescription] = [
RoombaSensorEntityDescription(
key="dock_tank_level",
translation_key="dock_tank_level",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: self.dock_tank_level,
),
]

SENSORS: list[RoombaSensorEntityDescription] = [
RoombaSensorEntityDescription(
key="battery",
Expand All @@ -37,6 +47,13 @@ class RoombaSensorEntityDescription(SensorEntityDescription):
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: self.battery_level,
),
RoombaSensorEntityDescription(
key="tank_level",
translation_key="tank_level",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: self.tank_level,
),
RoombaSensorEntityDescription(
key="battery_cycles",
translation_key="battery_cycles",
Expand Down Expand Up @@ -136,6 +153,15 @@ async def async_setup_entry(
RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS
)

dock_entities: list[RoombaSensor] = []
for entity_description in DOCK_SENSORS:
entity = RoombaSensor(roomba, blid, entity_description)
if entity.has_dock:
dock_entities.append(entity)
Comment on lines +157 to +160
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move the has_dock out of the entity, because it will be the same for every entity, and we could move that if statement to before the for loop

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I agree, but can you point me in the right direction to do it? I don't know where to put that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have access to the vacuum object at this point, so I would assume that we can just read the state from there


if len(dock_entities) > 0:
async_add_entities(dock_entities)
Comment on lines 153 to +163
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should call async_add_entities only once, so let's put everything in a list and then add it



class RoombaSensor(IRobotEntity, SensorEntity):
"""Roomba sensor."""
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/roomba/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@
},
"last_mission": {
"name": "Last mission start time"
},
"tank_level": {
"name": "Tank level"
},
"dock_tank_level": {
"name": "Dock tank level"
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/roomba/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,16 @@ def extra_state_attributes(self) -> dict[str, Any]:
detected_pad = state.get("detectedPad")
mop_ready = state.get("mopReady", {})
lid_closed = mop_ready.get("lidClosed")
tank_present = mop_ready.get("tankPresent")
tank_present = mop_ready.get("tankPresent") or state.get("tankPresent")
tank_level = state.get("tankLvl")
state_attrs[ATTR_DETECTED_PAD] = detected_pad
state_attrs[ATTR_LID_CLOSED] = lid_closed
state_attrs[ATTR_TANK_PRESENT] = tank_present
state_attrs[ATTR_TANK_LEVEL] = tank_level
bin_raw_state = state.get("bin", {})
if bin_raw_state.get("present") is not None:
state_attrs[ATTR_BIN_PRESENT] = bin_raw_state.get("present")
if bin_raw_state.get("full") is not None:
state_attrs[ATTR_BIN_FULL] = bin_raw_state.get("full")

return state_attrs
106 changes: 106 additions & 0 deletions tests/components/roomba/test_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Tests for iRobotEntity properties in the Roomba integration."""

from typing import Any
from unittest.mock import MagicMock

import pytest
from roombapy import Roomba

from homeassistant.components.roomba.entity import IRobotEntity


@pytest.fixture
def mock_roomba_state() -> dict[str, Any]:
"""Fixture to provide a mock Roomba state."""
return {
"tankLvl": 42,
"dock": {"tankLvl": 99},
"hwPartsRev": {"navSerialNo": "12345", "wlan0HwAddr": "AA:BB:CC:DD:EE:FF"},
"sku": "980",
"name": "Test Roomba",
"softwareVer": "3.2.1",
"hardwareRev": "1.0",
}


@pytest.fixture
def mock_roomba(mock_roomba_state: dict[str, Any]) -> Roomba:
"""Fixture to create a mock Roomba instance."""
roomba = MagicMock()
roomba.master_state = {"state": {"reported": mock_roomba_state}}
return roomba


class DummyEntity(IRobotEntity):
"""Dummy Roomba entity for testing purposes."""

def on_message(self, json_data: dict[str, Any]) -> None:
"""Handle incoming messages."""


def test_tank_level_property(mock_roomba: Roomba) -> None:
"""Test the tank level property of the IRobotEntity."""
entity = DummyEntity(mock_roomba, "blid123")
assert entity.tank_level == 42


def test_has_dock_property(mock_roomba: Roomba) -> None:
"""Test the has_dock property of the IRobotEntity."""
entity = DummyEntity(mock_roomba, "blid123")
assert entity.has_dock is True


def test_dock_tank_level_property(mock_roomba: Roomba) -> None:
"""Test the dock tank level property of the IRobotEntity."""
entity = DummyEntity(mock_roomba, "blid123")
assert entity.dock_tank_level == 99


def test_has_dock_property_false() -> None:
"""Test has_dock property returns False when dock is empty."""
mock_state = {
"tankLvl": 42,
"dock": {},
"hwPartsRev": {"navSerialNo": "12345", "wlan0HwAddr": "AA:BB:CC:DD:EE:FF"},
"sku": "980",
"name": "Test Roomba",
"softwareVer": "3.2.1",
"hardwareRev": "1.0",
}
roomba = MagicMock()
roomba.master_state = {"state": {"reported": mock_state}}
entity = DummyEntity(roomba, "blid123")
assert entity.has_dock is False


def test_tank_level_none() -> None:
"""Test tank_level property returns None if not present."""
mock_state = {
"dock": {"tankLvl": 99},
"hwPartsRev": {"navSerialNo": "12345", "wlan0HwAddr": "AA:BB:CC:DD:EE:FF"},
"sku": "980",
"name": "Test Roomba",
"softwareVer": "3.2.1",
"hardwareRev": "1.0",
}
roomba = MagicMock()
roomba.master_state = {"state": {"reported": mock_state}}
entity = DummyEntity(roomba, "blid123")
assert entity.tank_level is None


def test_dock_tank_level_none() -> None:
"""Test dock_tank_level property returns None if not present."""
mock_state = {
"tankLvl": 42,
"dock": {},
"hwPartsRev": {"navSerialNo": "12345", "wlan0HwAddr": "AA:BB:CC:DD:EE:FF"},
"sku": "980",
"name": "Test Roomba",
"softwareVer": "3.2.1",
"hardwareRev": "1.0",
}
roomba = MagicMock()
roomba.master_state = {"state": {"reported": mock_state}}
entity = DummyEntity(roomba, "blid123")
assert entity.dock_tank_level is None
85 changes: 85 additions & 0 deletions tests/components/roomba/test_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Tests for IRobotEntity usage in Roomba sensor platform."""

from collections.abc import Iterable
from typing import Any
from unittest.mock import MagicMock

import pytest
from roombapy import Roomba

from homeassistant.components.roomba.models import RoombaData
from homeassistant.components.roomba.sensor import async_setup_entry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity


@pytest.fixture
def mock_roomba_state() -> dict[str, Any]:
"""Fixture to provide a mock Roomba state."""
return {
"cap": {"pose": 1},
"cleanMissionStatus": {"cycle": "none", "phase": "charge"},
"softwareVer": "3.2.1",
"pose": {"point": {"x": 1, "y": 2}, "theta": 90},
"hwPartsRev": {"navSerialNo": "12345", "wlan0HwAddr": "AA:BB:CC:DD:EE:FF"},
"sku": "980",
"name": "Test Roomba",
"hardwareRev": "1.0",
"bin": {"present": True, "full": False},
}


@pytest.fixture
def mock_roomba(mock_roomba_state: dict[str, Any]) -> Roomba:
"""Fixture to create a mock Roomba vacuum instance."""
roomba = MagicMock()
roomba.send_command = MagicMock()
roomba.error_code = 0
roomba.error_message = None
roomba.current_state = "run"
roomba.set_preference = MagicMock()
roomba.register_on_message_callback = MagicMock()
roomba.master_state = {"state": {"reported": mock_roomba_state}}
return roomba


@pytest.mark.asyncio
@pytest.mark.parametrize(
("state", "expected_sensors"),
[
(
{},
12,
),
({"dock": {}}, 12),
({"dock": {"tankLvl": 10}}, 13),
],
)
async def test_async_setup_entry_selects_correct_class(
mock_roomba: Roomba,
state: dict[str, Any],
expected_sensors: int,
) -> None:
"""Test async_setup_entry selects the correct amount of sensors based on state."""
# Setup mocks
hass = MagicMock(spec=HomeAssistant)
config_entry = MagicMock(spec=ConfigEntry)
config_entry.entry_id = "test_entry"
master_state = {"state": {"reported": state}}
mock_roomba.master_state.update(master_state)
blid = "blid123"
hass.data = {"roomba": {"test_entry": RoombaData(roomba=mock_roomba, blid=blid)}}

added_entities: list[Entity] = []

def async_add_entities(
new_entities: Iterable[Entity],
update_before_add: bool = False,
*,
config_subentry_id: str | None = None,
) -> None:
added_entities.extend(list(new_entities))

await async_setup_entry(hass, config_entry, async_add_entities)
assert len(added_entities) == expected_sensors
Loading
0