make better

This commit is contained in:
ia74 2025-07-21 17:44:58 -05:00
parent e77e353890
commit 74b3b6e478
10 changed files with 527 additions and 83 deletions

View file

@ -1,16 +1,26 @@
# roomba_rest980 # roomba_rest980
Drop-in native integration/replacement for [jerrywillans/ha-rest980-roomba](https://github.com/jeremywillans/ha-rest980-roomba). Drop-in native integration/replacement for [jeremywillans/ha-rest980-roomba](https://github.com/jeremywillans/ha-rest980-roomba).
Still work in progress, but the vacuum entity has been fully ported over. Still work in progress, but the vacuum entity has been fully ported over.
## Roadmap ## Roadmap
- [x] Feature parity (minus actions) with vacuum entity - [x] Attribute parity with jeremywillans' YAML entry
- [x] Cloud API connection - [x] Cloud API connection
- [ ] Cloud MQTT connection - [ ] Cloud MQTT connection
- [ ] Actions - [x] Actions
- [ ] Dynamically grab rooms and add them to the UI - [x] Start
- [ ] Clean all rooms by default
- [x] Selective room cleaning
- [ ] Two pass feature
- [x] Pause
- [ ] Unpause (Requires further testing)
- [x] Return Home
- [x] Stop
- [x] Spot Clean
- [ ] Mapping Run
- [x] Dynamically grab rooms and add them to the UI
- [x] Grab room data (optional, cloud only) - [x] Grab room data (optional, cloud only)
- [ ] Create map image - [ ] Create map image
@ -162,14 +172,24 @@ If all has gone right, checking the device will show something like this:
![Added the config!](img/fin.png) ![Added the config!](img/fin.png)
## Step 4: Rooms! (Cloud) ## Step 3.5: Cloud issues.. (Cloud)
> As of current, this text is a placeholder for when this feature actually gets added. Still work in progress, but when this banner is removed, the below will be true. iRobot does some quite interesting things with their API. As of current, my implementation does not use the cloud MQTT server (yet). However, even with the iRobot app and every instance of a connection closed, you may get ratelimited (*sometimes*) with the error "No mqtt slot available". There isn't a workaround for this that I know of, except to restart HA/reload the robot's config entry in ~5 minutes.
## Step 4: Rooms! (Cloud)
Your rooms will be auto-imported, alongside a clean map view, much like the one from the app. Your rooms will be auto-imported, alongside a clean map view, much like the one from the app.
This allows you to selectively clean rooms, and control it by automation (tutorial later). This allows you to selectively clean rooms, and control it by automation (tutorial later).
Rooms you select will be cleaned in the order you select. Two-pass functionailty coming soon as well. Rooms you select will be cleaned in the order you select. Two-pass functionailty coming soon as well.
![Added the config!](img/rooms.png)
Room types and names are also dynamically imported as to be expected.
To work with this, switch the "Clean (room)" switches on in the order you like, then press the Clean button from the vacuum's entity!
![Room selection](img/clean.png)
## Step 4: Rooms! (rest980 ONLY) ## Step 4: Rooms! (rest980 ONLY)
TO BE CONTINUED... TO BE CONTINUED...
@ -177,7 +197,7 @@ Rooms are not given to us easily when we're fully local, but a fix is in progres
## Important Note ## Important Note
From this part on, the guide will not diverge unless required and will assume you are using Cloud features, but most of it should be generically implemented. From this part on, the guide will not diverge into Cloud/Local unless required and will assume you are using Cloud features, but most of it should be generically implemented.
## Step 5: Robot Maintenance / Done! ## Step 5: Robot Maintenance / Done!
@ -185,18 +205,22 @@ From this part on, the guide will not diverge unless required and will assume yo
This integration will eventually support the maintenance function of the Roomba, but still is not implemented This integration will eventually support the maintenance function of the Roomba, but still is not implemented
## Cleaning a room using the Roomba from HA
> Unfortunately, this is not implemented yet, alongside any other action.
In any configuration you'd like, you may lay the switches on the dashboard and switch them in the order you want them cleaned. After that, press Start on the native Vacuum!
## Note:
Unfortunately, this is about where my current progress ends. We gather all the possible data and display it. I will be working on this integration however and eventually these features will be fully supported.
## Backwards Compatibility ## Backwards Compatibility
Minus the actions (currently!), the integration adds all the attributes that you would expect from `jeremywillans` implementation, even adding an `extendedState` attribute that gives you "Ready", "Training", "Spot", etc. since HA doesnt do that natively for some odd reason. The integration adds all the attributes that you would expect from [jeremywillans implementation](https://github.com/jeremywillans/ha-rest980-roomba), making it compatible with [the lovelace-roomba-vacuum-card](https://github.com/jeremywillans/lovelace-roomba-vacuum-card).
![Compatibility](img/compat.png) ![Compatibility](img/compat.png)
One minor issue is that the Vacuum entity only supports these states:
```
Cleaning: The vacuum is currently cleaning.
Docked: The vacuum is currently docked. It is assumed that docked can also mean charging.
Error: The vacuum encountered an error while cleaning.
Idle: The vacuum is not paused, not docked, and does not have any errors.
Paused: The vacuum was cleaning but was paused without returning to the dock.
Returning: The vacuum is done cleaning and is currently returning to the dock, but not yet docked.
Unavailable: The entity is currently unavailable.
Unknown: The state is not yet known.
```
even adding an `extendedState` attribute that gives you "Ready", "Training", "Spot", etc. since HA doesnt do that natively for some odd reason.

View file

@ -404,6 +404,10 @@ class iRobotCloudApi:
async with self.session.get(final_url, headers=signed_headers) as response: async with self.session.get(final_url, headers=signed_headers) as response:
if response.status != 200: if response.status != 200:
if response.status == 403:
await self.authenticate()
_LOGGER.info("Reauthenticating API")
return await self._aws_request(url, params)
raise CloudApiError(f"AWS request failed: {response.status}") raise CloudApiError(f"AWS request failed: {response.status}")
return await response.json() return await response.json()
@ -460,10 +464,16 @@ class iRobotCloudApi:
return robot_data return robot_data
retry_count = 0
async def get_all_robots_data(self) -> dict[str, dict[str, Any]]: async def get_all_robots_data(self) -> dict[str, dict[str, Any]]:
"""Get data for all authenticated robots.""" """Get data for all authenticated robots."""
if not self.robots: if not self.robots:
if self.retry_count == 3:
raise CloudApiError("No robots found. Authenticate first.") raise CloudApiError("No robots found. Authenticate first.")
self.retry_count += 1
await self.authenticate()
return await self.get_all_robots_data()
all_data = {} all_data = {}
for blid in self.robots: for blid in self.robots:
@ -475,3 +485,115 @@ class iRobotCloudApi:
all_data[blid] = {"error": str(e)} all_data[blid] = {"error": str(e)}
return all_data return all_data
"""
active_pmapv_details:
active_pmapv:
pmap_id: BGQxV6zGTmCsalWFHr-S5g
pmapv_id: 250720T215523
create_time: 1753048538
proc_state: OK_Processed
creator: robot
nMssn: 1182
mission_id: 01K0MC4XWG0DKT67MCSGGG4924
learning_percentage: 100
last_user_pmapv_id: 250718T074805
last_user_ts: 1752824885
shareability: 1
robot_cap:
maps: 3
pmaps: 10
robot_id: B61489C9D5104793AFEA1F26C91B61DF
map_header:
id: BGQxV6zGTmCsalWFHr-S5g
version: 250720T215523
name: Main Floor
learning_percentage: 100
create_time: 1753048538
resolution: 0.105
user_orientation_rad: 1.5634
robot_orientation_rad: 3.1457
area: 38.1418
nmssn: 1182
mission_id: 01K0MC4XWG0DKT67MCSGGG4924
regions:
- id: '11'
name: Kitchen
region_type: kitchen
policies:
odoa_mode: 0
odoa_feats: {}
disabled_operating_modes: 0
override_operating_modes: 0
time_estimates:
- unit: seconds
estimate: 420
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: true
- unit: seconds
estimate: 210
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: false
- id: '15'
name: ''
region_type: unspecified
policies:
odoa_mode: 0
odoa_feats: {}
disabled_operating_modes: 0
override_operating_modes: 0
time_estimates:
- unit: seconds
estimate: 458
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: true
- unit: seconds
estimate: 229
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: false
- id: '10'
name: Hallway
region_type: hallway
policies:
odoa_mode: 0
odoa_feats: {}
disabled_operating_modes: 0
override_operating_modes: 0
time_estimates:
- unit: seconds
estimate: 1282
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: true
- unit: seconds
estimate: 641
confidence: GOOD_CONFIDENCE
params:
noAutoPasses: true
twoPass: false
zones: []
keepoutzones: []
observed_zones:
- id: '1449649421'
extent_type: rug
quality:
confidence: 70
related_objects:
- '1449649421'
- id: '1048295640'
extent_type: rug
quality:
confidence: 70
related_objects:
- '10482956"""

View file

@ -3,10 +3,12 @@
import logging import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoombaDataCoordinator, RoombaCloudCoordinator from .coordinator import RoombaDataCoordinator, RoombaCloudCoordinator
import voluptuous as vol
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -21,34 +23,83 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id + "_coordinator"] = coordinator hass.data[DOMAIN][entry.entry_id + "_coordinator"] = coordinator
hass.data[DOMAIN][entry.entry_id + "_blid"] = "unknown" hass.data[DOMAIN][entry.entry_id + "_blid"] = "unknown"
# Set up cloud coordinator if enabled
if entry.data["cloud_api"]: if entry.data["cloud_api"]:
cc = RoombaCloudCoordinator(hass, entry) cc = RoombaCloudCoordinator(hass, entry)
await cc.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id + "_cloud"] = cc hass.data[DOMAIN][entry.entry_id + "_cloud"] = cc
if "blid" not in entry.data:
for blid, robo in cc.data.items(): # Start background task for cloud setup and BLID matching
try: hass.async_create_task(_async_setup_cloud(hass, entry, coordinator, cc))
ifo = robo["robot_info"] or {}
ifosku = ifo.get("sku")
ifoswv = ifo.get("softwareVer")
ifoname = ifo.get("name")
thisname = coordinator.data.get("name", "Roomba")
thisswv = coordinator.data.get("softwareVer")
thissku = coordinator.data.get("sku")
if ifosku == thissku and ifoswv == thisswv and ifoname == thisname:
hass.data[DOMAIN][entry.entry_id + "_blid"] = blid
except Exception as e:
_LOGGER.debug(e)
else: else:
hass.data[DOMAIN][entry.entry_id + "_cloud"] = {} hass.data[DOMAIN][entry.entry_id + "_cloud"] = {}
# Register services
await _async_register_services(hass)
# Forward platforms; create tasks but await to ensure no failure? # Forward platforms; create tasks but await to ensure no failure?
await hass.config_entries.async_forward_entry_setups( await hass.config_entries.async_forward_entry_setups(entry, ["vacuum", "sensor"])
entry, ["vacuum", "sensor", "switch"]
)
return True return True
async def _async_register_services(hass: HomeAssistant) -> None:
"""Register integration services."""
async def handle_vacuum_clean(call: ServiceCall) -> None:
"""Handle vacuum clean service call."""
try:
payload = call.data["payload"]
base_url = call.data["base_url"]
session = async_get_clientsession(hass)
async with session.post(
f"{base_url}/api/local/action/cleanRoom",
headers={"content-type": "application/json"},
json=payload,
) as response:
if response.status != 200:
_LOGGER.error("Failed to send clean command: %s", response.status)
else:
_LOGGER.debug("Clean command sent successfully")
except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Error sending clean command: %s", e)
async def handle_action(call: ServiceCall) -> None:
"""Handle action service call."""
try:
action = call.data["action"]
base_url = call.data["base_url"]
session = async_get_clientsession(hass)
async with session.get(
f"{base_url}/api/local/action/{action}",
) as response:
if response.status != 200:
_LOGGER.error("Failed to send clean command: %s", response.status)
else:
_LOGGER.debug("Action sent successfully")
except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Error sending clean command: %s", e)
# Register services if not already registered
if not hass.services.has_service(DOMAIN, "rest980_clean"):
hass.services.async_register(
DOMAIN,
"rest980_clean",
handle_vacuum_clean,
vol.Schema({vol.Required("payload"): dict, vol.Required("base_url"): str}),
)
if not hass.services.has_service(DOMAIN, "rest980_action"):
hass.services.async_register(
DOMAIN,
"rest980_action",
handle_action,
vol.Schema({vol.Required("action"): str, vol.Required("base_url"): str}),
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Safely remove Roombas.""" """Safely remove Roombas."""
await hass.config_entries.async_unload_platforms( await hass.config_entries.async_unload_platforms(
@ -56,3 +107,67 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
hass.data[DOMAIN].pop(entry.entry_id + "_coordinator") hass.data[DOMAIN].pop(entry.entry_id + "_coordinator")
return True return True
async def _async_setup_cloud(
hass: HomeAssistant,
entry: ConfigEntry,
coordinator: RoombaDataCoordinator,
cloud_coordinator: RoombaCloudCoordinator,
) -> None:
"""Set up cloud coordinator and perform BLID matching in background."""
try:
# Refresh cloud data
await cloud_coordinator.async_config_entry_first_refresh()
# Perform BLID matching if not already stored
if "blid" not in entry.data:
await _async_match_blid(hass, entry, coordinator, cloud_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, ["switch"])
except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Failed to set up cloud coordinator: %s", e)
# Set empty cloud data so entities can still be created
hass.data[DOMAIN][entry.entry_id + "_cloud"] = {}
async def _async_match_blid(
hass: HomeAssistant,
entry: ConfigEntry,
coordinator: RoombaDataCoordinator,
cloud_coordinator: RoombaCloudCoordinator,
) -> None:
"""Match local Roomba with cloud robot by comparing device info."""
try:
for blid, robo in cloud_coordinator.data.items():
try:
# Get cloud robot info
robot_info = robo.get("robot_info") or {}
cloud_sku = robot_info.get("sku")
cloud_sw_ver = robot_info.get("softwareVer")
cloud_name = robot_info.get("name")
# Get local robot info
local_data = coordinator.data or {}
local_name = local_data.get("name", "Roomba")
local_sw_ver = local_data.get("softwareVer")
local_sku = local_data.get("sku")
# Match robots by SKU, software version, and name
if (
cloud_sku == local_sku
and cloud_sw_ver == local_sw_ver
and cloud_name == local_name
):
hass.data[DOMAIN][entry.entry_id + "_blid"] = blid
_LOGGER.info("Matched local Roomba with cloud robot %s", blid)
break
except (KeyError, TypeError) as e:
_LOGGER.debug("Error matching robot %s: %s", blid, e)
else:
_LOGGER.warning("Could not match local Roomba with any cloud robot")
except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Error during BLID matching: %s", e)

View file

@ -11,8 +11,15 @@ notReadyMappings = {
15: "Low Battery", 15: "Low Battery",
39: "Pending", 39: "Pending",
48: "Path Blocked", 48: "Path Blocked",
68: "Updating Map",
}
errorMappings = {
0: "n-a",
15: "Reboot Required",
18: "Docking Issue",
68: "Updating Map",
} }
errorMappings = {0: "n-a", 15: "Reboot Required", 18: "Docking Issue"}
cycleMappings = { cycleMappings = {
"clean": "Clean", "clean": "Clean",
@ -61,3 +68,41 @@ jobInitiatorMappings = {
"localApp": "HA", "localApp": "HA",
"none": "None", # Added for RoombaJobInitiator "none": "None", # Added for RoombaJobInitiator
} }
regionTypeMappings = {
"default": "mdi:map-marker",
"custom": "mdi:face-agent",
"basement": "mdi:home-floor-b",
"bathroom": "mdi:shower",
"bedroom": "mdi:bed-king",
"breakfast_room": "mdi:silverware-fork-knief",
"closet": "mdi:hanger",
"den": "mdi:sofa-single",
"dining_room": "mdi:silverware-fork-knife",
"entryway": "mdi:door-open",
"family_room": "mdi:sofa-single",
"foyer": "mdi:door-open",
"garage": "mdi:garage",
"guest_bathroom": "mdi:shower",
"guest_bedroom": "mdi:bed-king",
"hallway": "mdi:shoe-print",
"kitchen": "mdi:fridge",
"kids_room": "mdi:teddy-bear",
"laundry_room": "mdi:washing-machine",
"living_room": "mdi:sofa",
"lounge": "mdi:sofa",
"media_room": "mdi:television",
"mud_room": "mdi:landslide",
"office": "mdi:chair-rolling",
"outside": "mdi:asterisk",
"pantry": "mdi:archive",
"playroom": "mdi:teddy-bear",
"primary_bathroom": "mdi:shower",
"primary_bedroom": "mdi:bed-king",
"recreation_room": "mdi:sofa",
"storage_room": "mdi:archive",
"study": "mdi:bookshelf",
"sun_room": "mdi:sun-angle",
"unfinished_basement": "mdi:home-floor-b",
"workshop": "mdi:toolbox",
}

View file

@ -1,5 +1,7 @@
"""Create sensors that poll Roomba's data.""" """Create sensors that poll Roomba's data."""
import logging
from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import PERCENTAGE, UnitOfArea, UnitOfTime from homeassistant.const import PERCENTAGE, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -9,11 +11,37 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN, cleanBaseMappings, jobInitiatorMappings, phaseMappings from .const import DOMAIN, cleanBaseMappings, jobInitiatorMappings, phaseMappings
from .RoombaSensor import RoombaSensor, RoombaCloudSensor from .RoombaSensor import RoombaSensor, RoombaCloudSensor
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities):
"""Create the sensors needed to poll Roomba's data.""" """Create the sensors needed to poll Roomba's data."""
coordinator = hass.data[DOMAIN][entry.entry_id + "_coordinator"] coordinator = hass.data[DOMAIN][entry.entry_id + "_coordinator"]
cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"] cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"]
# Create cloud pmap entities if cloud data is available
cloud_entities = []
if cloudCoordinator and cloudCoordinator.data:
blid = hass.data[DOMAIN][entry.entry_id + "_blid"]
# Get cloud data for the specific robot
if blid in cloudCoordinator.data:
cloud_data = cloudCoordinator.data[blid]
# Create pmap entities from cloud data
if "pmaps" in cloud_data:
for pmap in cloud_data["pmaps"]:
try:
cloud_entities.append(
RoombaCloudPmap(cloudCoordinator, entry, pmap)
)
except (KeyError, TypeError) as e:
_LOGGER.warning(
"Failed to create pmap entity for %s: %s",
pmap.get("pmap_id", "unknown"),
e,
)
if cloud_entities:
async_add_entities(cloud_entities)
async_add_entities( async_add_entities(
[ [
RoombaAttributes(coordinator, entry), RoombaAttributes(coordinator, entry),
@ -116,6 +144,21 @@ class RoombaCloudAttributes(RoombaCloudSensor):
) )
class RoombaCloudPmap(RoombaCloudSensor):
"""Sensor for Roomba persistent map (pmap) data from cloud."""
def __init__(self, coordinator, entry, pmap) -> None:
"""Initialize the pmap sensor with data from cloud API."""
# Handle different pmap data structures
header = pmap["active_pmapv_details"]["map_header"]
pmap_name = header.get("name", "Unknown Map")
pmap_id = header.get("id", "unknown")
self._rs_given_info = (pmap_name, pmap_id)
super().__init__(coordinator, entry)
self._attr_extra_state_attributes = pmap
class RoombaPhase(RoombaSensor): class RoombaPhase(RoombaSensor):
"""A simple sensor that returns the phase of the Roomba.""" """A simple sensor that returns the phase of the Roomba."""

View file

@ -1,11 +1,11 @@
# services.yaml # services.yaml
vacuum_action: action:
fields: fields:
command: command:
required: true required: true
example: "dock" example: "dock"
vacuum_clean: clean:
fields: fields:
payload: payload:
required: true required: true

View file

@ -3,21 +3,37 @@
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
import logging
from .const import DOMAIN, regionTypeMappings
from .const import DOMAIN _LOGGER = logging.getLogger(__name__)
ROOMS = {
"Kitchen": {"region_id": "11", "type": "rid"},
"Living Room": {"region_id": "9", "type": "rid"},
"Dining Room": {"region_id": "1", "type": "rid"},
"Hallway": {"region_id": "10", "type": "rid"},
"Office": {"region_id": "12", "type": "rid"},
}
async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities):
"""Create the switches to identify cleanable rooms.""" """Create the switches to identify cleanable rooms."""
entities = [RoomSwitch(entry, name, data) for name, data in ROOMS.items()] cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"]
entities = []
if cloudCoordinator and cloudCoordinator.data:
blid = hass.data[DOMAIN][entry.entry_id + "_blid"]
# Get cloud data for the specific robot
if blid in cloudCoordinator.data:
cloud_data = cloudCoordinator.data[blid]
# Create pmap entities from cloud data
if "pmaps" in cloud_data:
for pmap in cloud_data["pmaps"]:
try:
for region in pmap["active_pmapv_details"]["regions"]:
entities.append(
RoomSwitch(
entry, region["name"] or "Unnamed Room", region
)
)
except (KeyError, TypeError) as e:
_LOGGER.warning(
"Failed to create pmap entity for %s: %s",
pmap.get("pmap_id", "unknown"),
e,
)
for ent in entities: for ent in entities:
hass.data[DOMAIN][f"switch.{ent.unique_id}"] = ent hass.data[DOMAIN][f"switch.{ent.unique_id}"] = ent
async_add_entities(entities) async_add_entities(entities)
@ -29,15 +45,21 @@ class RoomSwitch(SwitchEntity):
def __init__(self, entry, name, data) -> None: def __init__(self, entry, name, data) -> None:
"""Creates a switch entity for rooms.""" """Creates a switch entity for rooms."""
self._attr_name = f"Clean {name}" self._attr_name = f"Clean {name}"
self._attr_unique_id = f"{entry.entry_id}_{name.lower().replace(' ', '_')}" self._attr_unique_id = f"{entry.entry_id}_{data['id']}"
self._is_on = False self._is_on = False
self._room_json = data self._room_json = {"region_id": data["id"], "type": "rid"}
self._attr_entity_category = EntityCategory.CONFIG self._attr_entity_category = EntityCategory.CONFIG
self._attr_extra_state_attributes = data
self._attr_device_info = { self._attr_device_info = {
"identifiers": {(DOMAIN, entry.unique_id)}, "identifiers": {(DOMAIN, entry.unique_id)},
"name": entry.title, "name": entry.title,
"manufacturer": "iRobot", "manufacturer": "iRobot",
} }
# autodetect icon
icon = regionTypeMappings.get(
data["region_type"], regionTypeMappings.get("default")
)
self._attr_icon = icon
@property @property
def is_on(self): def is_on(self):
@ -47,13 +69,20 @@ class RoomSwitch(SwitchEntity):
async def async_turn_on(self, **kwargs): async def async_turn_on(self, **kwargs):
"""Yes.""" """Yes."""
self._is_on = True self._is_on = True
if self not in order_switched:
order_switched.append(self)
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_off(self, **kwargs): async def async_turn_off(self, **kwargs):
"""No.""" """No."""
self._is_on = False self._is_on = False
if self in order_switched:
order_switched.remove(self)
self.async_write_ha_state() self.async_write_ha_state()
def get_region_json(self): def get_region_json(self):
"""I'm not sure what this does to be honest.""" """Return robot-readable JSON to identify the room to start cleaning it."""
return self._room_json return self._room_json
order_switched: list[RoomSwitch] = []

View file

@ -1,8 +1,5 @@
"""The vacuum.""" """The vacuum."""
from datetime import datetime
import json
import logging import logging
from homeassistant.components.vacuum import ( from homeassistant.components.vacuum import (
@ -15,7 +12,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .LegacyCompatibility import createExtendedAttributes from .LegacyCompatibility import createExtendedAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,7 +23,8 @@ SUPPORT_ROBOT = (
| VacuumEntityFeature.MAP | VacuumEntityFeature.MAP
| VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.STATE | VacuumEntityFeature.STATE
| VacuumEntityFeature.STATUS | VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
) )
@ -60,6 +57,7 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity):
data = self.coordinator.data or {} data = self.coordinator.data or {}
status = data.get("cleanMissionStatus", {}) status = data.get("cleanMissionStatus", {})
cycle = status.get("cycle") cycle = status.get("cycle")
phase = status.get("phase")
not_ready = status.get("notReady") not_ready = status.get("notReady")
self._attr_activity = VacuumActivity.IDLE self._attr_activity = VacuumActivity.IDLE
@ -69,8 +67,14 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity):
self._attr_activity = VacuumActivity.ERROR self._attr_activity = VacuumActivity.ERROR
if cycle in ["clean", "quick", "spot", "train"]: if cycle in ["clean", "quick", "spot", "train"]:
self._attr_activity = VacuumActivity.CLEANING self._attr_activity = VacuumActivity.CLEANING
if cycle in ["evac", "dock"]: if cycle in ["evac", "dock"]: # Emptying Roomba Bin to Dock, Entering Dock
self._attr_activity = VacuumActivity.DOCKED self._attr_activity = VacuumActivity.DOCKED
if phase in {
"hmUsrDock",
"hwMidMsn",
"hmPostMsn",
}: # Sent Home, Mid Dock, Final Dock
self._attr_activity = VacuumActivity.RETURNING
self._attr_available = data != {} self._attr_available = data != {}
self._attr_extra_state_attributes = createExtendedAttributes(self) self._attr_extra_state_attributes = createExtendedAttributes(self)
@ -88,37 +92,99 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity):
async def async_start(self): async def async_start(self):
"""Start cleaning floors, check if any are selected or just clean everything.""" """Start cleaning floors, check if any are selected or just clean everything."""
payload = [] data = self.coordinator.data or {}
if data.get("phase") == "stop":
for entity in self.hass.states.async_all("switch"):
if entity.entity_id.startswith("switch.clean_") and entity.state == "on":
switch_obj = self.hass.data[DOMAIN].get(entity.entity_id)
if switch_obj:
payload.append(switch_obj.get_region_json())
if payload:
# TODO: FIX THIS FIX THIS IT NEEDS TO BE DYNAMIC NOT THIS GARBAGE
# TODO: FIX THIS FIX THIS IT NEEDS TO BE DYNAMIC NOT THIS GARBAGE
# TODO: FIX THIS FIX THIS IT NEEDS TO BE DYNAMIC NOT THIS GARBAGE
await self.hass.services.async_call( await self.hass.services.async_call(
DOMAIN, DOMAIN,
"vacuum_clean", "rest980_action",
service_data={ service_data={
"payload": json.dumps( "action": "resume",
{ "base_url": self._entry.data["base_url"],
"ordered": 1, },
"pmap_id": "BGQxV6zGTmCsalWFHr-S5g", blocking=True,
"regions": payload,
}
) )
return
try:
# Get selected rooms from switches (if available)
payload = []
regions = []
# Check if we have room selection switches available
if hasattr(self, "_selected_rooms") and self._selected_rooms:
# Build regions list from selected rooms
regions = [
room.get_region_json()
for room in self._selected_rooms
if hasattr(room, "get_region_json")
]
# If we have specific regions selected, use targeted cleaning
if regions:
payload = {
"ordered": 1,
"pmap_id": self._attr_extra_state_attributes.get("pmap0_id", ""),
"regions": regions,
}
await self.hass.services.async_call(
DOMAIN,
"rest980_clean",
service_data={
"payload": payload,
"base_url": self._entry.data["base_url"],
}, },
blocking=True, blocking=True,
) )
else: else:
_LOGGER.warning("No rooms selected for cleaning") # No specific rooms selected, start general clean
_LOGGER.info("Starting general cleaning (no specific rooms selected)")
await self.hass.services.async_call(
DOMAIN,
"rest980_clean",
service_data={
"payload": {"action": "start"},
"base_url": self._entry.data["base_url"],
},
blocking=True,
)
except (KeyError, AttributeError, ValueError) as e:
_LOGGER.error("Failed to start cleaning due to configuration error: %s", e)
except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Failed to start cleaning: %s", e)
async def async_stop(self):
"""Stop the action."""
await self.hass.services.async_call(
DOMAIN,
"rest980_action",
service_data={
"action": "stop",
"base_url": self._entry.data["base_url"],
},
blocking=True,
)
async def async_pause(self):
"""Pause the current action."""
await self.hass.services.async_call(
DOMAIN,
"rest980_action",
service_data={
"action": "pause",
"base_url": self._entry.data["base_url"],
},
blocking=True,
)
async def async_return_to_base(self): async def async_return_to_base(self):
"""Calls the Roomba back to its dock.""" """Calls the Roomba back to its dock."""
await self.hass.services.async_call( await self.hass.services.async_call(
DOMAIN, "vacuum_action", service_data={"command": "dock"}, blocking=True DOMAIN,
"rest980_action",
service_data={
"action": "dock",
"base_url": self._entry.data["base_url"],
},
blocking=True,
) )

BIN
img/clean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
img/rooms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB