mirror of
https://github.com/sudoxnym/roomba_rest980.git
synced 2026-04-14 11:37:46 +00:00
make better
This commit is contained in:
parent
e77e353890
commit
74b3b6e478
10 changed files with 527 additions and 83 deletions
60
README.md
60
README.md
|
|
@ -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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
|
|
||||||
|
|
@ -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
BIN
img/clean.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
img/rooms.png
Normal file
BIN
img/rooms.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Loading…
Reference in a new issue