diff --git a/custom_components/roomba_rest980/CloudApi.py b/custom_components/roomba_rest980/CloudApi.py index 5266a6c..ad034ad 100644 --- a/custom_components/roomba_rest980/CloudApi.py +++ b/custom_components/roomba_rest980/CloudApi.py @@ -444,6 +444,9 @@ class iRobotCloudApi: umf_data = await self._aws_request(url, params) + # Save UMF data for debugging/camera development + await self._save_umf_data_for_debug(pmap_id, umf_data) + return umf_data async def get_favorites(self) -> dict[str, Any]: @@ -499,6 +502,49 @@ class iRobotCloudApi: all_data["favorites"] = await self.get_favorites() return all_data + async def _save_umf_data_for_debug( + self, pmap_id: str, umf_data: dict[str, Any] + ) -> None: + """Save UMF data to file for debugging purposes.""" + if not DEBUG_SAVE_UMF: + return + + try: + # Create debug data structure + debug_data = { + "timestamp": datetime.now(UTC).isoformat(), + "pmap_id": pmap_id, + "umf_data": umf_data, + } + + # Load existing data if file exists + existing_data = [] + if DEBUG_UMF_PATH.exists(): + try: + async with aiofiles.open(DEBUG_UMF_PATH) as f: + content = await f.read() + existing_data = json.loads(content) + if not isinstance(existing_data, list): + existing_data = [existing_data] + except (json.JSONDecodeError, OSError): + existing_data = [] + + # Add new data + existing_data.append(debug_data) + + # Keep only the latest 10 entries to avoid huge files + if len(existing_data) > 10: + existing_data = existing_data[-10:] + + # Save back to file + async with aiofiles.open(DEBUG_UMF_PATH, "w") as f: + await f.write(json.dumps(existing_data, indent=2, default=str)) + + _LOGGER.debug("Saved UMF data for pmap %s to %s", pmap_id, DEBUG_UMF_PATH) + + except (OSError, json.JSONEncodeError) as e: + _LOGGER.warning("Failed to save UMF debug data: %s", e) + """ diff --git a/custom_components/roomba_rest980/RoombaSensor.py b/custom_components/roomba_rest980/RoombaSensor.py index 3e57d19..02ef439 100644 --- a/custom_components/roomba_rest980/RoombaSensor.py +++ b/custom_components/roomba_rest980/RoombaSensor.py @@ -5,7 +5,8 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity - +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -14,6 +15,7 @@ _LOGGER = logging.getLogger(__name__) class RoombaSensor(CoordinatorEntity, SensorEntity): """Generic Roomba sensor to provide coordinator.""" + _attr_has_entity_name = True _rs_given_info: tuple[str, str] = ("Sensor", "sensor") def __init__(self, coordinator, entry) -> None: @@ -25,11 +27,19 @@ class RoombaSensor(CoordinatorEntity, SensorEntity): self._attr_has_entity_name = True self._attr_name = self._rs_given_info[0] self._attr_unique_id = f"{entry.unique_id}_{self._rs_given_info[1]}" - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.unique_id)}, - "name": entry.title, - "manufacturer": "iRobot", - } + + @property + def device_info(self) -> DeviceInfo: + """Return the Roomba's device information.""" + data = self.coordinator.data or {} + return DeviceInfo( + identifiers={DOMAIN, self._entry.unique_id}, + name=data.get("name", "Roomba"), + manufacturer="iRobot", + model="Roomba", + model_id=data.get("sku"), + sw_version=data.get("softwareVer"), + ) def isMissionActive(self) -> bool: """Return whether or not there is a mission in progress.""" @@ -56,6 +66,7 @@ class RoombaSensor(CoordinatorEntity, SensorEntity): class RoombaCloudSensor(CoordinatorEntity, SensorEntity): """Generic Roomba sensor to provide coordinator.""" + _attr_has_entity_name = True _rs_given_info: tuple[str, str] = ("Sensor", "sensor") def __init__(self, coordinator, entry) -> None: @@ -67,10 +78,18 @@ class RoombaCloudSensor(CoordinatorEntity, SensorEntity): self._attr_has_entity_name = True self._attr_name = self._rs_given_info[0] self._attr_unique_id = f"{entry.unique_id}_{self._rs_given_info[1]}" - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.unique_id)}, - "name": entry.title, - "manufacturer": "iRobot", - } if not entry.data["cloud_api"]: self._attr_available = False + + @property + def device_info(self) -> DeviceInfo: + """Return the Roomba's device information.""" + data = self.coordinator.data or {} + return DeviceInfo( + identifiers={DOMAIN, self._entry.unique_id}, + name=data.get("name", "Roomba"), + manufacturer="iRobot", + model="Roomba", + model_id=data.get("sku"), + sw_version=data.get("softwareVer"), + ) diff --git a/custom_components/roomba_rest980/__init__.py b/custom_components/roomba_rest980/__init__.py index 8d20345..38aee9f 100644 --- a/custom_components/roomba_rest980/__init__.py +++ b/custom_components/roomba_rest980/__init__.py @@ -11,30 +11,66 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import RoombaCloudCoordinator, RoombaDataCoordinator +from .switch import RoomSwitch CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) +class RoombaRuntimeData: + """Setup the runtime data structure.""" + + local_coordinator: RoombaDataCoordinator = None + robot_blid: str = None + cloud_enabled: bool = False + cloud_coordinator: RoombaCloudCoordinator = None + + switched_rooms: dict[str, RoomSwitch] = {} + + def __init__( + self, + local_coordinator: RoombaDataCoordinator, + robot_blid: str, + cloud_enabled: bool, + cloud_coordinator: RoombaCloudCoordinator, + ) -> None: + """Initialize the class with given data.""" + self.local_coordinator = local_coordinator + self.robot_blid = robot_blid + self.cloud_enabled = cloud_enabled + self.cloud_coordinator = cloud_coordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Setup Roombas with the Rest980 base url.""" coordinator = RoombaDataCoordinator(hass, entry) + cloud_coordinator: RoombaCloudCoordinator = None + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id + "_coordinator"] = coordinator - hass.data[DOMAIN][entry.entry_id + "_blid"] = "unknown" + entry.runtime_data = RoombaRuntimeData( + local_coordinator=coordinator, + robot_blid=None, + cloud_enabled=entry.data["cloud_api"], + cloud_coordinator=cloud_coordinator, + ) # Set up cloud coordinator if enabled if entry.data["cloud_api"]: - cc = RoombaCloudCoordinator(hass, entry) - hass.data[DOMAIN][entry.entry_id + "_cloud"] = cc + cloud_coordinator = RoombaCloudCoordinator(hass, entry) + + await cloud_coordinator.async_config_entry_first_refresh() # Start background task for cloud setup and BLID matching - hass.async_create_task(_async_setup_cloud(hass, entry, coordinator, cc)) + hass.async_create_task( + _async_setup_cloud(hass, entry, coordinator, cloud_coordinator) + ) + + # Update runtime data with cloud coordinator + entry.runtime_data.cloud_coordinator = cloud_coordinator else: - hass.data[DOMAIN][entry.entry_id + "_cloud"] = {} + cloud_coordinator = None # Register services await _async_register_services(hass) @@ -107,7 +143,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_unload_platforms( entry, ["vacuum", "sensor", "switch", "button", "camera"] ) - hass.data[DOMAIN].pop(entry.entry_id + "_coordinator") return True @@ -122,9 +157,20 @@ async def _async_setup_cloud( # 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) + # Perform BLID matching only if not already stored in config entry + if "robot_blid" not in entry.data: + matched_blid = await _async_match_blid( + hass, entry, coordinator, cloud_coordinator + ) + if matched_blid: + # Store the matched BLID permanently in config entry data + hass.config_entries.async_update_entry( + entry, data={**entry.data, "robot_blid": matched_blid} + ) + entry.runtime_data.robot_blid = matched_blid + else: + # Use stored BLID from config entry + entry.runtime_data.robot_blid = entry.data["robot_blid"] await hass.config_entries.async_forward_entry_setups( entry, ["switch", "button", "camera"] @@ -132,8 +178,7 @@ async def _async_setup_cloud( 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"] = {} + cloud_coordinator = None async def _async_match_blid( @@ -164,7 +209,7 @@ async def _async_match_blid( and cloud_sw_ver == local_sw_ver and cloud_name == local_name ): - hass.data[DOMAIN][entry.entry_id + "_blid"] = blid + entry.runtime_data.robot_blid = blid _LOGGER.info("Matched local Roomba with cloud robot %s", blid) break diff --git a/custom_components/roomba_rest980/button.py b/custom_components/roomba_rest980/button.py index 294cdd9..de7ce38 100644 --- a/custom_components/roomba_rest980/button.py +++ b/custom_components/roomba_rest980/button.py @@ -13,10 +13,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): """Create the switches to identify cleanable rooms.""" - cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"] + cloudCoordinator = entry.runtime_data.cloud_coordinator entities = [] if cloudCoordinator and cloudCoordinator.data: - blid = hass.data[DOMAIN][entry.entry_id + "_blid"] + blid = entry.runtime_data.robot_blid # Get cloud data for the specific robot if blid in cloudCoordinator.data: cloud_data = cloudCoordinator.data @@ -35,7 +35,7 @@ class FavoriteButton(ButtonEntity): """Creates a button entity for entries.""" self._attr_name = f"{data['name']}" self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_{data['favorite_id']}" + self._attr_unique_id = f"{entry.unique_id}_{data['favorite_id']}" self._attr_entity_category = EntityCategory.CONFIG self._attr_extra_state_attributes = data self._data = data diff --git a/custom_components/roomba_rest980/camera.py b/custom_components/roomba_rest980/camera.py index 09330c5..2c9f824 100644 --- a/custom_components/roomba_rest980/camera.py +++ b/custom_components/roomba_rest980/camera.py @@ -46,9 +46,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Roomba map camera.""" - _LOGGER.debug("Setting up camera platform for entry %s", entry.entry_id) + _LOGGER.debug("Setting up camera platform for entry %s", entry.unique_id) - cloudCoordinator = hass.data[DOMAIN].get(entry.entry_id + "_cloud") + cloudCoordinator = entry.runtime_data.cloud_coordinator if not cloudCoordinator: _LOGGER.warning("No cloud coordinator found for camera setup") @@ -59,7 +59,7 @@ async def async_setup_entry( return entities = [] - blid = hass.data[DOMAIN].get(entry.entry_id + "_blid", "unknown") + blid = entry.runtime_data.robot_blid _LOGGER.debug("Using BLID: %s for camera setup", blid) if blid != "unknown" and blid in cloudCoordinator.data: @@ -131,7 +131,7 @@ class RoombaMapCamera(Camera): # Camera attributes self._attr_name = f"Roomba Map - {self._map_header.get('name', 'Unknown')}" - self._attr_unique_id = f"{entry.entry_id}_map_{pmap_id}" + self._attr_unique_id = f"{entry.unique_id}_map_{pmap_id}" # Device info self._attr_device_info = { diff --git a/custom_components/roomba_rest980/config_flow.py b/custom_components/roomba_rest980/config_flow.py index 84c0b4b..670e5b3 100644 --- a/custom_components/roomba_rest980/config_flow.py +++ b/custom_components/roomba_rest980/config_flow.py @@ -1,21 +1,32 @@ """The configuration flow for the robot.""" import asyncio +import hashlib +import logging +from aiohttp import ClientConnectorError, ClientError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .CloudApi import iRobotCloudApi +from .CloudApi import AuthenticationError, iRobotCloudApi from .const import DOMAIN -SCHEMA = vol.Schema( +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( { vol.Required("base_url"): str, vol.Required("cloud_api", default=True): bool, - vol.Optional("irobot_username"): str, - vol.Optional("irobot_password"): str, + } +) + +CLOUD_SCHEMA = vol.Schema( + { + vol.Required("irobot_username"): str, + vol.Required("irobot_password"): str, } ) @@ -23,46 +34,90 @@ SCHEMA = vol.Schema( class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow.""" - async def async_step_user(self, user_input=None): + VERSION = 2 + + _proposed_name: str + _user_data: dict[str, any] + + async def async_step_cloud(self, user_input=None) -> ConfigFlowResult: + """Show user the setup for the cloud API.""" + if user_input is not None: + errors = {} + async with iRobotCloudApi( + user_input["irobot_username"], user_input["irobot_password"] + ) as api: + try: + await api.authenticate() + except AuthenticationError: + errors["base"] = "cloud_authentication_error" + except Exception: # Allowed in config flow for robustness + errors["base"] = "unknown" + if errors: + return self.async_show_form( + step_id="cloud", + data_schema=CLOUD_SCHEMA, + errors=errors, + ) + + if hasattr(self, "_user_data"): + data = {**self._user_data, **user_input} + return self.async_create_entry( + title=self._proposed_name, + data=data, + ) + return self.async_abort(reason="missing_user_data") + + return self.async_show_form(step_id="cloud", data_schema=CLOUD_SCHEMA) + + async def test_local(self, user_input): + """Test connection to local rest980 API.""" + session = async_get_clientsession(self.hass) + async with session.get( + f"{user_input['base_url']}/api/local/info/state" + ) as resp: + data = await resp.json() or {} + if data == {}: + raise ValueError("No data returned from device") + + unique_id = hashlib.md5(user_input["base_url"].encode()).hexdigest()[:8] + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return data + + async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Show the user the input for the base url.""" if user_input is not None: - session = async_get_clientsession(self.hass) - data = {} - async with asyncio.timeout(10): - try: - async with session.get( - f"{user_input['base_url']}/api/local/info/state" - ) as resp: - data = await resp.json() or {} - if data == {}: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA, - errors=["cannot_connect"], - ) - if user_input["cloud_api"]: - async with iRobotCloudApi( - user_input["irobot_username"], user_input["irobot_password"] - ) as api: - await api.authenticate() - except Exception: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA, - errors=["cannot_connect"], - ) + errors = {} + try: + async with asyncio.timeout(10): + device_data = await self.test_local(user_input) + except TimeoutError: + errors["base"] = "local_cannot_connect" + except (ClientError, ClientConnectorError, OSError): + errors["base"] = "local_cannot_connect" + except ValueError: + errors["base"] = "local_connected_no_data" + except Exception: # Allowed in config flow for robustness + errors["base"] = "unknown" - return self.async_create_entry( - title=data.get("name", "Roomba"), - data=user_input, - ) + if errors: + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors=errors, + ) - return self.async_show_form(step_id="user", data_schema=SCHEMA) + # Use device name if available, otherwise fall back to URL + device_name = device_data.get("name", "Roomba") + self._proposed_name = f"{device_name}" - async def async_step_options(self, user_input=None): - """I dont know.""" - return self.async_create_entry( - title="Room Switches Configured via UI", - data=self.options, - options=self.options, - ) + if not user_input["cloud_api"]: + return self.async_create_entry( + title=self._proposed_name, + data=user_input, + ) + # Store user data for use in cloud step + self._user_data = user_input + return self.async_show_form(step_id="cloud", data_schema=CLOUD_SCHEMA) + return self.async_show_form(step_id="user", data_schema=USER_SCHEMA) diff --git a/custom_components/roomba_rest980/const.py b/custom_components/roomba_rest980/const.py index e344d6b..b26519e 100644 --- a/custom_components/roomba_rest980/const.py +++ b/custom_components/roomba_rest980/const.py @@ -14,11 +14,88 @@ notReadyMappings = { 68: "Updating Map", } +## Some mappings thanks to https://github.com/NickWaterton/Roomba980-Python/blob/master/roomba/roomba.py errorMappings = { 0: "n-a", 15: "Reboot Required", 18: "Docking Issue", 68: "Updating Map", + 1: "Left wheel off floor", + 2: "Main brushes stuck", + 3: "Right wheel off floor", + 4: "Left wheel stuck", + 5: "Right wheel stuck", + 6: "Stuck near a cliff", + 7: "Left wheel error", + 8: "Bin error", + 9: "Bumper stuck", + 10: "Right wheel error", + 11: "Bin error", + 12: "Cliff sensor issue", + 13: "Both wheels off floor", + 14: "Bin missing", + 16: "Bumped unexpectedly", + 17: "Path blocked", + 19: "Undocking issue", + 20: "Docking issue", + 21: "Navigation problem", + 22: "Navigation problem", + 23: "Battery issue", + 24: "Navigation problem", + 25: "Reboot required", + 26: "Vacuum problem", + 27: "Vacuum problem", + 29: "Software update needed", + 30: "Vacuum problem", + 31: "Reboot required", + 32: "Smart map problem", + 33: "Path blocked", + 34: "Reboot required", + 35: "Unrecognised cleaning pad", + 36: "Bin full", + 37: "Tank needed refilling", + 38: "Vacuum problem", + 39: "Reboot required", + 40: "Navigation problem", + 41: "Timed out", + 42: "Localization problem", + 43: "Navigation problem", + 44: "Pump issue", + 45: "Lid open", + 46: "Low battery", + 47: "Reboot required", + 48: "Path blocked", + 52: "Pad required attention", + 53: "Software update required", + 65: "Hardware problem detected", + 66: "Low memory", + 73: "Pad type changed", + 74: "Max area reached", + 75: "Navigation problem", + 76: "Hardware problem detected", + 88: "Back-up refused", + 89: "Mission runtime too long", + 101: "Battery isn't connected", + 102: "Charging error", + 103: "Charging error", + 104: "No charge current", + 105: "Charging current too low", + 106: "Battery too warm", + 107: "Battery temperature incorrect", + 108: "Battery communication failure", + 109: "Battery error", + 110: "Battery cell imbalance", + 111: "Battery communication failure", + 112: "Invalid charging load", + 114: "Internal battery failure", + 115: "Cell failure during charging", + 116: "Charging error of Home Base", + 118: "Battery communication failure", + 119: "Charging timeout", + 120: "Battery not initialized", + 122: "Charging system error", + 123: "Battery not initialized", + 216: "Charging base bag full", } cycleMappings = { @@ -31,15 +108,23 @@ cycleMappings = { "none": "Ready", } +## Some mappings thanks to https://github.com/NickWaterton/Roomba980-Python/blob/master/roomba/roomba.py phaseMappings = { + "new": "New Mission", + "resume": "Resumed", + "recharge": "Recharging", + "completed": "Mission Completed", + "cancelled": "Cancelled", + "pause": "Paused", + "chargingerror": "Base Unplugged", "charge": "Charge", "run": "Run", "evac": "Empty", "stop": "Paused", "stuck": "Stuck", "hmUsrDock": "Sent Home", - "hmMidMsn": "Mid Dock", - "hmPostMsn": "Final Dock", + "hmMidMsn": "Docking", + "hmPostMsn": "Docking - Ending Job", "idle": "Idle", # Added for RoombaPhase "stopped": "Stopped", # Added for RoombaPhase } @@ -57,7 +142,7 @@ cleanBaseMappings = { 351: "Clogged", 352: "Sealing Problem", 353: "Bag Full", - 360: "Comms Problem", + 360: "IR Comms Problem", 364: "Bin Full Sensors Not Cleared", } diff --git a/custom_components/roomba_rest980/manifest.json b/custom_components/roomba_rest980/manifest.json index ace117f..819937e 100644 --- a/custom_components/roomba_rest980/manifest.json +++ b/custom_components/roomba_rest980/manifest.json @@ -4,7 +4,6 @@ "codeowners": [ "@ia74" ], - "version": "1.9.0", "config_flow": true, "dependencies": [], "dhcp": [ @@ -27,11 +26,11 @@ ], "documentation": "https://github.com/ia74/roomba_rest980", "iot_class": "local_polling", + "quality_scale": "bronze", "requirements": [ - "aiohttp", - "Pillow", - "aiofiles" + "aiofiles==24.1.0" ], + "version": "1.10.0", "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/custom_components/roomba_rest980/quality_scale.yaml b/custom_components/roomba_rest980/quality_scale.yaml new file mode 100644 index 0000000..6ab4419 --- /dev/null +++ b/custom_components/roomba_rest980/quality_scale.yaml @@ -0,0 +1,19 @@ +rules: + # Bronze + action-setup: todo + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: done + docs-actions: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo diff --git a/custom_components/roomba_rest980/sensor.py b/custom_components/roomba_rest980/sensor.py index 4728cc8..49181c5 100644 --- a/custom_components/roomba_rest980/sensor.py +++ b/custom_components/roomba_rest980/sensor.py @@ -16,13 +16,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): """Create the sensors needed to poll Roomba's data.""" - coordinator = hass.data[DOMAIN][entry.entry_id + "_coordinator"] - cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"] + coordinator = entry.runtime_data.local_coordinator + cloudCoordinator = entry.runtime_data.cloud_coordinator # Create cloud pmap entities if cloud data is available cloud_entities = [] if cloudCoordinator and cloudCoordinator.data: - blid = hass.data[DOMAIN][entry.entry_id + "_blid"] + blid = entry.runtime_data.robot_blid # Get cloud data for the specific robot if blid in cloudCoordinator.data: cloud_data = cloudCoordinator.data[blid] @@ -136,12 +136,7 @@ class RoombaCloudAttributes(RoombaCloudSensor): @property def extra_state_attributes(self): """Return all the attributes returned by iRobot's cloud.""" - return ( - self.coordinator.data.get( - self.hass.data[DOMAIN][self._entry.entry_id + "_blid"] - ) - or {} - ) + return self.coordinator.data.get(self._entry.runtime_data.robot_blid) or {} class RoombaCloudPmap(RoombaCloudSensor): diff --git a/custom_components/roomba_rest980/switch.py b/custom_components/roomba_rest980/switch.py index c5e83b9..0ee0b74 100644 --- a/custom_components/roomba_rest980/switch.py +++ b/custom_components/roomba_rest980/switch.py @@ -13,10 +13,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): """Create the switches to identify cleanable rooms.""" - cloudCoordinator = hass.data[DOMAIN][entry.entry_id + "_cloud"] + cloudCoordinator = entry.runtime_data.cloud_coordinator entities = [] if cloudCoordinator and cloudCoordinator.data: - blid = hass.data[DOMAIN][entry.entry_id + "_blid"] + blid = entry.runtime_data.robot_blid # Get cloud data for the specific robot if blid in cloudCoordinator.data: cloud_data = cloudCoordinator.data[blid] @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): e, ) for ent in entities: - hass.data[DOMAIN][f"switch.{ent.unique_id}"] = ent + entry.runtime_data.switched_rooms[f"switch.{ent.unique_id}"] = ent async_add_entities(entities) @@ -47,7 +47,7 @@ class RoomSwitch(SwitchEntity): def __init__(self, entry, name, data) -> None: """Creates a switch entity for rooms.""" self._attr_name = f"Clean {name}" - self._attr_unique_id = f"{entry.entry_id}_{data['id']}" + self._attr_unique_id = f"{entry.unique_id}_{data['id']}" self._is_on = False self._room_json = {"region_id": data["id"], "type": "rid"} self._attr_entity_category = EntityCategory.CONFIG diff --git a/custom_components/roomba_rest980/translations/en.json b/custom_components/roomba_rest980/translations/en.json index dd5a23d..72571ab 100644 --- a/custom_components/roomba_rest980/translations/en.json +++ b/custom_components/roomba_rest980/translations/en.json @@ -1,26 +1,34 @@ { + "common": {}, "config": { "step": { "user": { "title": "Setup Roomba for integration", "data": { "base_url": "rest980 Base URL (ex: http://localhost:3000)", - "cloud_api": "Enable cloud features?", - "irobot_username": "iRobot Username", - "irobot_password": "iRobot Password" + "cloud_api": "Enable cloud features?" }, "data_description": { "cloud_api": "Use iRobot's API to add extra features like dynamic rooms and maps." } + }, + "cloud": { + "title": "Enter iRobot Home credentials", + "data": { + "irobot_username": "iRobot Username", + "irobot_password": "iRobot Password" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cloud_authentication_error": "Could not login to the iRobot Home API.", + "local_cannot_connect": "Could not connect to rest980 on that URL.", + "local_connected_no_data": "Rest980 did not output valid Roomba data.", + "unknown": "Unknown error occurred.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "missing_user_data": "Forgot the local Rest980 data." } } } \ No newline at end of file diff --git a/custom_components/roomba_rest980/vacuum.py b/custom_components/roomba_rest980/vacuum.py index a53f5e3..064b787 100644 --- a/custom_components/roomba_rest980/vacuum.py +++ b/custom_components/roomba_rest980/vacuum.py @@ -8,6 +8,7 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -28,11 +29,13 @@ SUPPORT_ROBOT = ( ) -async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): """Create the vacuum.""" - coordinator = hass.data[DOMAIN][entry.entry_id + "_coordinator"] - await coordinator.async_config_entry_first_refresh() - async_add_entities([RoombaVacuum(hass, coordinator, entry)]) + async_add_entities( + [RoombaVacuum(hass, entry.runtime_data.local_coordinator, entry)] + ) class RoombaVacuum(CoordinatorEntity, StateVacuumEntity): @@ -44,13 +47,8 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity): self.hass = hass self._entry: ConfigEntry = entry self._attr_supported_features = SUPPORT_ROBOT - self._attr_unique_id = f"{entry.unique_id}_vacuum" + self._attr_unique_id = f"{entry.entry_id}_vacuum" self._attr_name = entry.title - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.unique_id)}, - "name": entry.title, - "manufacturer": "iRobot", - } def _handle_coordinator_update(self): """Update all attributes.""" @@ -78,15 +76,21 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity): self._attr_available = data != {} self._attr_extra_state_attributes = createExtendedAttributes(self) - self._attr_device_info = { - "identifiers": self._attr_device_info.get("identifiers"), - "name": self._attr_device_info.get("name"), - "manufacturer": self._attr_device_info.get("manufacturer"), - "model": f"Roomba {data.get('sku')}", - "sw_version": data.get("softwareVer"), - } self._async_write_ha_state() + @property + def device_info(self) -> DeviceInfo: + """Return the Roomba's device information.""" + data = self.coordinator.data or {} + return DeviceInfo( + identifiers={DOMAIN, self._entry.unique_id}, + name=data.get("name", "Roomba"), + manufacturer="iRobot", + model="Roomba", + model_id=data.get("sku"), + sw_version=data.get("softwareVer"), + ) + async def async_clean_spot(self, **kwargs): """Spot clean.""" @@ -111,7 +115,7 @@ class RoombaVacuum(CoordinatorEntity, StateVacuumEntity): regions = [] # Check if we have room selection switches available - domain_data = self.hass.data.get(DOMAIN, {}) + domain_data = self._entry.runtime_data.switched_rooms selected_rooms = [] # Find all room switches that are turned on