Repository: home-assistant/core
Pull request: Add favorite position buttons to Motion Blinds
<!--
  You are amazing! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
-->
## Breaking change
<!--
  If your PR contains a breaking change for existing users, it is important
  to tell them what breaks, how to make it work again and why we did this.
  This piece of text is published with the release notes, so it helps if you
  write it towards our users, not us.
  Note: Remove this section if this PR is NOT a breaking change.
-->


## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
Add favorite position buttons to Motion Blinds:
go to favorite position
set current position as favorite (first need to short press the reset button on the blind in order to change the favorite position).

## Type of change
<!--
  What type of change does your PR introduce to Home Assistant?
  NOTE: Please, check only 1! box!
  If your PR requires multiple boxes to be checked, you'll most likely need to
  split it into multiple PRs. This makes things easier and faster to code review.
-->

- [ ] Dependency upgrade
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New integration (thank you!)
- [x] 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
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes https://github.com/starkillerOG/motion-blinds/issues/33
- This PR is related to issue: 
- Link to documentation pull request: https://github.com/home-assistant/home-assistant.io/pull/34233

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Local tests pass. **Your PR cannot be merged unless tests pass**
- [x] There is no commented out code in this PR.
- [x] I have followed the [development checklist][dev-checklist]
- [x] I have followed the [perfect PR recommendations][perfect-pr]
- [x] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [x] Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

- [x] Documentation added/updated for [www.home-assistant.io][docs-repository]

If the code communicates with devices, web services, or third-party tools:

- [x] The [manifest file][manifest-docs] has all fields filled out correctly.  
      Updated and included derived files by running: `python3 -m script.hassfest`.
- [x] New or updated dependencies have been added to `requirements_all.txt`.  
      Updated by running `python3 -m script.gen_requirements_all`.
- [x] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

<!--
  This project is very active and we have a high turnover of pull requests.

  Unfortunately, the number of incoming pull requests is higher than what our
  reviewers can review and merge so there is a long backlog of pull requests
  waiting for review. You can help here!
  
  By reviewing another pull request, you will help raise the code quality of
  that pull request and the final review will be faster. This way the general
  pace of pull request reviews will go up and your wait time will go down.
  
  When picking a pull request to review, try to choose one that hasn't yet
  been reviewed.

  Thanks for helping out!
-->

To help with the load of incoming pull requests:

- [ ] I have reviewed two other [open pull requests][prs] in this repository.

[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure

<!--
  Thank you for contributing <3

  Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr


Problem statement:
Favorite position function
Hi,

Sorry if this is not the right way to do this.
I use the motion-blinds intergration in Home-Assistant in combination with my Brel hub and rollerblinds.
In the brel application on my phone I can sent the command "favorite possition" and the blinds wil go to the present position. Just like a long press on the stop button on the remote does.
I don't see this function in this intergration. Is it possible to get this function? Or somehow sent the "favorite position" command?

Thanks in advance.

Hints from issue discussion:
@jrstaal I indeed did not add the option to go to the favorite possition (to HomeAssistant).
This because in HomeAssistant you can set the rollerblind to a specific position % using the `service: cover.set_cover_position`.
Therefore it is really easy to make a button in the frontend or as part of a automation or script to set the blind to a specific (favorite) position.
Therefore I thougth it would not be nessesary to add the favorite position.

If you would really like to have it, I can add a button for it if you like, but at the earliest it could be included in HA 2023.2.0.
Just let me know.

If you appreciate the motion_blinds integration and want to support its development, please consider [sponsering](https://github.com/sponsors/starkillerOG).
I would really like to have the favorite position button in home assistant, if you could do that. That would be awesome!

Reason is, I have duo rollerblinds and the favorite position is way more specific than the percentages. With duo rollerblinds it needs to be specific or else I don’t have the proper see trough.

I’m definitely making a donation! 

Thanks!
@jrstaal I think you can use a combination of position and tilt to get a more accurate postioning of the duo rollerblinds.

Thank you very much for your generous donation.

I have been looking into the possibility of the favorite postion, but to my surprise there is no such command in the API to move the blind to its favorite position.
Even more surprising to me is that the Brel apps favorite postion is actually just a postion saved in the app, so moving to the "favorire" position in the app moves to a diffrent "favorite" position than using the remote control to move to the favorite postion stored in the blind itself.

I have contacted the manufacturer and he is looking into this and asking around if there is some hidden API command to move the blind to its favorite position.

So I am very sorry, but at this point it seems impossible to add the favorite position button to Home Assistant.

I will close this issue now, but if I hear back from the manufacturer that there is a API command, I will re-open and implement the command.

Did you found out if there is a API function for the favorite function?
There is definitely a difference in setting a percentage and the favorite position, and the hub uses the favorite position stored in the motor. Because I never set a favorite position with the hub, only with the remote and I can call the favorite position in the app and still knows where to go.
I found out today that I hate the brel app but I need that favorite position function so bad! 
With the old motors when pressing stop 3 times (using blocky in domoticz) I could use it but this does not work for me with my new motors.
Can you please take a look?
@jrstaal I had a look, but could not find any API commond to go to a preset favorite position, even tried to guess some commands, but no luck.
@starkillerOG I Found some new information. I used the Brel app on my MacBook with some tools Wireshark, Postman en Requestly. Found some interesting stuff.
Looks like the brel app uses "https://connectoreu.shadeconnector.com/" as backend. Looks like the app uses json to send requests to the backend en receives json back.
With that in mind I searched for information about shade connector. They use the same bridges, so I digged some deeper in the bridge documentation. Finally found something about my favourite function. Looks likes it is called "3rd position".

Please have a look at this document: https://www.autoblinds.co.nz/files/Connector_Bridge_RS485_Interface.pdf
There is a lot of information about sending requests to the bridge. I looked at the Motionblinds code but I do not understand how exactly the api to the bridge works. Maybe this document can help you?
There is also a zip file: https://www.autoblinds.co.nz/files/3rd_Party_Integration_for_Connector_Bridge_20230729.zip 
It contains a lot of files with documentation about the api calls (CRESTRON-Dooya-1.0.0.pdf).

Also, in the JSON post from the app, function number 12 is used to call the "3rd position" Where 0, 1, 2 are down, stop up.
This was also traceable to the Chinese documentation of shade connector: https://github.com/ShadeConnector/IOSDocument/blob/master/DataPoint/DataPoint_CN.md 
Here the translation: 
https://github-com.translate.goog/ShadeConnector/IOSDocument/blob/master/DataPoint/DataPoint_CN.md?_x_tr_sl=auto&_x_tr_tl=en&_x_tr_hl=nl&_x_tr_pto=wapp 
12 points to: Run the third travel point

I hope you can do something with this information? Or maybe you can explain what you exactly need? Maybe I can try to alter the code and play around in my own HA.

@jrstaal I tested the operation 12 and indeed it moves the blind to the favorite postion.
That was the info I needed, I will implement a button entity in HomeAssistant to move the blind to its favorite position.
May take a bit of time, but I expect it to be ready in the HA 2024.9 release in a month.

Thanks for the detailed info and figuring this out!
WAUW YEAH I'm so happy now! Thank you! Looking forward to it.

Commit message: Merge remote-tracking branch 'upstream/dev' into motion_favorite

Patch to review:
diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py
new file mode 100644
index 00000000000000..929947ce03aa03
--- /dev/null
+++ b/homeassistant/components/motion_blinds/button.py
@@ -0,0 +1,66 @@
+"""Support for Motionblinds button entity using their WLAN API."""
+
+from __future__ import annotations
+
+from motionblinds.motion_blinds import LimitStatus
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
+from .entity import MotionCoordinatorEntity
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Perform the setup for Motionblinds."""
+    entities: list[ButtonEntity] = []
+    motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
+    coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
+
+    for blind in motion_gateway.device_list.values():
+        if blind.limit_status == LimitStatus.Limit3Detected.name:
+            entities.append(MotionGoFavoriteButton(coordinator, blind))
+            entities.append(MotionSetFavoriteButton(coordinator, blind))
+
+    async_add_entities(entities)
+
+
+class MotionGoFavoriteButton(MotionCoordinatorEntity, ButtonEntity):
+    """Button entity to go to the favorite position of a blind."""
+
+    _attr_translation_key = "go_favorite"
+
+    def __init__(self, coordinator, blind):
+        """Initialize the Motion Button."""
+        super().__init__(coordinator, blind)
+        self._attr_unique_id = f"{blind.mac}-go-favorite"
+
+    async def async_press(self) -> None:
+        """Execute the button action."""
+        async with self._api_lock:
+            await self.hass.async_add_executor_job(self._blind.Go_favorite_position)
+        await self.async_request_position_till_stop()
+
+
+class MotionSetFavoriteButton(MotionCoordinatorEntity, ButtonEntity):
+    """Button entity to set the favorite position of a blind to the current position."""
+
+    _attr_entity_category = EntityCategory.CONFIG
+    _attr_translation_key = "set_favorite"
+
+    def __init__(self, coordinator, blind):
+        """Initialize the Motion Button."""
+        super().__init__(coordinator, blind)
+        self._attr_unique_id = f"{blind.mac}-set-favorite"
+
+    async def async_press(self) -> None:
+        """Execute the button action."""
+        async with self._api_lock:
+            await self.hass.async_add_executor_job(self._blind.Set_favorite_position)
diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py
index e089fd17943b75..96067d7ceb07f5 100644
--- a/homeassistant/components/motion_blinds/const.py
+++ b/homeassistant/components/motion_blinds/const.py
@@ -6,7 +6,7 @@
 MANUFACTURER = "Motionblinds, Coulisse B.V."
 DEFAULT_GATEWAY_NAME = "Motionblinds Gateway"
 
-PLATFORMS = [Platform.COVER, Platform.SENSOR]
+PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR]
 
 CONF_WAIT_FOR_PUSH = "wait_for_push"
 CONF_INTERFACE = "interface"
diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py
index 2cbee96adb71c8..72b78915badffb 100644
--- a/homeassistant/components/motion_blinds/cover.py
+++ b/homeassistant/components/motion_blinds/cover.py
@@ -5,7 +5,7 @@
 import logging
 from typing import Any
 
-from motionblinds import DEVICE_TYPES_WIFI, BlindType
+from motionblinds import BlindType
 import voluptuous as vol
 
 from homeassistant.components.cover import (
@@ -16,10 +16,9 @@
     CoverEntityFeature,
 )
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant
+from homeassistant.core import HomeAssistant
 from homeassistant.helpers import config_validation as cv, entity_platform
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_call_later
 from homeassistant.helpers.typing import VolDictType
 
 from .const import (
@@ -31,8 +30,6 @@
     KEY_GATEWAY,
     SERVICE_SET_ABSOLUTE_POSITION,
     UPDATE_DELAY_STOP,
-    UPDATE_INTERVAL_MOVING,
-    UPDATE_INTERVAL_MOVING_WIFI,
 )
 from .entity import MotionCoordinatorEntity
 
@@ -179,14 +176,6 @@ def __init__(self, coordinator, blind, device_class):
         """Initialize the blind."""
         super().__init__(coordinator, blind)
 
-        self._requesting_position: CALLBACK_TYPE | None = None
-        self._previous_positions = []
-
-        if blind.device_type in DEVICE_TYPES_WIFI:
-            self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
-        else:
-            self._update_interval_moving = UPDATE_INTERVAL_MOVING
-
         self._attr_device_class = device_class
         self._attr_unique_id = blind.mac
 
@@ -218,47 +207,6 @@ def is_closed(self) -> bool | None:
             return None
         return self._blind.position == 100
 
-    async def async_scheduled_update_request(self, *_):
-        """Request a state update from the blind at a scheduled point in time."""
-        # add the last position to the list and keep the list at max 2 items
-        self._previous_positions.append(self.current_cover_position)
-        if len(self._previous_positions) > 2:
-            del self._previous_positions[: len(self._previous_positions) - 2]
-
-        async with self._api_lock:
-            await self.hass.async_add_executor_job(self._blind.Update_trigger)
-
-        self.async_write_ha_state()
-
-        if len(self._previous_positions) < 2 or not all(
-            self.current_cover_position == prev_position
-            for prev_position in self._previous_positions
-        ):
-            # keep updating the position @self._update_interval_moving until the position does not change.
-            self._requesting_position = async_call_later(
-                self.hass,
-                self._update_interval_moving,
-                self.async_scheduled_update_request,
-            )
-        else:
-            self._previous_positions = []
-            self._requesting_position = None
-
-    async def async_request_position_till_stop(self, delay=None):
-        """Request the position of the blind every self._update_interval_moving seconds until it stops moving."""
-        if delay is None:
-            delay = self._update_interval_moving
-
-        self._previous_positions = []
-        if self.current_cover_position is None:
-            return
-        if self._requesting_position is not None:
-            self._requesting_position()
-
-        self._requesting_position = async_call_later(
-            self.hass, delay, self.async_scheduled_update_request
-        )
-
     async def async_open_cover(self, **kwargs: Any) -> None:
         """Open the cover."""
         async with self._api_lock:
diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py
index 4734d4d9a65671..20f3efa5f7934d 100644
--- a/homeassistant/components/motion_blinds/entity.py
+++ b/homeassistant/components/motion_blinds/entity.py
@@ -5,8 +5,10 @@
 from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway
 from motionblinds.motion_blinds import MotionBlind
 
+from homeassistant.core import CALLBACK_TYPE
 from homeassistant.helpers import device_registry as dr
 from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.event import async_call_later
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 from .const import (
@@ -15,6 +17,8 @@
     DOMAIN,
     KEY_GATEWAY,
     MANUFACTURER,
+    UPDATE_INTERVAL_MOVING,
+    UPDATE_INTERVAL_MOVING_WIFI,
 )
 from .coordinator import DataUpdateCoordinatorMotionBlinds
 from .gateway import device_name
@@ -36,6 +40,14 @@ def __init__(
         self._blind = blind
         self._api_lock = coordinator.api_lock
 
+        self._requesting_position: CALLBACK_TYPE | None = None
+        self._previous_positions: list[int | dict | None] = []
+
+        if blind.device_type in DEVICE_TYPES_WIFI:
+            self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
+        else:
+            self._update_interval_moving = UPDATE_INTERVAL_MOVING
+
         if blind.device_type in DEVICE_TYPES_GATEWAY:
             gateway = blind
         else:
@@ -95,3 +107,44 @@ async def async_will_remove_from_hass(self) -> None:
         """Unsubscribe when removed."""
         self._blind.Remove_callback(self.unique_id)
         await super().async_will_remove_from_hass()
+
+    async def async_scheduled_update_request(self, *_):
+        """Request a state update from the blind at a scheduled point in time."""
+        # add the last position to the list and keep the list at max 2 items
+        self._previous_positions.append(self._blind.position)
+        if len(self._previous_positions) > 2:
+            del self._previous_positions[: len(self._previous_positions) - 2]
+
+        async with self._api_lock:
+            await self.hass.async_add_executor_job(self._blind.Update_trigger)
+
+        self.coordinator.async_update_listeners()
+
+        if len(self._previous_positions) < 2 or not all(
+            self._blind.position == prev_position
+            for prev_position in self._previous_positions
+        ):
+            # keep updating the position @self._update_interval_moving until the position does not change.
+            self._requesting_position = async_call_later(
+                self.hass,
+                self._update_interval_moving,
+                self.async_scheduled_update_request,
+            )
+        else:
+            self._previous_positions = []
+            self._requesting_position = None
+
+    async def async_request_position_till_stop(self, delay=None):
+        """Request the position of the blind every self._update_interval_moving seconds until it stops moving."""
+        if delay is None:
+            delay = self._update_interval_moving
+
+        self._previous_positions = []
+        if self._blind.position is None:
+            return
+        if self._requesting_position is not None:
+            self._requesting_position()
+
+        self._requesting_position = async_call_later(
+            self.hass, delay, self.async_scheduled_update_request
+        )
diff --git a/homeassistant/components/motion_blinds/icons.json b/homeassistant/components/motion_blinds/icons.json
index a61c36e3f00e6a..9e1cd613e5b53d 100644
--- a/homeassistant/components/motion_blinds/icons.json
+++ b/homeassistant/components/motion_blinds/icons.json
@@ -1,4 +1,14 @@
 {
+  "entity": {
+    "button": {
+      "go_favorite": {
+        "default": "mdi:star"
+      },
+      "set_favorite": {
+        "default": "mdi:star-cog"
+      }
+    }
+  },
   "services": {
     "set_absolute_position": "mdi:set-square"
   }
diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json
index cb9468c3a27d5c..ddbf928462aeb0 100644
--- a/homeassistant/components/motion_blinds/strings.json
+++ b/homeassistant/components/motion_blinds/strings.json
@@ -62,6 +62,14 @@
     }
   },
   "entity": {
+    "button": {
+      "go_favorite": {
+        "name": "Go to favorite position"
+      },
+      "set_favorite": {
+        "name": "Set current position as favorite"
+      }
+    },
     "cover": {
       "top": {
         "name": "Top"


Code review comments:
diff_hunk: @@ -0,0 +1,66 @@
+"""Support for Motionblinds button entity using their WLAN API."""
+
+from __future__ import annotations
+
+from motionblinds.motion_blinds import LimitStatus
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
+from .entity import MotionCoordinatorEntity
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Perform the setup for Motionblinds."""
+    entities: list[ButtonEntity] = []
+    motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
+    coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
+
+    for blind in motion_gateway.device_list.values():
+        if blind.limit_status == LimitStatus.Limit3Detected.name:
+            entities.append(MotionGoFavoriteButton(coordinator, blind))
+            entities.append(MotionSetFavoriteButton(coordinator, blind))
+
+    async_add_entities(entities)
+
+
+class MotionGoFavoriteButton(MotionCoordinatorEntity, ButtonEntity):
+    """Button entity to go to the favorite position of a blind."""
+
+    _attr_translation_key = "go_favorite"
+
+    def __init__(self, coordinator, blind):
+        """Initialize the Motion Button."""
+        super().__init__(coordinator, blind)
+        self._attr_unique_id = f"{blind.mac}-go-favorite"
path: homeassistant/components/motion_blinds/button.py
text: @user1:
```suggestion
        self._attr_unique_id = f"{blind.mac}_go-favorite"
```
Not sure what you use at the other platforms, if it's a `-`, just keep it like that, otherwise I'd recommend this as it's a good practice because if you ever have to migrate in the future you can just do `.split("_")` and have both sides intact

@author:
In the other platforms I also already use `self._attr_unique_id = f"{blind.mac}-battery"` in multiple places in a simular way.

Although a nice suggestion, i don't think it is wise to deviate from the pattern for the motion_blinds integrations.
Will keep this in mind for future integrations.