-
-
Notifications
You must be signed in to change notification settings - Fork 29.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add SensorPush Cloud integration #121890
base: dev
Are you sure you want to change the base?
Add SensorPush Cloud integration #121890
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @sstallion
It seems you haven't yet signed a CLA. Please do so here.
Once you do that we will be able to review and accept this pull request.
Thanks!
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
62d93ba
to
13e0239
Compare
e66eb2f
to
f8a8e68
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's been a while since I last left a review, so please take all of this with a grain of salt. I may be incorrect, so don't take it all as absolute truth.
No worries, this is my first integration - I appreciate another set of eyes! |
5be9bd1
to
7b48ff6
Compare
Ping! (Keeping PR from going stale) |
7b48ff6
to
a7470d4
Compare
def api_call[**_P, _R]( | ||
func: Callable[Concatenate[SensorPushCloudApi, _P], _R], | ||
) -> Callable[Concatenate[SensorPushCloudApi, _P], _R]: | ||
"""Decorate API calls to handle SensorPush Cloud exceptions.""" | ||
|
||
@wraps(func) | ||
def _api_call(self: SensorPushCloudApi, *args: _P.args, **kwargs: _P.kwargs) -> _R: | ||
LOGGER.debug(f"API call to {func} with args={args}, kwargs={kwargs}") | ||
retries: int = 0 | ||
while True: | ||
try: | ||
result = func(self, *args, **kwargs) | ||
except Exception as e: | ||
# Force reauthorization if an exception occurs to avoid | ||
# authorization failures after temporary outages. | ||
self.deadline = dt_util.now() | ||
|
||
# The SensorPush Cloud API suffers from frequent exceptions; | ||
# requests are retried before raising an error. | ||
if retries < REQUEST_RETRIES: | ||
retries = retries + 1 | ||
continue | ||
|
||
LOGGER.debug(f"API call to {func} failed after {retries} retries") | ||
if isinstance(e, ApiException): | ||
# API exceptions provide a JSON-encoded message in the | ||
# body; otherwise, fall back to the general behavior. | ||
try: | ||
data = json.loads(e.body) | ||
raise SensorPushCloudError(data["message"]) from e | ||
except Exception: # noqa: BLE001 | ||
pass | ||
raise SensorPushCloudError from e | ||
else: | ||
LOGGER.debug(f"API call to {func} succeeded after {retries} retries") | ||
return result | ||
|
||
return _api_call |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like something thats api specific, should we keep it in the library?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately the API is a generated Swagger client, which doesn't allow the user to customize generated output beyond the package name etc. I'm not the biggest fan of this approach either, but it seemed the cleanest method to interface with the API without leaking details.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you generate the openapi?
I always have a huge aversion against those libraries as they always come with deficits and the integration code has to make up for that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did. I was originally going to write a custom API library for it, but after reading the SensorPush developer documentation, they seemed to favor using Swagger for generating API clients. There wasn't a huge amount of code to integrate the two, so it seemed like a fair trade-off.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally I always like the freedom and the efficiency you can add over autogenerating
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with Joost, that this code should be part of the library
async def async_authorize(self) -> None: | ||
"""Sign in and request an authorization code.""" | ||
return await self.hass.async_add_executor_job(self.authorize) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How can we make everything async from the getgo?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a tough one. My preference is for everything to be async as well, however generated Swagger clients have a broad set of compatibility constraints with little support for async. My hope was to at least quarantine blocking calls and then rely on the executor to at least give the appearance of supporting async calls.
vol.Required(CONF_DEVICE_IDS, default=[]): SelectSelector( | ||
SelectSelectorConfig(options=options, multiple=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we select devices and not just add everything?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm glad you asked! SensorPush lumps all devices under a single login regardless of locality. For my use case, I have sensors in two different houses, each running their own Home Assistant instance. I need the ability to select specific sensors for each space.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We usually add everything and have the user disable stuff they don't need
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha. Having that part of the config flow does make it easier to filter device data at runtime. I can remove it if you wish, but it will increase the amount of data that is downloaded each update.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be consistent throughout the Home Assistant, all devices should be added, and the user has the possibility to disable it via the UI.
Please remove the option to select specific devices
if user_input is not None: | ||
email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD] | ||
self.api = SensorPushCloudApi(self.hass, email, password) | ||
await self.async_set_unique_id(email, raise_on_progress=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you change the email?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, this is a fixed part of the SensorPush API used for login and cannot be changed as far as I can tell.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if you need to change your email address? Do you need to create a new account?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just double checked and it looks like you can change the email using the dashboard. In this case, could it make sense to have a follow-up PR that supports the reauth config flow and allow the user to change both email and password?
data[device_id] = { | ||
ATTR_ALTITUDE: sample.altitude, | ||
ATTR_ATMOSPHERIC_PRESSURE: sample.barometric_pressure, | ||
ATTR_BATTERY_VOLTAGE: sensor["battery_voltage"], | ||
ATTR_DEWPOINT: sample.dewpoint, | ||
ATTR_HUMIDITY: sample.humidity, | ||
ATTR_LAST_UPDATE: sample.observed, | ||
ATTR_SIGNAL_STRENGTH: sensor["rssi"], | ||
ATTR_TEMPERATURE: sample.temperature, | ||
ATTR_VAPOR_PRESSURE: sample.vpd, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why dont we just leave the sample in the Coordinator data and have the sensor platform figure out what data it wants to show
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could do that, however it would complicate the sensor code a bit. I was hoping to keep a minimum subset of data resident rather than the entire set of supported devices in addition to the sample data. The SensorPush API isn't well designed and you can see the effects here. Fetching sensor data cannot be filtered, so we're always grabbing every sensor even if they aren't being used by Home Assistant.
a7470d4
to
8794274
Compare
8794274
to
ecb9b64
Compare
Thanks for the review @joostlek! I wanted to check if there was anything preventing this PR from merging. The integration has been working well for some time now and I wanted to double check before I made major changes to the config flow. I also wasn't sure if you wanted me to create a custom API library versus using the existing Swagger client or if that was personal preference. Thanks again! |
ecb9b64
to
d8cc277
Compare
We probably still have a few rounds to go. But I kinda dislike all the things in the integration that are needed to complete the library. We'd rather have a more complete library. But it's not a blocker as this isn't the only one, but I still wanted to express my opinions on that :) |
I understand - I'm not the biggest fan of this approach either. I don't have a good feeling for when/if SensorPush will make changes to the API (the most recent update I can find was from a few weeks ago). Once the API settles I can put together something that would be much nicer to integrate. With respect to the remaining issues to address, what do you see as blockers to merge the PR? |
I did ask around for you. Is it possible to create the openapi as an inner folder in the dependency and just have a public facing facade with nice and neat functions?
|
Thanks! Not really - Swagger generates an entire setuptools-based package for Python clients. I've also released this to PyPI before opening this PR, which might make it a little tough to back out. The only other alternative I can think of is to create a new package which depends on the Swagger client and presents the same interface as api.py. The only consumer of this new package would be Home Assistant, which doesn't seem quite right since it would create a cyclic dependency (eg. this library would have to rely on both hass and sensorpush_api to do its job unless we want to use the generic Python executor) |
I think there are more ways of generating them, I've also seen them async |
Cc @mj23000, you know more about this I think |
while True: | ||
try: | ||
result = func(self, *args, **kwargs) | ||
except Exception as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Catching the rare Exception
outside the config flow is not allowed. Please adopt the code to catch only specific ones
def api_call[**_P, _R]( | ||
func: Callable[Concatenate[SensorPushCloudApi, _P], _R], | ||
) -> Callable[Concatenate[SensorPushCloudApi, _P], _R]: | ||
"""Decorate API calls to handle SensorPush Cloud exceptions.""" | ||
|
||
@wraps(func) | ||
def _api_call(self: SensorPushCloudApi, *args: _P.args, **kwargs: _P.kwargs) -> _R: | ||
LOGGER.debug(f"API call to {func} with args={args}, kwargs={kwargs}") | ||
retries: int = 0 | ||
while True: | ||
try: | ||
result = func(self, *args, **kwargs) | ||
except Exception as e: | ||
# Force reauthorization if an exception occurs to avoid | ||
# authorization failures after temporary outages. | ||
self.deadline = dt_util.now() | ||
|
||
# The SensorPush Cloud API suffers from frequent exceptions; | ||
# requests are retried before raising an error. | ||
if retries < REQUEST_RETRIES: | ||
retries = retries + 1 | ||
continue | ||
|
||
LOGGER.debug(f"API call to {func} failed after {retries} retries") | ||
if isinstance(e, ApiException): | ||
# API exceptions provide a JSON-encoded message in the | ||
# body; otherwise, fall back to the general behavior. | ||
try: | ||
data = json.loads(e.body) | ||
raise SensorPushCloudError(data["message"]) from e | ||
except Exception: # noqa: BLE001 | ||
pass | ||
raise SensorPushCloudError from e | ||
else: | ||
LOGGER.debug(f"API call to {func} succeeded after {retries} retries") | ||
return result | ||
|
||
return _api_call |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with Joost, that this code should be part of the library
# Generated Swagger clients install default logging handlers that | ||
# conflict with Home Assistant; remove before making API calls. | ||
self.configuration = Configuration() | ||
for logger in self.configuration.logger.values(): | ||
logger.removeHandler(self.configuration.logger_stream_handler) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please fix this directly in the code gen tools. It looks hacky and is a workaround, which should be avoided
vol.Required(CONF_DEVICE_IDS, default=[]): SelectSelector( | ||
SelectSelectorConfig(options=options, multiple=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be consistent throughout the Home Assistant, all devices should be added, and the user has the possibility to disable it via the UI.
Please remove the option to select specific devices
if user_input is not None: | ||
email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD] | ||
self.api = SensorPushCloudApi(self.hass, email, password) | ||
await self.async_set_unique_id(email, raise_on_progress=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if you need to change your email address? Do you need to create a new account?
ATTR_TEMPERATURE: sample.temperature, | ||
ATTR_VAPOR_PRESSURE: sample.vpd, | ||
} | ||
except Exception: # noqa: BLE001 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please catch only specific exceptions
What is the difference between the local and cloud sensor push integration regarding functionality? |
Are you using swagger-codegen for the API generation? We use openapi-generator , which seems to be the more up to date OpenAPI generator. |
But I personally do not allow any more Pydantic V1 integrationd without shims for v2 |
Yeah exactly, which is part of what makes our generation complicated. It looks like the API client isn't too big to manually add the shims, so you can probably avoid too much work. |
Thanks for giving the extra info @mj23000, much appreciated! |
There should be no difference between the two; even the polling intervals are the same. At the moment, it does not appear that the BLE version of the integration provides every possible sensor but that's likely just a limitation in the sensorpush-ble library. |
Gotcha. Is there an exemplar I could use to pattern this integration after? |
@joostlek It sounds like the generated sensorpush-api library is a no-go for integration. Since it sounds like there are better generation tools available that library will need an overhaul. With respect to adding this new library, should this be added as an internal package since it will need modification or does this still need to be an external library published to PyPI? |
I mean, I think you got the idea now that it should not be just generate and publish and call it a day. We still require all device specific logic to be its own library, so that's a must. |
Yes, the mozart-open-api Python client can be used as an example (And is used in the bang_olufsen integration). This client is modified in various ways, so the output of the OpenAPI-generator will be a bit different, but not majorly. To make Pydantic v1/v2 work, ensure that the Pydantic v1 imports are wrapped in a try catch like this: try:
from pydantic.v1 import Field, StrictInt, StrictStr
except ImportError:
from pydantic import Field, StrictInt, StrictStr This may unfortunately break some typing checks. |
Heads up, I'm still working on this though I'm somewhat slowed by travel for work. I should have something ready for review late next week. |
Proposed change
This PR adds cloud integration for SensorPush devices. It communicates with the publicly available Cloud API using the sensorpush-api Python package. Care was taken to ensure that presented devices appeared the same as those created by the existing SensorPush integration. A G1 WiFi Gateway is required to make use of the Cloud API.
Type of change
Additional information
Checklist
ruff format homeassistant tests
)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest
.requirements_all.txt
.Updated by running
python3 -m script.gen_requirements_all
.To help with the load of incoming pull requests: