-
-
Notifications
You must be signed in to change notification settings - Fork 36.1k
Add support for "Indoor Only" mode on Sure Petcare pets/flaps #158073
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
base: dev
Are you sure you want to change the base?
Changes from all commits
b60b2df
f1ed01c
cdfd19a
d7a42ab
dd3eb0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| """Support for Sure PetCare Flaps switches.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, cast | ||
|
|
||
| from surepy.const import BASE_RESOURCE | ||
| from surepy.entities import SurepyEntity | ||
| from surepy.entities.pet import Pet as SurepyPet | ||
| from surepy.enums import EntityType | ||
| from surepy.exceptions import SurePetcareError | ||
|
|
||
| from homeassistant.components.switch import SwitchEntity | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.exceptions import HomeAssistantError | ||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||
|
|
||
| from .const import DOMAIN, PROFILE_INDOOR, PROFILE_OUTDOOR | ||
| from .coordinator import SurePetcareDataCoordinator | ||
| from .entity import SurePetcareEntity | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, | ||
| entry: ConfigEntry, | ||
| async_add_entities: AddConfigEntryEntitiesCallback, | ||
| ) -> None: | ||
| """Set up Sure PetCare switches on a config entry.""" | ||
|
|
||
| coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] | ||
|
|
||
| pets_by_tag_id: dict[int, SurepyPet] = {} | ||
| for entity in coordinator.data.values(): | ||
| if entity.type == EntityType.PET: | ||
| pet = cast(SurepyPet, entity) | ||
| if pet.tag_id is not None: | ||
| pets_by_tag_id[pet.tag_id] = pet | ||
|
|
||
| entities: list[SurePetcareIndoorModeSwitch] = [] | ||
| for entity in coordinator.data.values(): | ||
| if entity.type in [EntityType.CAT_FLAP, EntityType.PET_FLAP]: | ||
| for tag_data in entity.raw_data().get("tags", []): | ||
| tag_id = tag_data.get("id") | ||
| if tag_id in pets_by_tag_id: | ||
| entities.append( | ||
| SurePetcareIndoorModeSwitch( | ||
| pet=pets_by_tag_id[tag_id], | ||
| flap=entity, | ||
| coordinator=coordinator, | ||
| ) | ||
| ) | ||
|
|
||
| async_add_entities(entities) | ||
|
|
||
|
|
||
| class SurePetcareIndoorModeSwitch(SurePetcareEntity, SwitchEntity): | ||
| """A switch implementation for Sure Petcare pet indoor mode.""" | ||
|
|
||
| _attr_has_entity_name = True | ||
| _attr_translation_key = "indoor_mode" | ||
| _attr_entity_registry_enabled_default = False | ||
|
|
||
| def __init__( | ||
| self, | ||
| pet: SurepyPet, | ||
| flap: SurepyEntity, | ||
| coordinator: SurePetcareDataCoordinator, | ||
| ) -> None: | ||
| """Initialize a Sure Petcare indoor mode switch.""" | ||
| self._pet = pet | ||
| self._flap = flap | ||
| self._profile_id: int | None = None | ||
| self._available = False | ||
|
|
||
| # Initialize with flap_id so the entity is attached to the flap device | ||
| super().__init__(flap.id, coordinator) | ||
AlexC marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| self._attr_unique_id = f"{self._device_id}-{pet.id}-indoor_mode" | ||
| self._attr_translation_placeholders = {"pet_name": pet.name} | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return true if entity is available.""" | ||
| return self._available and super().available | ||
|
|
||
| @callback | ||
| def _update_attr(self, surepy_entity: SurepyEntity) -> None: | ||
| """Update the state from the flap's tag data.""" | ||
| tags_by_id = { | ||
| tag.get("id"): tag for tag in surepy_entity.raw_data().get("tags", []) | ||
| } | ||
|
Comment on lines
+90
to
+92
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Future improvement for the library, we should add this in the model in an easy way, as we're now looping over the tags for every door
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with you here, see my comment around the general state of the library below |
||
|
|
||
| pet_tag = tags_by_id.get(self._pet.tag_id) | ||
| if pet_tag is None: | ||
| # Pet no longer configured for this flap | ||
| self._available = False | ||
| self._profile_id = None | ||
| return | ||
|
|
||
| self._available = True | ||
| self._profile_id = pet_tag.get("profile", PROFILE_OUTDOOR) | ||
| self._attr_is_on = self._profile_id == PROFILE_INDOOR | ||
|
|
||
| async def _async_update_tag_profile(self, profile: int) -> None: | ||
| """Call the API to set the pet's profile on this flap.""" | ||
| result = await self.coordinator.surepy.sac.call( | ||
| method="PUT", | ||
| resource=f"{BASE_RESOURCE}/device/{self._flap.id}/tag/{self._pet.tag_id}", | ||
| json={"profile": profile}, | ||
| ) | ||
| if result is None: | ||
| raise SurePetcareError("Device tag API call returned None") | ||
|
|
||
| async def _async_set_profile(self, profile: int) -> None: | ||
| """Set the pet's profile on this flap.""" | ||
| try: | ||
| await self._async_update_tag_profile(profile) | ||
| except SurePetcareError as err: | ||
| await self.coordinator.async_request_refresh() | ||
AlexC marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| mode = "indoor" if profile == PROFILE_INDOOR else "outdoor" | ||
| raise HomeAssistantError( | ||
| f"Failed to set {self._pet.name} {mode} mode on {self._flap.name}" | ||
| ) from err | ||
|
|
||
| # Update state immediately after successful API call | ||
| self._profile_id = profile | ||
| self._attr_is_on = profile == PROFILE_INDOOR | ||
| self.async_write_ha_state() | ||
AlexC marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+120
to
+129
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we only refresh when it failed?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My thinking was that if the API request is successful, then there is no need to make another API request to the data that we already know. A refresh will happen on the next polling interval (every 3 minutes for this integration). On the failure state, I feel a refresh is required because we don't know what happened to the request, so fetching the latest data right away would bring it back in sync. Happy to take your advice here though
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you ever get it to error out?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not in a real example, only via the tests. However I do see an issue as the From what I can see, I can update this to throw
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we shouldn't throw exceptions other than ServiceValidationError (when the user has a skill issue) and HomeAssistantError (aka, we had a reason that it failed)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean within the This is what existing methods do as linked to above
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've made the above change to align with how the other |
||
|
|
||
| async def async_turn_on(self, **kwargs: Any) -> None: | ||
| """Turn on the switch (set indoor mode).""" | ||
| if self._attr_is_on: | ||
| return | ||
|
|
||
| await self._async_set_profile(PROFILE_INDOOR) | ||
|
|
||
| async def async_turn_off(self, **kwargs: Any) -> None: | ||
| """Turn off the switch (set outdoor mode).""" | ||
| if not self._attr_is_on: | ||
| return | ||
|
|
||
| await self._async_set_profile(PROFILE_OUTDOOR) | ||
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.
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.
The best API docs that exist for this vendor is from their Swagger docs for their Beta env. There is a schema for the
profile(SpecialProfiles) however that just gives an array of ints with no description as to what these are ([ 0, 1, 2, 3, 4, 5, 6 ]]).When viewing the requests made on their web app I cannot see any usage other than
2(outdoor) or3(indoor) when you are marking a pet to be "inside only" for a given flapThere 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.
Hmm, do we have a way to discover other modes? Because if we find more we should make it a select entity instead
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 suspect that the remaining profiles are related to their other products (devices) such as their smart feeder and water fountain. They appear to use "tags" on each "device" to control certain features, one of which is the indoor only mode for their cat flap as implemented here.
If I can find some batteries for our feeder I can help confirm that. Though going through their app again for everything catflap related, it's only ever "2" or "3"
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 if you use their API to set it to something different?
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.
So long as you pass in an int from 0 to 6, you can successfully set that profile on the device. However, none of the other values have any impact on the Indoor Only mode. I have reached out to their support to see if they are able to provide any more info here, though I do suspect these other profile values are for other devices (not flaps)