Skip to content

Commit 7e75ca7

Browse files
mib1185bramkragten
authored andcommitted
Revert "Remove neato integration (#154902)" (#155685)
1 parent 6616b57 commit 7e75ca7

30 files changed

+1574
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ homeassistant.components.myuplink.*
361361
homeassistant.components.nam.*
362362
homeassistant.components.nanoleaf.*
363363
homeassistant.components.nasweb.*
364+
homeassistant.components.neato.*
364365
homeassistant.components.nest.*
365366
homeassistant.components.netatmo.*
366367
homeassistant.components.network.*
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Support for Neato botvac connected vacuum cleaners."""
2+
3+
import logging
4+
5+
import aiohttp
6+
from pybotvac import Account
7+
from pybotvac.exceptions import NeatoException
8+
9+
from homeassistant.config_entries import ConfigEntry
10+
from homeassistant.const import CONF_TOKEN, Platform
11+
from homeassistant.core import HomeAssistant
12+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
13+
from homeassistant.helpers import config_entry_oauth2_flow
14+
15+
from . import api
16+
from .const import NEATO_DOMAIN, NEATO_LOGIN
17+
from .hub import NeatoHub
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
PLATFORMS = [
22+
Platform.BUTTON,
23+
Platform.CAMERA,
24+
Platform.SENSOR,
25+
Platform.SWITCH,
26+
Platform.VACUUM,
27+
]
28+
29+
30+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
31+
"""Set up config entry."""
32+
hass.data.setdefault(NEATO_DOMAIN, {})
33+
if CONF_TOKEN not in entry.data:
34+
raise ConfigEntryAuthFailed
35+
36+
implementation = (
37+
await config_entry_oauth2_flow.async_get_config_entry_implementation(
38+
hass, entry
39+
)
40+
)
41+
42+
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
43+
try:
44+
await session.async_ensure_token_valid()
45+
except aiohttp.ClientResponseError as ex:
46+
_LOGGER.debug("API error: %s (%s)", ex.code, ex.message)
47+
if ex.code in (401, 403):
48+
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
49+
raise ConfigEntryNotReady from ex
50+
51+
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
52+
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
53+
hub = NeatoHub(hass, Account(neato_session))
54+
55+
await hub.async_update_entry_unique_id(entry)
56+
57+
try:
58+
await hass.async_add_executor_job(hub.update_robots)
59+
except NeatoException as ex:
60+
_LOGGER.debug("Failed to connect to Neato API")
61+
raise ConfigEntryNotReady from ex
62+
63+
hass.data[NEATO_LOGIN] = hub
64+
65+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
66+
67+
return True
68+
69+
70+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
71+
"""Unload config entry."""
72+
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
73+
if unload_ok:
74+
hass.data[NEATO_DOMAIN].pop(entry.entry_id)
75+
76+
return unload_ok
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""API for Neato Botvac bound to Home Assistant OAuth."""
2+
3+
from __future__ import annotations
4+
5+
from asyncio import run_coroutine_threadsafe
6+
from typing import Any
7+
8+
import pybotvac
9+
10+
from homeassistant import config_entries, core
11+
from homeassistant.components.application_credentials import AuthImplementation
12+
from homeassistant.helpers import config_entry_oauth2_flow
13+
14+
15+
class ConfigEntryAuth(pybotvac.OAuthSession): # type: ignore[misc]
16+
"""Provide Neato Botvac authentication tied to an OAuth2 based config entry."""
17+
18+
def __init__(
19+
self,
20+
hass: core.HomeAssistant,
21+
config_entry: config_entries.ConfigEntry,
22+
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
23+
) -> None:
24+
"""Initialize Neato Botvac Auth."""
25+
self.hass = hass
26+
self.session = config_entry_oauth2_flow.OAuth2Session(
27+
hass, config_entry, implementation
28+
)
29+
super().__init__(self.session.token, vendor=pybotvac.Neato())
30+
31+
def refresh_tokens(self) -> str:
32+
"""Refresh and return new Neato Botvac tokens."""
33+
run_coroutine_threadsafe(
34+
self.session.async_ensure_token_valid(), self.hass.loop
35+
).result()
36+
37+
return self.session.token["access_token"] # type: ignore[no-any-return]
38+
39+
40+
class NeatoImplementation(AuthImplementation):
41+
"""Neato implementation of LocalOAuth2Implementation.
42+
43+
We need this class because we have to add client_secret
44+
and scope to the authorization request.
45+
"""
46+
47+
@property
48+
def extra_authorize_data(self) -> dict[str, Any]:
49+
"""Extra data that needs to be appended to the authorize url."""
50+
return {"client_secret": self.client_secret}
51+
52+
async def async_generate_authorize_url(self, flow_id: str) -> str:
53+
"""Generate a url for the user to authorize.
54+
55+
We must make sure that the plus signs are not encoded.
56+
"""
57+
url = await super().async_generate_authorize_url(flow_id)
58+
return f"{url}&scope=public_profile+control_robots+maps"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Application credentials platform for neato."""
2+
3+
from pybotvac import Neato
4+
5+
from homeassistant.components.application_credentials import (
6+
AuthorizationServer,
7+
ClientCredential,
8+
)
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import config_entry_oauth2_flow
11+
12+
from . import api
13+
14+
15+
async def async_get_auth_implementation(
16+
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
17+
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
18+
"""Return auth implementation for a custom auth implementation."""
19+
vendor = Neato()
20+
return api.NeatoImplementation(
21+
hass,
22+
auth_domain,
23+
credential,
24+
AuthorizationServer(
25+
authorize_url=vendor.auth_endpoint,
26+
token_url=vendor.token_endpoint,
27+
),
28+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Support for Neato buttons."""
2+
3+
from __future__ import annotations
4+
5+
from pybotvac import Robot
6+
7+
from homeassistant.components.button import ButtonEntity
8+
from homeassistant.config_entries import ConfigEntry
9+
from homeassistant.const import EntityCategory
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
12+
13+
from .const import NEATO_ROBOTS
14+
from .entity import NeatoEntity
15+
16+
17+
async def async_setup_entry(
18+
hass: HomeAssistant,
19+
entry: ConfigEntry,
20+
async_add_entities: AddConfigEntryEntitiesCallback,
21+
) -> None:
22+
"""Set up Neato button from config entry."""
23+
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
24+
25+
async_add_entities(entities, True)
26+
27+
28+
class NeatoDismissAlertButton(NeatoEntity, ButtonEntity):
29+
"""Representation of a dismiss_alert button entity."""
30+
31+
_attr_translation_key = "dismiss_alert"
32+
_attr_entity_category = EntityCategory.CONFIG
33+
34+
def __init__(
35+
self,
36+
robot: Robot,
37+
) -> None:
38+
"""Initialize a dismiss_alert Neato button entity."""
39+
super().__init__(robot)
40+
self._attr_unique_id = f"{robot.serial}_dismiss_alert"
41+
42+
async def async_press(self) -> None:
43+
"""Press the button."""
44+
await self.hass.async_add_executor_job(self.robot.dismiss_current_alert)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Support for loading picture from Neato."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
from typing import Any
8+
9+
from pybotvac.exceptions import NeatoRobotException
10+
from pybotvac.robot import Robot
11+
from urllib3.response import HTTPResponse
12+
13+
from homeassistant.components.camera import Camera
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.core import HomeAssistant
16+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
17+
18+
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
19+
from .entity import NeatoEntity
20+
from .hub import NeatoHub
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
25+
ATTR_GENERATED_AT = "generated_at"
26+
27+
28+
async def async_setup_entry(
29+
hass: HomeAssistant,
30+
entry: ConfigEntry,
31+
async_add_entities: AddConfigEntryEntitiesCallback,
32+
) -> None:
33+
"""Set up Neato camera with config entry."""
34+
neato: NeatoHub = hass.data[NEATO_LOGIN]
35+
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
36+
dev = [
37+
NeatoCleaningMap(neato, robot, mapdata)
38+
for robot in hass.data[NEATO_ROBOTS]
39+
if "maps" in robot.traits
40+
]
41+
42+
if not dev:
43+
return
44+
45+
_LOGGER.debug("Adding robots for cleaning maps %s", dev)
46+
async_add_entities(dev, True)
47+
48+
49+
class NeatoCleaningMap(NeatoEntity, Camera):
50+
"""Neato cleaning map for last clean."""
51+
52+
_attr_translation_key = "cleaning_map"
53+
54+
def __init__(
55+
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
56+
) -> None:
57+
"""Initialize Neato cleaning map."""
58+
super().__init__(robot)
59+
Camera.__init__(self)
60+
self.neato = neato
61+
self._mapdata = mapdata
62+
self._available = neato is not None
63+
self._robot_serial: str = self.robot.serial
64+
self._attr_unique_id = self.robot.serial
65+
self._generated_at: str | None = None
66+
self._image_url: str | None = None
67+
self._image: bytes | None = None
68+
69+
def camera_image(
70+
self, width: int | None = None, height: int | None = None
71+
) -> bytes | None:
72+
"""Return image response."""
73+
self.update()
74+
return self._image
75+
76+
def update(self) -> None:
77+
"""Check the contents of the map list."""
78+
79+
_LOGGER.debug("Running camera update for '%s'", self.entity_id)
80+
try:
81+
self.neato.update_robots()
82+
except NeatoRobotException as ex:
83+
if self._available: # Print only once when available
84+
_LOGGER.error(
85+
"Neato camera connection error for '%s': %s", self.entity_id, ex
86+
)
87+
self._image = None
88+
self._image_url = None
89+
self._available = False
90+
return
91+
92+
if self._mapdata:
93+
map_data: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0]
94+
if (image_url := map_data["url"]) == self._image_url:
95+
_LOGGER.debug(
96+
"The map image_url for '%s' is the same as old", self.entity_id
97+
)
98+
return
99+
100+
try:
101+
image: HTTPResponse = self.neato.download_map(image_url)
102+
except NeatoRobotException as ex:
103+
if self._available: # Print only once when available
104+
_LOGGER.error(
105+
"Neato camera connection error for '%s': %s", self.entity_id, ex
106+
)
107+
self._image = None
108+
self._image_url = None
109+
self._available = False
110+
return
111+
112+
self._image = image.read()
113+
self._image_url = image_url
114+
self._generated_at = map_data.get("generated_at")
115+
self._available = True
116+
117+
@property
118+
def available(self) -> bool:
119+
"""Return if the robot is available."""
120+
return self._available
121+
122+
@property
123+
def extra_state_attributes(self) -> dict[str, Any]:
124+
"""Return the state attributes of the vacuum cleaner."""
125+
data: dict[str, Any] = {}
126+
127+
if self._generated_at is not None:
128+
data[ATTR_GENERATED_AT] = self._generated_at
129+
130+
return data

0 commit comments

Comments
 (0)