Skip to content
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

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from

Conversation

sstallion
Copy link

@sstallion sstallion commented Jul 13, 2024

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

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (you're welcome!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

To help with the load of incoming pull requests:

  1. Adding Reauth Flow to SimpleFin  #121863 (review)
  2. Bump azure-kusto dependencies to 4.5.1 #121805 (review)

Copy link

@home-assistant home-assistant bot left a 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!

@home-assistant
Copy link

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copy link
Contributor

@DCSBL DCSBL left a 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.

homeassistant/components/sensorpush_cloud/__init__.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/const.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/strings.json Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/strings.json Outdated Show resolved Hide resolved
@sstallion
Copy link
Author

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!

@sstallion sstallion force-pushed the sstallion/sensorpush_cloud branch 3 times, most recently from 5be9bd1 to 7b48ff6 Compare July 18, 2024 20:59
@sstallion
Copy link
Author

Ping! (Keeping PR from going stale)

Comment on lines +31 to +68
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
Copy link
Member

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?

Copy link
Author

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.

Copy link
Member

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

Copy link
Author

@sstallion sstallion Jul 21, 2024

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.

Copy link
Member

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

Copy link
Contributor

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

Comment on lines +117 to +119
async def async_authorize(self) -> None:
"""Sign in and request an authorization code."""
return await self.hass.async_add_executor_job(self.authorize)
Copy link
Member

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?

Copy link
Author

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.

Comment on lines +63 to +64
vol.Required(CONF_DEVICE_IDS, default=[]): SelectSelector(
SelectSelectorConfig(options=options, multiple=True)
Copy link
Member

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?

Copy link
Author

@sstallion sstallion Jul 21, 2024

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.

Copy link
Member

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

Copy link
Author

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.

Copy link
Contributor

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)
Copy link
Member

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?

Copy link
Author

@sstallion sstallion Jul 21, 2024

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.

Copy link
Contributor

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?

Copy link
Author

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?

Comment on lines +78 to +88
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,
}
Copy link
Member

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

Copy link
Author

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.

homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/sensorpush_cloud/sensor.py Outdated Show resolved Hide resolved
@home-assistant home-assistant bot marked this pull request as draft July 21, 2024 19:31
@sstallion sstallion marked this pull request as ready for review July 21, 2024 19:58
@home-assistant home-assistant bot requested a review from joostlek July 21, 2024 19:58
@sstallion
Copy link
Author

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!

@joostlek
Copy link
Member

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 :)

@sstallion
Copy link
Author

sstallion commented Jul 23, 2024

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?

@joostlek
Copy link
Member

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?

/Mylib
         | _api/ (autogen)
         | __init__.py
         | client.py (wrap api)

@sstallion
Copy link
Author

sstallion commented Jul 23, 2024

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)

@joostlek
Copy link
Member

I think there are more ways of generating them, I've also seen them async

@joostlek
Copy link
Member

Cc @mj23000, you know more about this I think

while True:
try:
result = func(self, *args, **kwargs)
except Exception as e:
Copy link
Contributor

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

Comment on lines +31 to +68
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
Copy link
Contributor

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

Comment on lines +91 to +95
# 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)
Copy link
Contributor

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

Comment on lines +63 to +64
vol.Required(CONF_DEVICE_IDS, default=[]): SelectSelector(
SelectSelectorConfig(options=options, multiple=True)
Copy link
Contributor

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)
Copy link
Contributor

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
Copy link
Contributor

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

@home-assistant home-assistant bot marked this pull request as draft July 24, 2024 10:20
@edenhaus
Copy link
Contributor

What is the difference between the local and cloud sensor push integration regarding functionality?
What features will be offered only by the local one and which ones only by the cloud one?

@mj23000
Copy link
Contributor

mj23000 commented Jul 24, 2024

Are you using swagger-codegen for the API generation? We use openapi-generator , which seems to be the more up to date OpenAPI generator.
An async Python client can easily be generated by using the python-pydantic-v1(As Home Assistant still uses pydantic v1 for now) generator and the asyncio backend. Getting everything to work as expected can be a bit of a pain, which results in our generation being pretty complicated as we modify many of the generated files.

@joostlek
Copy link
Member

But I personally do not allow any more Pydantic V1 integrationd without shims for v2

@mj23000
Copy link
Contributor

mj23000 commented Jul 24, 2024

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.

@joostlek
Copy link
Member

Thanks for giving the extra info @mj23000, much appreciated!

@sstallion
Copy link
Author

What is the difference between the local and cloud sensor push integration regarding functionality? What features will be offered only by the local one and which ones only by the cloud one?

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.

@sstallion
Copy link
Author

Are you using swagger-codegen for the API generation? We use openapi-generator , which seems to be the more up to date OpenAPI generator. An async Python client can easily be generated by using the python-pydantic-v1(As Home Assistant still uses pydantic v1 for now) generator and the asyncio backend. Getting everything to work as expected can be a bit of a pain, which results in our generation being pretty complicated as we modify many of the generated files.

Gotcha. Is there an exemplar I could use to pattern this integration after?

@sstallion
Copy link
Author

@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?

@joostlek
Copy link
Member

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.

@mj23000
Copy link
Contributor

mj23000 commented Jul 24, 2024

Are you using swagger-codegen for the API generation? We use openapi-generator , which seems to be the more up to date OpenAPI generator. An async Python client can easily be generated by using the python-pydantic-v1(As Home Assistant still uses pydantic v1 for now) generator and the asyncio backend. Getting everything to work as expected can be a bit of a pain, which results in our generation being pretty complicated as we modify many of the generated files.

Gotcha. Is there an exemplar I could use to pattern this integration after?

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.

@sstallion
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants