├── .github └── workflows │ └── validate.yaml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── automate │ ├── __init__.py │ ├── base.py │ ├── config_flow.py │ ├── const.py │ ├── cover.py │ ├── helpers.py │ ├── hub.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations │ └── en.json └── hacs.json /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/* 2 | config2/* 3 | sync.sh 4 | 5 | tests/testing_config/deps 6 | tests/testing_config/home-assistant.log 7 | 8 | # hass-release 9 | data/ 10 | .token 11 | 12 | # Hide sublime text stuff 13 | *.sublime-project 14 | *.sublime-workspace 15 | 16 | # Hide some OS X stuff 17 | .DS_Store 18 | .AppleDouble 19 | .LSOverride 20 | Icon 21 | 22 | # Thumbnails 23 | ._* 24 | 25 | # IntelliJ IDEA 26 | .idea 27 | *.iml 28 | 29 | # pytest 30 | .pytest_cache 31 | .cache 32 | 33 | # GITHUB Proposed Python stuff: 34 | *.py[cod] 35 | 36 | # C extensions 37 | *.so 38 | 39 | # Packages 40 | *.egg 41 | *.egg-info 42 | dist 43 | build 44 | eggs 45 | .eggs 46 | parts 47 | bin 48 | var 49 | sdist 50 | develop-eggs 51 | .installed.cfg 52 | lib 53 | lib64 54 | pip-wheel-metadata 55 | 56 | # Logs 57 | *.log 58 | pip-log.txt 59 | 60 | # Unit test / coverage reports 61 | .coverage 62 | .tox 63 | coverage.xml 64 | nosetests.xml 65 | htmlcov/ 66 | test-reports/ 67 | test-results.xml 68 | test-output.xml 69 | 70 | # Translations 71 | *.mo 72 | 73 | # Mr Developer 74 | .mr.developer.cfg 75 | .project 76 | .pydevproject 77 | 78 | .python-version 79 | 80 | # emacs auto backups 81 | *~ 82 | *# 83 | *.orig 84 | 85 | # venv stuff 86 | pyvenv.cfg 87 | pip-selfcheck.json 88 | venv 89 | .venv 90 | Pipfile* 91 | share/* 92 | Scripts/ 93 | 94 | # vimmy stuff 95 | *.swp 96 | *.swo 97 | tags 98 | ctags.tmp 99 | 100 | # vagrant stuff 101 | virtualization/vagrant/setup_done 102 | virtualization/vagrant/.vagrant 103 | virtualization/vagrant/config 104 | 105 | # Visual Studio Code 106 | .vscode/* 107 | !.vscode/cSpell.json 108 | !.vscode/extensions.json 109 | !.vscode/tasks.json 110 | .env 111 | 112 | # Built docs 113 | docs/build 114 | 115 | # Windows Explorer 116 | desktop.ini 117 | /home-assistant.pyproj 118 | /home-assistant.sln 119 | /.vs/* 120 | 121 | # mypy 122 | /.mypy_cache/* 123 | /.dmypy.json 124 | 125 | # Secrets 126 | .lokalise_token 127 | 128 | # monkeytype 129 | monkeytype.sqlite3 130 | 131 | # This is left behind by Azure Restore Cache 132 | tmp_cache 133 | 134 | # python-language-server / Rope 135 | .ropeproject 136 | sync-to-server.sh 137 | NOTES.md 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rollease Acmeda Automate Pulse Hub v2 integration for Home Assistant 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | The Automate Pulse 2 Hub by Rollease Acmeda integration allows you to control and monitor covers via your Automate Pulse v2 Hub (see the [acmeda](/integrations/acmeda) integration for the v1 hub). The integration uses an [API](https://pypi.org/project/aiopulse2/) to directly communicate with hubs on the local network, rather than connecting via the cloud. [See this integration](https://www.home-assistant.io/integrations/acmeda/) if you have a v1 hub. 6 | 7 | Devices are represented as a cover for monitoring and control as well as a sensor for monitoring battery level and signal strength. 8 | 9 | Please keep an eye on the Automate Shades web site on the status of firmware updates here: https://www.automateshades.com/firmware-updates/ 10 | 11 | # Installation 12 | 13 | This integration is designed to be added as a custom [HACS](https://hacs.xyz/) repository. Start with [installing HACS](https://hacs.xyz/docs/installation/prerequisites). 14 | 15 | Once HACS is installed, go to HACS in the sidebar, in the top right click on the 3 dots, select Custom repositories, then enter `https://github.com/sillyfrog/Automate-Pulse-v2` for the repository URL, and select "Integration" for the Category, and click Add. 16 | 17 | Close the window and then click "Explore & Add Repositories" in the bottom right. 18 | 19 | Search for and select the "Rollease Acmeda Automate Pulse Hub v2" integration, then click "Install this repository in HACS" and click "Install". 20 | 21 | Once installed, restart Home Assistant (not strictly required as this uses a Config Flow, but it's good practice). 22 | 23 | Once HA is back up, go to Configuration > Integrations > Add integration, and select "Automate Pulse Hub v2". 24 | 25 | This will prompt for the IP address of the hub and register the covers with Home Assistant. All devices are automatically discovered on the hub and you will have the opportunity to select the area each device is located. 26 | 27 | Once registration is complete, you should see a `cover` and a `sensor` entity for the battery for each device. Additionally there is a disabled sensors for the signal strength. The integration automatically manages the addition/update/removal of any devices connected on the hub at startup, including device names unless manually specified in Home Assistant. 28 | 29 | ## Caveats 30 | 31 | If the IP address for the hub changes, you will need to re-register it with Home Assistant again. To avoid this, set up a DHCP reservation on your router for your hub so that it always has the same IP address. 32 | 33 | The integration has the following limitations: 34 | 35 | - covers with position as well as tilt are not yet supported (I'm not sure if such a product exists). 36 | - the integration doesn't make use of rooms and scenes configured in the hub, use the equivalent functionality in Home Assistant instead. 37 | - when adding new covers, you may need to restart Home Assistant to see the full details _after_ updating the name in the *Pulse 2* app. 38 | 39 | # Debugging 40 | 41 | If you are having issues with the integration, please enable debug logging for the integration by adding the following to your Home Assistant `configuration.yaml` file: 42 | 43 | ```yaml 44 | logger: 45 | default: warning 46 | logs: 47 | aiopulse2: debug 48 | automate: debug 49 | custom_components.automate: debug 50 | ``` 51 | 52 | Then restart Home Assistant and check the logs for any errors and/or provide for debugging. -------------------------------------------------------------------------------- /custom_components/automate/__init__.py: -------------------------------------------------------------------------------- 1 | """The Automate Pulse Hub v2 integration.""" 2 | 3 | import logging 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant 7 | 8 | from .const import DOMAIN 9 | from .hub import PulseHub 10 | 11 | PLATFORMS = ["cover", "sensor"] 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | async def async_setup(hass: HomeAssistant, config: dict) -> bool: 17 | """Set up the Automate Pulse Hub v2 component.""" 18 | hass.data.setdefault(DOMAIN, {}) 19 | return True 20 | 21 | 22 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 23 | """Set up Automate Pulse Hub v2 from a config entry.""" 24 | hub = PulseHub(hass, entry) 25 | 26 | if not await hub.async_setup(): 27 | return False 28 | 29 | hass.data[DOMAIN][entry.entry_id] = hub 30 | 31 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 32 | 33 | return True 34 | 35 | 36 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 37 | """Unload a config entry.""" 38 | hub = hass.data[DOMAIN][entry.entry_id] 39 | 40 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 41 | 42 | if not await hub.async_reset(): 43 | return False 44 | 45 | if unload_ok: 46 | hass.data[DOMAIN].pop(entry.entry_id) 47 | 48 | return unload_ok 49 | -------------------------------------------------------------------------------- /custom_components/automate/base.py: -------------------------------------------------------------------------------- 1 | """Base class for Automate Roller Blinds.""" 2 | 3 | import logging 4 | 5 | import aiopulse2 6 | 7 | from homeassistant.core import callback 8 | from homeassistant.helpers import entity 9 | from homeassistant.helpers.device_registry import async_get as get_dev_reg 10 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 11 | from homeassistant.helpers.entity_registry import async_get as get_ent_reg 12 | 13 | from .const import AUTOMATE_ENTITY_REMOVE, DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class AutomateBase(entity.Entity): 19 | """Base representation of an Automate roller.""" 20 | 21 | def __init__(self, roller: aiopulse2.Roller): 22 | """Initialize the roller.""" 23 | self.roller = roller 24 | 25 | @property 26 | def title(self): 27 | """Return the title of the device shown in the integrations list.""" 28 | return f"{self.roller.name} ({self.roller.devicetype})" 29 | 30 | @property 31 | def available(self) -> bool: 32 | """Return True if roller and hub is available.""" 33 | return self.roller.online and self.roller.hub.connected 34 | 35 | # pylint: disable=no-self-use 36 | def include_entity(self) -> bool: 37 | """Return True (default) if entity should be included. 38 | 39 | Overridden by superclasses. 40 | """ 41 | return True 42 | 43 | async def async_remove_and_unregister(self): 44 | """Unregister from entity and device registry and call entity remove function.""" 45 | _LOGGER.info("Removing %s %s", self.__class__.__name__, self.unique_id) 46 | 47 | ent_registry = get_ent_reg(self.hass) 48 | if self.entity_id in ent_registry.entities: 49 | ent_registry.async_remove(self.entity_id) 50 | 51 | dev_registry = get_dev_reg(self.hass) 52 | device = dev_registry.async_get_device( 53 | identifiers={(DOMAIN, self.unique_id)}, connections=set() 54 | ) 55 | if device is not None: 56 | dev_registry.async_update_device( 57 | device.id, remove_config_entry_id=self.registry_entry.config_entry_id 58 | ) 59 | 60 | await self.async_remove() 61 | 62 | async def async_added_to_hass(self): 63 | """Entity has been added to hass.""" 64 | self.roller.callback_subscribe(self.notify_update) 65 | 66 | self.async_on_remove( 67 | async_dispatcher_connect( 68 | self.hass, 69 | AUTOMATE_ENTITY_REMOVE.format(self.roller.id), 70 | self.async_remove_and_unregister, 71 | ) 72 | ) 73 | 74 | async def async_will_remove_from_hass(self): 75 | """Entity being removed from hass.""" 76 | self.roller.callback_unsubscribe(self.notify_update) 77 | 78 | @callback 79 | def notify_update(self, roller: aiopulse2.Roller): 80 | """Write updated device state information.""" 81 | _LOGGER.debug( 82 | "Device update notification received: %s (%r)", roller.id, roller.name 83 | ) 84 | self.schedule_update_ha_state() 85 | 86 | @property 87 | def should_poll(self): 88 | """Report that Automate entities do not need polling.""" 89 | return False 90 | 91 | @property 92 | def unique_id(self): 93 | """Return the unique ID of this roller.""" 94 | return self.roller.id 95 | 96 | @property 97 | def name(self): 98 | """Return the name of roller.""" 99 | return self.roller.name 100 | 101 | @property 102 | def device_info(self): 103 | """Return the device info.""" 104 | attrs = { 105 | "identifiers": {(DOMAIN, self.roller.id)}, 106 | } 107 | return attrs 108 | -------------------------------------------------------------------------------- /custom_components/automate/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Automate Pulse Hub v2 integration.""" 2 | 3 | import logging 4 | 5 | import aiopulse2 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries 9 | 10 | from .const import DOMAIN # pylint:disable=unused-import 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) 15 | 16 | 17 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 18 | """Handle a config flow for Automate Pulse Hub v2.""" 19 | 20 | VERSION = 1 21 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 22 | 23 | async def async_step_user(self, user_input=None): 24 | """Handle the initial step once we have info from the user.""" 25 | errors = {} 26 | if user_input is not None: 27 | try: 28 | hub = aiopulse2.Hub(user_input["host"]) 29 | await hub.test() 30 | info = {"title": hub.name} 31 | 32 | return self.async_create_entry(title=info["title"], data=user_input) 33 | except Exception: # pylint: disable=broad-except 34 | errors["base"] = "cannot_connect" 35 | 36 | return self.async_show_form( 37 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 38 | ) 39 | -------------------------------------------------------------------------------- /custom_components/automate/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Automate Pulse Hub v2 integration.""" 2 | 3 | DOMAIN = "automate" 4 | 5 | AUTOMATE_HUB_UPDATE = "automate_hub_update_{}" 6 | AUTOMATE_ENTITY_REMOVE = "automate_entity_remove_{}" 7 | -------------------------------------------------------------------------------- /custom_components/automate/cover.py: -------------------------------------------------------------------------------- 1 | """Support for Automate Roller Blinds.""" 2 | 3 | import aiopulse2 4 | from homeassistant.components.cover import ( 5 | ATTR_POSITION, 6 | CoverDeviceClass, 7 | CoverEntity, 8 | CoverEntityFeature, 9 | ) 10 | from homeassistant.core import callback 11 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 12 | 13 | from .base import AutomateBase 14 | from .const import AUTOMATE_HUB_UPDATE, DOMAIN 15 | from .helpers import async_add_automate_entities 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | """Set up the Automate Rollers from a config entry.""" 20 | hub = hass.data[DOMAIN][config_entry.entry_id] 21 | 22 | current = set() 23 | 24 | @callback 25 | def async_add_automate_covers(): 26 | async_add_automate_entities( 27 | hass, AutomateCover, config_entry, current, async_add_entities 28 | ) 29 | 30 | hub.cleanup_callbacks.append( 31 | async_dispatcher_connect( 32 | hass, 33 | AUTOMATE_HUB_UPDATE.format(config_entry.entry_id), 34 | async_add_automate_covers, 35 | ) 36 | ) 37 | 38 | 39 | class AutomateCover(AutomateBase, CoverEntity): 40 | """Representation of a Automate cover device.""" 41 | 42 | _attr_device_class = CoverDeviceClass.SHADE 43 | 44 | @property 45 | def current_cover_position(self): 46 | """Return the current position of the roller blind. 47 | 48 | None is unknown, 0 is closed, 100 is fully open. 49 | """ 50 | position = None 51 | if self.roller.closed_percent is not None: 52 | position = 100 - self.roller.closed_percent 53 | return position 54 | 55 | @property 56 | def current_cover_tilt_position(self): 57 | """Return the current tilt of the roller blind. 58 | 59 | None is unknown, 0 is closed, 100 is fully open. 60 | """ 61 | return None 62 | 63 | @property 64 | def supported_features(self): 65 | """Flag supported features.""" 66 | supported_features = 0 67 | if self.current_cover_position is not None: 68 | supported_features |= ( 69 | CoverEntityFeature.OPEN 70 | | CoverEntityFeature.CLOSE 71 | | CoverEntityFeature.STOP 72 | | CoverEntityFeature.SET_POSITION 73 | ) 74 | if self.current_cover_tilt_position is not None: 75 | supported_features |= ( 76 | CoverEntityFeature.OPEN_TILT 77 | | CoverEntityFeature.CLOSE_TILT 78 | | CoverEntityFeature.STOP_TILT 79 | | CoverEntityFeature.SET_TILT_POSITION 80 | ) 81 | 82 | return supported_features 83 | 84 | @property 85 | def device_info(self): 86 | """Return the device info.""" 87 | attrs = super().device_info 88 | attrs["manufacturer"] = "Automate" 89 | attrs["model"] = self.roller.devicetype 90 | attrs["sw_version"] = self.roller.version 91 | attrs["via_device"] = (DOMAIN, self.roller.hub.id) 92 | attrs["name"] = self.name 93 | return attrs 94 | 95 | @property 96 | def is_opening(self): 97 | """Is cover opening/moving up.""" 98 | return self.roller.action == aiopulse2.MovingAction.up 99 | 100 | @property 101 | def is_closing(self): 102 | """Is cover closing/moving down.""" 103 | return self.roller.action == aiopulse2.MovingAction.down 104 | 105 | @property 106 | def is_closed(self): 107 | """Return if the cover is closed.""" 108 | return self.roller.closed_percent == 100 109 | 110 | async def async_close_cover(self, **kwargs): 111 | """Close the roller.""" 112 | await self.roller.move_down() 113 | 114 | async def async_open_cover(self, **kwargs): 115 | """Open the roller.""" 116 | await self.roller.move_up() 117 | 118 | async def async_stop_cover(self, **kwargs): 119 | """Stop the roller.""" 120 | await self.roller.move_stop() 121 | 122 | async def async_set_cover_position(self, **kwargs): 123 | """Move the roller shutter to a specific position.""" 124 | await self.roller.move_to(100 - kwargs[ATTR_POSITION]) 125 | 126 | async def async_close_cover_tilt(self, **kwargs): 127 | """Close the roller.""" 128 | await self.roller.move_down() 129 | 130 | async def async_open_cover_tilt(self, **kwargs): 131 | """Open the roller.""" 132 | await self.roller.move_up() 133 | 134 | async def async_stop_cover_tilt(self, **kwargs): 135 | """Stop the roller.""" 136 | await self.roller.move_stop() 137 | 138 | async def async_set_cover_tilt(self, **kwargs): 139 | """Tilt the roller shutter to a specific position.""" 140 | await self.roller.move_to(100 - kwargs[ATTR_POSITION]) 141 | -------------------------------------------------------------------------------- /custom_components/automate/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for Automate Pulse.""" 2 | 3 | import logging 4 | 5 | from homeassistant.core import callback 6 | from homeassistant.helpers.device_registry import async_get as get_dev_reg 7 | 8 | from .const import DOMAIN 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | @callback 14 | def async_add_automate_entities( 15 | hass, entity_class, config_entry, current, async_add_entities 16 | ): 17 | """Add any new entities.""" 18 | hub = hass.data[DOMAIN][config_entry.entry_id] 19 | _LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) 20 | 21 | api = hub.api.rollers 22 | 23 | new_items = [] 24 | for unique_id, roller in api.items(): 25 | if unique_id not in current: 26 | _LOGGER.debug("New %s %s", entity_class.__name__, unique_id) 27 | new_item = entity_class(roller) 28 | current.add(unique_id) 29 | if new_item.include_entity(): 30 | new_items.append(new_item) 31 | 32 | async_add_entities(new_items) 33 | 34 | 35 | async def update_devices(hass, config_entry, api): 36 | """Tell hass that device info has been updated.""" 37 | dev_registry = get_dev_reg(hass) 38 | 39 | for api_item in api.values(): 40 | # Update Device name 41 | device = dev_registry.async_get_device( 42 | identifiers={(DOMAIN, api_item.id)}, connections=set() 43 | ) 44 | if device is not None: 45 | dev_registry.async_update_device( 46 | device.id, 47 | name=api_item.name, 48 | ) 49 | -------------------------------------------------------------------------------- /custom_components/automate/hub.py: -------------------------------------------------------------------------------- 1 | """Code to handle a Pulse Hub.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | 8 | import aiopulse2 9 | from homeassistant.helpers import device_registry 10 | from homeassistant.helpers.dispatcher import async_dispatcher_send 11 | 12 | from .const import AUTOMATE_ENTITY_REMOVE, AUTOMATE_HUB_UPDATE 13 | from .helpers import update_devices 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class PulseHub: 19 | """Manages a single Pulse Hub.""" 20 | 21 | def __init__(self, hass, config_entry): 22 | """Initialize the system.""" 23 | self.config_entry = config_entry 24 | self.hass = hass 25 | self.api: aiopulse2.Hub | None = None 26 | self.tasks = [] 27 | self.current_rollers = {} 28 | self.cleanup_callbacks = [] 29 | self._entered_into_device_registry = False 30 | 31 | @property 32 | def title(self): 33 | """Return the title of the hub shown in the integrations list.""" 34 | return f"{self.api.name} ({self.api.host})" 35 | 36 | @property 37 | def host(self): 38 | """Return the host of this hub.""" 39 | return self.config_entry.data["host"] 40 | 41 | async def async_setup(self): 42 | """Set up a hub based on host parameter.""" 43 | host = self.host 44 | 45 | hub = aiopulse2.Hub(host, propagate_callbacks=True) 46 | 47 | self.api = hub 48 | 49 | hub.callback_subscribe(self.async_notify_update) 50 | self.tasks.append(asyncio.create_task(hub.run())) 51 | 52 | _LOGGER.debug("Hub setup complete") 53 | return True 54 | 55 | async def async_reset(self): 56 | """Reset this hub to default state.""" 57 | for cleanup_callback in self.cleanup_callbacks: 58 | cleanup_callback() 59 | 60 | # If not setup 61 | if self.api is None: 62 | return False 63 | 64 | self.api.callback_unsubscribe(self.async_notify_update) 65 | await self.api.stop() 66 | del self.api 67 | self.api = None 68 | 69 | # Wait for any running tasks to complete 70 | await asyncio.wait(self.tasks) 71 | 72 | return True 73 | 74 | async def async_notify_update(self, hub=None): 75 | """Evaluate entities when hub reports that update has occurred.""" 76 | _LOGGER.debug("Hub %s updated", self.title) 77 | 78 | # Check we have an ID, if not, wait for further updates 79 | if not self.api.id: 80 | _LOGGER.debug("Hub ID not yet available") 81 | return 82 | 83 | if not self._entered_into_device_registry: 84 | _LOGGER.debug("Entering Hub %s device registry", self.title) 85 | 86 | dev_registry = device_registry.async_get(self.hass) 87 | dev_registry.async_get_or_create( 88 | config_entry_id=self.config_entry.entry_id, 89 | identifiers={("automate", self.api.id)}, 90 | manufacturer="Automate", 91 | model="Pulse V2 Hub", 92 | name=f"Pulse V2 Hub ({self.api.host})", 93 | ) 94 | self._entered_into_device_registry = True 95 | 96 | await update_devices(self.hass, self.config_entry, self.api.rollers) 97 | self.hass.config_entries.async_update_entry(self.config_entry, title=self.title) 98 | 99 | async_dispatcher_send( 100 | self.hass, AUTOMATE_HUB_UPDATE.format(self.config_entry.entry_id) 101 | ) 102 | 103 | for unique_id in list(self.current_rollers): 104 | if unique_id not in self.api.rollers: 105 | _LOGGER.debug("Notifying remove of %s", unique_id) 106 | self.current_rollers.pop(unique_id) 107 | async_dispatcher_send( 108 | self.hass, AUTOMATE_ENTITY_REMOVE.format(unique_id) 109 | ) 110 | -------------------------------------------------------------------------------- /custom_components/automate/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "automate", 3 | "name": "Automate Pulse Hub v2", 4 | "version": "0.12.0", 5 | "config_flow": true, 6 | "iot_class": "local_push", 7 | "documentation": "https://github.com/sillyfrog/Automate-Pulse-v2", 8 | "issue_tracker": "https://github.com/sillyfrog/Automate-Pulse-v2/issues", 9 | "requirements": ["aiopulse2==0.10.0"], 10 | "codeowners": ["@sillyfrog"] 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/automate/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Automate Roller Blind Batteries.""" 2 | 3 | from homeassistant.components.sensor import ( 4 | SensorDeviceClass, 5 | SensorEntity, 6 | SensorStateClass, 7 | ) 8 | from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 11 | 12 | from .base import AutomateBase 13 | from .const import AUTOMATE_HUB_UPDATE, DOMAIN 14 | from .helpers import async_add_automate_entities 15 | 16 | 17 | async def async_setup_entry(hass, config_entry, async_add_entities): 18 | """Set up the Automate Rollers from a config entry.""" 19 | hub = hass.data[DOMAIN][config_entry.entry_id] 20 | 21 | current_battery = set() 22 | current_signal = set() 23 | 24 | @callback 25 | def async_add_automate_sensors(): 26 | async_add_automate_entities( 27 | hass, AutomateBattery, config_entry, current_battery, async_add_entities 28 | ) 29 | async_add_automate_entities( 30 | hass, AutomateSignal, config_entry, current_signal, async_add_entities 31 | ) 32 | 33 | hub.cleanup_callbacks.append( 34 | async_dispatcher_connect( 35 | hass, 36 | AUTOMATE_HUB_UPDATE.format(config_entry.entry_id), 37 | async_add_automate_sensors, 38 | ) 39 | ) 40 | 41 | 42 | class AutomateBattery(AutomateBase, SensorEntity): 43 | """Representation of a Automate cover battery sensor.""" 44 | 45 | _attr_device_class = SensorDeviceClass.BATTERY 46 | _attr_state_class = SensorStateClass.MEASUREMENT 47 | _attr_native_unit_of_measurement = PERCENTAGE 48 | 49 | @property 50 | def name(self): 51 | """Return the name of roller Battery.""" 52 | if super().name is None: 53 | return None 54 | return f"{super().name} Battery" 55 | 56 | @property 57 | def state(self): 58 | """Return the state of the device battery.""" 59 | return self.roller.battery_percent 60 | 61 | @property 62 | def unique_id(self): 63 | """Return a unique identifier for this device.""" 64 | return f"{self.roller.id}_battery" 65 | 66 | @property 67 | def extra_state_attributes(self): 68 | """Additional information about the battery state.""" 69 | attrs = super().extra_state_attributes 70 | if attrs is None: 71 | attrs = {} 72 | else: 73 | attrs = attrs.copy() 74 | attrs[ATTR_VOLTAGE] = self.roller.battery 75 | return attrs 76 | 77 | def include_entity(self) -> bool: 78 | """Return True if roller has a battery.""" 79 | return self.roller.has_battery 80 | 81 | 82 | class AutomateSignal(AutomateBase, SensorEntity): 83 | """Representation of a Automate cover WiFi signal sensor.""" 84 | 85 | _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH 86 | _attr_state_class = SensorStateClass.MEASUREMENT 87 | _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS 88 | _attr_entity_registry_enabled_default = False 89 | 90 | @property 91 | def name(self): 92 | """Return the name of roller.""" 93 | if super().name is None: 94 | return None 95 | return f"{super().name} Signal" 96 | 97 | @property 98 | def state(self): 99 | """Return the state of the device signal strength.""" 100 | return self.roller.signal 101 | 102 | @property 103 | def unique_id(self): 104 | """Return a unique identifier for this device.""" 105 | return f"{self.roller.id}_signal" 106 | -------------------------------------------------------------------------------- /custom_components/automate/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "host": "[%key:common::config_flow::data::host%]" 7 | } 8 | } 9 | }, 10 | "error": { 11 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 12 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 13 | "unknown": "[%key:common::config_flow::error::unknown%]" 14 | }, 15 | "abort": { 16 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /custom_components/automate/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "host": "Host" 15 | } 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Rollease Acmeda Automate Pulse Hub v2", 3 | "render_readme": true, 4 | "iot_class": [ 5 | "Local Polling", 6 | "Local Push" 7 | ] 8 | } --------------------------------------------------------------------------------