├── .gitignore ├── LICENSE ├── README.md ├── msp_integration_101_intermediate ├── README.md ├── __init__.py ├── api.py ├── base.py ├── binary_sensor.py ├── config_flow.py ├── const.py ├── coordinator.py ├── fan.py ├── light.py ├── manifest.json ├── sensor.py ├── services.py ├── services.yaml ├── strings.json ├── switch.py └── translations │ └── en.json ├── msp_integration_101_template ├── README.md ├── __init__.py ├── api.py ├── binary_sensor.py ├── config_flow.py ├── const.py ├── coordinator.py ├── manifest.json ├── sensor.py ├── strings.json └── translations │ └── en.json └── msp_push_data_example ├── __init__.py ├── api.py ├── binary_sensor.py ├── config_flow.py ├── const.py ├── coordinator.py ├── manifest.json ├── sensor.py ├── strings.json └── translations └── en.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | wiserapitest.py 3 | *._DS_Store 4 | *__pycache__ 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | .idea 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | *.DS_Store 111 | .vscode/settings.json 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mark Parker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example Home Assistant Integration 2 | 3 | These example integrations attempt to give a good basic framework for those wishing to develop a HA custom integration but have little knowledge of how to start. 4 | 5 | There are 3 main integrations here as follows: 6 | 7 | ## Integration 101 Template 8 | 9 | This is (in my view) the basic building blocks to get started on your own integration. It will need some modifications by you to suit your own specifics but it shows how to: 10 | 11 | - Integration Initialisation 12 | - The basic steps to initialise your integration 13 | - Adding a listener to reload your integration if options change 14 | - Adding the option to delete devices in the UI 15 | 16 | - Config Flow 17 | - Create a basic config flow to setup your integration via the UI 18 | - Create a config flow to reconfigure your integration 19 | - Creat an options flow to allow setting of optional parameters 20 | 21 | - Using a DataUpdateCoordinator 22 | - How to setup a data coordinator to manage communication with your api 23 | - How to store and use that data across your entity platforms 24 | 25 | - Entity platforms 26 | - Examples of adding binary sensors 27 | - Examples of adding sensors 28 | 29 | ## Integration 101 Intermediate 30 | 31 | This builds on the Integration 101 Template and adds some more advanced functionality to help make your integration more functional: 32 | 33 | - Config Flow 34 | - Multi-step flows 35 | - Using selectors in your flows 36 | - Using data from your api in a config flow 37 | 38 | - Entity Platforms 39 | - Setting up switches, lights and fans as examples of entites that can control your devices 40 | - Using `_attr_*` attributes in your entity classes to reduce code size 41 | 42 | - Base Entity 43 | - Using a base entity from which to inherit all your entity classes to make you code smaller and neater 44 | 45 | - Services 46 | - Entity services to call against your entities 47 | - Integration services to call against your integration/api 48 | 49 | ## Integration 101 Advanced 50 | 51 | This is currently a work in progress but will add the following to the Integration 101 Intermediate example. 52 | 53 | - Translations 54 | - Websockets 55 | - Firing Events 56 | - Device Triggers for automations 57 | - And maybe more... (please raise and issue to see a subject covered) 58 | 59 | 60 | ## Can I Run Them? 61 | 62 | Yes, these are fully functioning integrations (I mean they don't do anything as the api is mocked), that will provide a config flow to add the integration and create devices with entities. 63 | 64 | In the Integration 101 Template, the mocked api is designed to randomly change the door open/closed status and the temp sensor values, so it looks like it is getting actual readings from real devices. The Integration 101 Intermedate and Advanced don't do this. 65 | 66 | NOTE: For the purposes of being a good example, the api only accepts a specific username and password. These are prepopulated when adding the integration (do not do this with your own real world integrations!). If you modify these, you will get an Authorisation error (but thats what should happen if they are wrong!). 67 | 68 | ## How Do I Install This? 69 | 70 | If you are going to start developing integrations, there are some things you need to learn and do first. These are: 71 | 72 | 1. **Create a HA development environment** 73 | 74 | Investing the time to do this at the start will reward you with all that time back and more later. I highly recommend using the VSCode Dev Container method. 75 | 76 | [Developing with Visual Studio Code and Devcontainer](https://developers.home-assistant.io/docs/development_environment#developing-with-visual-studio-code--devcontainer) 77 | 78 | 2. **Create a github account (it's free) and fork this repository** 79 | 80 | That way, you can modify this example to build your own and have a nice safe place to store it, so you do not risk loosing the work you put into it. Again, VSCode will make it very easy to commit changes to your repository, if you take the time to set it up. 81 | 82 | 3. **Obviously, you are going to need some level of Python knowledge** 83 | 84 | If you are like me, walking through existing working examples, is a good way to learn and there is also much on google. The biggest learning curve (IMHO), is how to get going with an integration. The development documentation is pretty good in places but I think much more of a reference guide than a step by step getting you going. That is why I decided to put some time to create this working example that includes the key elements you will need for pretty much any custom integration. 85 | 86 | So, once you have ticked off your todo list above, you can install one of these examples by cloning from your fork to your machine. You can copy all the folders into your config\custom_components folder or just choose one. Once you are a bit more familiar with the development process and the required tools, see advanced note below on how I do it to make my life easier. 87 | 88 | ## Starting to Code Your Own Integration 89 | 90 | These example integrations are basic foundations for an integration. It is unlikely that you can just copy it, make a few tweaks and off you go. However, it tries to demonstrate many of the HA concepts that most integrations would need. 91 | 92 | If you are starting out using this example, I would recommend taking the following path. In each step, add logging output to help you see what is going on - use simple text to show you have reached a point in a function, output api responses, variable values etc. 93 | 94 | 1. Establish the link to your api and configure the minimum set of parameters in your config flow to connect to it. 95 | 2. Think about the data format you will have from your api and develop your DataUpdateCoordinator to capture and store that data in a way that makes it easier to use in your entities. 96 | 3. Start adding your entities (and device definitions), one type at a time to ensure you are happy that they are being created/functioning correctly as you go. 97 | 4. Now you can add more complexity with more config flow parameters, config flow options etc, automation triggers etc. 98 | 99 | ## Breaking Down The Code 100 | 101 | Each example folder has a README file in which I have tried to explain each of the main elements. I have also commented the code to provide explanaitions of what is happening and what you should change for your own integration. 102 | 103 | ## Advanced Notes 104 | 105 | ### Advanced How Do I Install This? 106 | 107 | In order to simplify my workflow when writing custom components, I have a directory on my machine that all developed custom components have a folder under. This allows me to manage my git workflow via this folder seperate from the HA Dev Container as there are more files to include in an overall repository that what goes into custom_components (this README.md for example!). 108 | 109 | I then modify my devcontainer.json file (the one provided by HA to create you dev container) and mount this developed custom components directory to config/share within the dev container. 110 | From that I can create a symlink to place the correct folder in custom components. 111 | 112 | Sounds confusing?? Ok....So I have a folder structure: 113 | 114 | ```text 115 | development 116 | |_integrationAFolder 117 | |_custom_components 118 | |_integrationA 119 | |_integrationBFolder 120 | |_custom_components 121 | |_integrationA 122 | |-etc 123 | ``` 124 | 125 | The integrationXFolder level is the one that relates to your github repo. 126 | 127 | Then to be able to access this in my VSCode Dev Container, I add the following to the devcontainer.json file, just under the RunArgs line (currently around line 16). 128 | 129 | ```text 130 | "mounts": [ 131 | "source=${localEnv:HOME}/development,target=${containerWorkspaceFolder}/share/development,type=bind", 132 | ], 133 | ``` 134 | 135 | You will then need to rebuild your dev container and this folder will now appear under config/share in your dev container. 136 | 137 | To then symlink my custom integration into the config/custom_components folder, do the following in a terminal session inside the dev container. 138 | 139 | ```text 140 | cd /workspaces/core/config/custom_components 141 | ln -s /workspaces/core/share/development/integrationAFolder/custom_components/integrationA integrationA 142 | ``` 143 | 144 | And hey presto, when you run (or restart if already running) the Home Assistant server in your dev container, the integration will load and you will be able to add it via Devices & Services. 145 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/README.md: -------------------------------------------------------------------------------- 1 | # Integration 101 Intermediate Example 2 | 3 | This is a slightly more advanced integration built on top of the Integration 101 Template. If you are new to writing integrations, it is highly recommended to start with the Integration 101 Template and get your integration working to talk to your api and generate some sensor entities, before adding some of these more advanced elements to control functions of your api/devices. 4 | 5 | Even if you are more adept, it still might be worth reviewing the Integration 101 Template first to familiarise yourself with how it is built up to this intermediate example. 6 | 7 | ### What does it show? 8 | 9 | It adds the following things to the original basic example. 10 | 11 | - More advanced config flow, demonstrating multi-step configs, using selectors and using your api data in the flow. 12 | - Services - both integration and entity services. More about the differences of these below. 13 | - A base entity class that all entity types inherit, to show how you can save code for common entity properties. 14 | - Examples of using _attr_* attributes in your entity platform classes to set entity properties and reduce code. 15 | - Switches, lights and fan entity types 16 | 17 | It also allows you to play around with this code to see how those changes impact the integration, knowing you are working from a good start point. 18 | 19 | ### Does it work? 20 | 21 | Yes, it is fully functional in the Home Assistant UI, except the api is mocked, so doesn't do anything for real! However, if you use the switches and lights, you will see other sensors change as they would in a real situation. 22 | 23 | ## Further Explanaitions 24 | 25 | ## Services 26 | 27 | So, as stated above, there are 2 types of services, Integration Services and Entity Services. They are pretty similar in creating a service but have nuances in use and are setup slightly differently. 28 | 29 | However, in both of these service types, you need to have a services.yaml file with a set of keys that define some properties like name, description etc and also definitions for your fields which drive how they appear in the UI Developer console. 30 | 31 | This Intermediate Example has 3 services: 32 | - 1 Entity Service 33 | - 1 Integration Service 34 | - 1 Integration Service with a Response 35 | 36 | along with an example service.yaml file to match, so you can see how to create each one in code. 37 | 38 | NOTE: Home Assistant does come with many built in services to control many functions of devices. Before diving in to create your own, make sure that one doesn't already exist, otherwise you can make your integration more complicated to use by not supporting the serivces expected by your users. 39 | 40 | ### Integration Services 41 | 42 | These are setup at your integration level and perform any function you wish. 43 | 44 | Unlike Entity Serivces, you can have a service that does not require an entity to be selected. For example, if you wanted to send a command to your api, your service can just require the user select the command and it will send it. They can be used to perform functions on some of your entities but it is easier to use Entity services for that. 45 | 46 | ### Entity Services 47 | 48 | These are setup within your entity platform and are specifically for calling a service on an entity or group of entities. 49 | 50 | These services automatially request an entity id, device, area or label and as such, you could not perform the same example as above. 51 | 52 | The service will be called on entities that match the selected target (area, device, entity or label) and therefore it can run on multiple entities at the same time. If that entity does not have the function specified in the service, nothing will happen. Ie, no action will be performed and no error will be raised. You can this service being called on multiple entities in this example by adding the same label to the 2 lights or put them in the same area and select that label or area when calling the service. Turn on debug logging to see the devices that got updated. 53 | 54 | If you want to use this Entity Service on multiple platforms (ie lights and switches), you have to define it in each platform separately. It is also recommended that if you do this, make the service definition exactly the same or you will run into issues. 55 | 56 | An example of use for this is that you want to send a command to one or many lights on your integration, such as setting an off timer (as per our example in the code). In which case, you would pick an entity relating to a specific light, or select an area to send the command to all lights in that area. 57 | 58 | See within lights.py for a commented code example of this. -------------------------------------------------------------------------------- /msp_integration_101_intermediate/__init__.py: -------------------------------------------------------------------------------- 1 | """The Integration 101 Template integration. 2 | 3 | This shows how to use the requests library to get and use data from an external device over http and 4 | uses this data to create some binary sensors (of a generic type) and sensors (of multiple types). 5 | 6 | Things you need to change 7 | 1. Change the api call in the coordinator async_update_data and the config flow validate input methods. 8 | 2. The constants in const.py that define the api data parameters to set sensors for (and the sensor async_setup_entry logic) 9 | 3. The specific sensor types to match your requirements. 10 | """ 11 | 12 | from __future__ import annotations 13 | 14 | from collections.abc import Callable 15 | from dataclasses import dataclass 16 | import logging 17 | 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import Platform 20 | from homeassistant.core import HomeAssistant 21 | from homeassistant.exceptions import ConfigEntryNotReady 22 | from homeassistant.helpers.device_registry import DeviceEntry 23 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 24 | 25 | from .const import DOMAIN 26 | from .coordinator import ExampleCoordinator 27 | from .services import ExampleServicesSetup 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | # ---------------------------------------------------------------------------- 32 | # A list of the different platforms we wish to setup. 33 | # Add or remove from this list based on your specific need 34 | # of entity platform types. 35 | # ---------------------------------------------------------------------------- 36 | PLATFORMS: list[Platform] = [ 37 | Platform.BINARY_SENSOR, 38 | Platform.FAN, 39 | Platform.LIGHT, 40 | Platform.SENSOR, 41 | Platform.SWITCH, 42 | ] 43 | 44 | type MyConfigEntry = ConfigEntry[RuntimeData] 45 | 46 | 47 | @dataclass 48 | class RuntimeData: 49 | """Class to hold your data.""" 50 | 51 | coordinator: DataUpdateCoordinator 52 | cancel_update_listener: Callable 53 | 54 | 55 | async def async_setup_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 56 | """Set up Example Integration from a config entry.""" 57 | 58 | # ---------------------------------------------------------------------------- 59 | # Initialise the coordinator that manages data updates from your api. 60 | # This is defined in coordinator.py 61 | # ---------------------------------------------------------------------------- 62 | coordinator = ExampleCoordinator(hass, config_entry) 63 | 64 | # ---------------------------------------------------------------------------- 65 | # Perform an initial data load from api. 66 | # async_config_entry_first_refresh() is special in that it does not log errors 67 | # if it fails. 68 | # ---------------------------------------------------------------------------- 69 | await coordinator.async_config_entry_first_refresh() 70 | 71 | # ---------------------------------------------------------------------------- 72 | # Test to see if api initialised correctly, else raise ConfigNotReady to make 73 | # HA retry setup. 74 | # Change this to match how your api will know if connected or successful 75 | # update. 76 | # ---------------------------------------------------------------------------- 77 | if not coordinator.data: 78 | raise ConfigEntryNotReady 79 | 80 | # ---------------------------------------------------------------------------- 81 | # Initialise a listener for config flow options changes. 82 | # This will be removed automatically if the integraiton is unloaded. 83 | # See config_flow for defining an options setting that shows up as configure 84 | # on the integration. 85 | # If you do not want any config flow options, no need to have listener. 86 | # ---------------------------------------------------------------------------- 87 | cancel_update_listener = config_entry.async_on_unload( 88 | config_entry.add_update_listener(_async_update_listener) 89 | ) 90 | 91 | # ---------------------------------------------------------------------------- 92 | # Add the coordinator and update listener to your config entry to make 93 | # accessible throughout your integration 94 | # ---------------------------------------------------------------------------- 95 | config_entry.runtime_data = RuntimeData(coordinator, cancel_update_listener) 96 | 97 | # ---------------------------------------------------------------------------- 98 | # Setup platforms (based on the list of entity types in PLATFORMS defined above) 99 | # This calls the async_setup method in each of your entity type files. 100 | # ---------------------------------------------------------------------------- 101 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 102 | 103 | # ---------------------------------------------------------------------------- 104 | # Setup global services 105 | # This can be done here but included in a seperate file for ease of reading. 106 | # See also light.py for entity services examples 107 | # ---------------------------------------------------------------------------- 108 | ExampleServicesSetup(hass, config_entry) 109 | 110 | # Return true to denote a successful setup. 111 | return True 112 | 113 | 114 | async def _async_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): 115 | """Handle config options update. 116 | 117 | Reload the integration when the options change. 118 | Called from our listener created above. 119 | """ 120 | await hass.config_entries.async_reload(config_entry.entry_id) 121 | 122 | 123 | async def async_remove_config_entry_device( 124 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 125 | ) -> bool: 126 | """Delete device if selected from UI. 127 | 128 | Adding this function shows the delete device option in the UI. 129 | Remove this function if you do not want that option. 130 | You may need to do some checks here before allowing devices to be removed. 131 | """ 132 | return True 133 | 134 | 135 | async def async_unload_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 136 | """Unload a config entry. 137 | 138 | This is called when you remove your integration or shutdown HA. 139 | If you have created any custom services, they need to be removed here too. 140 | """ 141 | 142 | # Unload services 143 | for service in hass.services.async_services_for_domain(DOMAIN): 144 | hass.services.async_remove(DOMAIN, service) 145 | 146 | # Unload platforms and return result 147 | return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 148 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/api.py: -------------------------------------------------------------------------------- 1 | """API Placeholder. 2 | 3 | You should create your api seperately and have it hosted on PYPI. This is included here for the sole purpose 4 | of making this example code executable. 5 | """ 6 | 7 | from copy import deepcopy 8 | import logging 9 | from typing import Any 10 | 11 | import requests 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | MOCK_DATA = [ 16 | { 17 | "device_id": 1, 18 | "device_type": "SOCKET", 19 | "device_name": "Lounge Socket 1", 20 | "device_uid": "0123-4567-8910-1112", 21 | "software_version": "2.13", 22 | "state": "ON", 23 | "voltage": 239, 24 | "current": 1.2, 25 | "energy_delivered": 3247, 26 | "last_reboot": "2024-01-01T10:04:23Z", 27 | }, 28 | { 29 | "device_id": 2, 30 | "device_type": "SOCKET", 31 | "device_name": "Lounge Socket 2", 32 | "device_uid": "0123-4567-8910-3723", 33 | "software_version": "2.13", 34 | "state": "ON", 35 | "voltage": 237, 36 | "current": 0.1, 37 | "energy_delivered": 634, 38 | "last_reboot": "2024-03-12T17:33:01Z", 39 | }, 40 | { 41 | "device_id": 3, 42 | "device_type": "ON_OFF_LIGHT", 43 | "device_name": "Lounge Light", 44 | "device_uid": "0123-4567-8910-4621", 45 | "software_version": "1.30", 46 | "state": "ON", 47 | "voltage": 237, 48 | "current": 0.2, 49 | "off_timer": "00:00", 50 | "last_reboot": "2023-11-11T09:03:01Z", 51 | }, 52 | { 53 | "device_id": 4, 54 | "device_type": "DIMMABLE_LIGHT", 55 | "device_name": "Kitchen Light", 56 | "device_uid": "0123-4967-8940-4691", 57 | "software_version": "1.35", 58 | "state": "ON", 59 | "brightness": 85, 60 | "voltage": 237, 61 | "current": 1.275, 62 | "off_timer": "00:00", 63 | "last_reboot": "2023-11-11T09:03:01Z", 64 | }, 65 | { 66 | "device_id": 5, 67 | "device_type": "TEMP_SENSOR", 68 | "device_name": "Kitchen Temp Sensor", 69 | "device_uid": "0123-4567-8910-9254", 70 | "software_version": "3.00", 71 | "temperature": 18.3, 72 | "last_reboot": "2024-05-02T19:46:00Z", 73 | }, 74 | { 75 | "device_id": 6, 76 | "device_type": "TEMP_SENSOR", 77 | "device_name": "Lounge Temp Sensor", 78 | "device_uid": "0123-4567-8910-9255", 79 | "software_version": "1.30", 80 | "temperature": 19.2, 81 | "last_reboot": "2024-03-12T17:33:01Z", 82 | }, 83 | { 84 | "device_id": 7, 85 | "device_type": "CONTACT_SENSOR", 86 | "device_name": "Kitchen Door Sensor", 87 | "device_uid": "0123-4567-8911-6295", 88 | "software_version": "1.41", 89 | "state": "OPEN", 90 | }, 91 | { 92 | "device_id": 8, 93 | "device_type": "CONTACT_SENSOR", 94 | "device_name": "Lounge Door Sensor", 95 | "device_uid": "0123-4567-8911-1753", 96 | "software_version": "1.41", 97 | "state": "CLOSED", 98 | }, 99 | { 100 | "device_id": 9, 101 | "device_type": "FAN", 102 | "device_name": "Lounge Fan", 103 | "device_uid": "0123-4599-1541-1793", 104 | "software_version": "2.11", 105 | "state": "ON", 106 | "oscillating": "OFF", 107 | "speed": 2, 108 | }, 109 | ] 110 | 111 | 112 | class API: 113 | """Class for example API.""" 114 | 115 | def __init__(self, host: str, user: str, pwd: str, mock: bool = False) -> None: 116 | """Initialise.""" 117 | self.host = host 118 | self.user = user 119 | self.pwd = pwd 120 | 121 | # For getting and setting the mock data 122 | self.mock = mock 123 | self.mock_data = deepcopy(MOCK_DATA) 124 | 125 | # Mock auth error if user != test and pwd != 1234 126 | if mock and (self.user != "test" or self.pwd != "1234"): 127 | raise APIAuthError("Invalid credentials!") 128 | 129 | def get_data(self) -> list[dict[str, Any]]: 130 | """Get api data.""" 131 | if self.mock: 132 | return self.get_mock_data() 133 | try: 134 | r = requests.get(f"http://{self.host}/api", timeout=10) 135 | return r.json() 136 | except requests.exceptions.ConnectTimeout as err: 137 | raise APIConnectionError("Timeout connecting to api") from err 138 | 139 | def set_data(self, device_id: int, parameter: str, value: Any) -> bool: 140 | """Set api data.""" 141 | if self.mock: 142 | return self.set_mock_data(device_id, parameter, value) 143 | try: 144 | data = {parameter, value} 145 | r = requests.post( 146 | f"http://{self.host}/api/{device_id}", json=data, timeout=10 147 | ) 148 | except requests.exceptions.ConnectTimeout as err: 149 | raise APIConnectionError("Timeout connecting to api") from err 150 | else: 151 | return r.status_code == 200 152 | 153 | # ---------------------------------------------------------------------------- 154 | # The below methods are used to mimic a real api for the example that changes 155 | # its values based on commands from the switches and lights and obvioulsy will 156 | # not be needed wiht your real api. 157 | # ---------------------------------------------------------------------------- 158 | def get_mock_data(self) -> dict[str, Any]: 159 | """Get mock api data.""" 160 | return self.mock_data 161 | 162 | def set_mock_data(self, device_id: int, parameter: str, value: Any) -> bool: 163 | """Update mock data.""" 164 | try: 165 | device = [ 166 | devices 167 | for devices in self.mock_data 168 | if devices.get("device_id") == device_id 169 | ][0] 170 | except IndexError: 171 | # Device id does not exist 172 | return False 173 | 174 | other_devices = [ 175 | devices 176 | for devices in self.mock_data 177 | if devices.get("device_id") != device_id 178 | ] 179 | 180 | # Modify device parameter 181 | if device.get(parameter): 182 | device[parameter] = value 183 | else: 184 | # Parameter does not exist on device 185 | return False 186 | 187 | # For sockets and lights, modify current values when off/on to mimic 188 | # real api and show changing sensors from your actions. 189 | if device["device_type"] in ["SOCKET", "ON_OFF_LIGHT", "DIMMABLE_LIGHT"]: 190 | if value == "OFF": 191 | device["current"] = 0 192 | else: 193 | original_device = [ 194 | devices 195 | for devices in MOCK_DATA 196 | if devices.get("device_id") == device_id 197 | ][0] 198 | device["current"] = original_device.get("current") 199 | 200 | # For dimmable lights if brightness is set to > 0, set to on 201 | if device["device_type"] == "DIMMABLE_LIGHT": 202 | if parameter == "brightness": 203 | if value > 0: 204 | device["state"] = "ON" 205 | device["current"] = value * 0.015 206 | else: 207 | device["state"] = "OFF" 208 | 209 | if parameter == "state": 210 | if value == "ON": 211 | device["brightness"] = 100 212 | else: 213 | device["brightness"] = 0 214 | 215 | _LOGGER.debug("Device Updated: %s", device) 216 | 217 | # Update mock data 218 | self.mock_data = other_devices 219 | self.mock_data.append(device) 220 | return True 221 | 222 | 223 | class APIAuthError(Exception): 224 | """Exception class for auth error.""" 225 | 226 | 227 | class APIConnectionError(Exception): 228 | """Exception class for connection error.""" 229 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/base.py: -------------------------------------------------------------------------------- 1 | """Base entity which all other entity platform classes can inherit. 2 | 3 | As all entity types have a common set of properties, you can 4 | create a base entity like this and inherit it in all your entity platforms. 5 | 6 | This just makes your code more efficient and is totally optional. 7 | 8 | See each entity platform (ie sensor.py, switch.py) for how this is inheritted 9 | and what additional properties and methods you need to add for each entity type. 10 | 11 | """ 12 | 13 | import logging 14 | from typing import Any 15 | 16 | from homeassistant.core import callback 17 | from homeassistant.helpers.device_registry import DeviceInfo 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .const import DOMAIN 21 | from .coordinator import ExampleCoordinator 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class ExampleBaseEntity(CoordinatorEntity): 27 | """Base Entity Class. 28 | 29 | This inherits a CoordinatorEntity class to register your entites to be updated 30 | by your DataUpdateCoordinator when async_update_data is called, either on the scheduled 31 | interval or by forcing an update. 32 | """ 33 | 34 | coordinator: ExampleCoordinator 35 | 36 | # ---------------------------------------------------------------------------- 37 | # Using attr_has_entity_name = True causes HA to name your entities with the 38 | # device name and entity name. Ie if your name property of your entity is 39 | # Voltage and this entity belongs to a device, Lounge Socket, this will name 40 | # your entity to be sensor.lounge_socket_voltage 41 | # 42 | # It is highly recommended (by me) to use this to give a good name structure 43 | # to your entities. However, totally optional. 44 | # ---------------------------------------------------------------------------- 45 | _attr_has_entity_name = True 46 | 47 | def __init__( 48 | self, coordinator: ExampleCoordinator, device: dict[str, Any], parameter: str 49 | ) -> None: 50 | """Initialise entity.""" 51 | super().__init__(coordinator) 52 | self.device = device 53 | self.device_id = device["device_id"] 54 | self.parameter = parameter 55 | 56 | @callback 57 | def _handle_coordinator_update(self) -> None: 58 | """Update sensor with latest data from coordinator.""" 59 | # This method is called by your DataUpdateCoordinator when a successful update runs. 60 | self.device = self.coordinator.get_device(self.device_id) 61 | _LOGGER.debug( 62 | "Updating device: %s, %s", 63 | self.device_id, 64 | self.coordinator.get_device_parameter(self.device_id, "device_name"), 65 | ) 66 | self.async_write_ha_state() 67 | 68 | @property 69 | def device_info(self) -> DeviceInfo: 70 | """Return device information.""" 71 | 72 | # ---------------------------------------------------------------------------- 73 | # Identifiers are what group entities into the same device. 74 | # If your device is created elsewhere, you can just specify the indentifiers 75 | # parameter to link an entity to a device. 76 | # If your device connects via another device, add via_device parameter with 77 | # the indentifiers of that device. 78 | # 79 | # Device identifiers should be unique, so use your integration name (DOMAIN) 80 | # and a device uuid, mac address or some other unique attribute. 81 | # ---------------------------------------------------------------------------- 82 | return DeviceInfo( 83 | name=self.coordinator.get_device_parameter(self.device_id, "device_name"), 84 | manufacturer="ACME Manufacturer", 85 | model=str( 86 | self.coordinator.get_device_parameter(self.device_id, "device_type") 87 | ) 88 | .replace("_", " ") 89 | .title(), 90 | sw_version=self.coordinator.get_device_parameter( 91 | self.device_id, "software_version" 92 | ), 93 | identifiers={ 94 | ( 95 | DOMAIN, 96 | self.coordinator.get_device_parameter(self.device_id, "device_uid"), 97 | ) 98 | }, 99 | ) 100 | 101 | @property 102 | def name(self) -> str: 103 | """Return the name of the sensor.""" 104 | return self.parameter.replace("_", " ").title() 105 | 106 | @property 107 | def unique_id(self) -> str: 108 | """Return unique id.""" 109 | 110 | # ---------------------------------------------------------------------------- 111 | # All entities must have a unique id across your whole Home Assistant server - 112 | # and that also goes for anyone using your integration who may have many other 113 | # integrations loaded. 114 | # 115 | # Think carefully what you want this to be as changing it later will cause HA 116 | # to create new entities. 117 | # 118 | # It is recommended to have your integration name (DOMAIN), some unique id 119 | # from your device such as a UUID, MAC address etc (not IP address) and then 120 | # something unique to your entity (like name - as this would be unique on a 121 | # device) 122 | # 123 | # If in your situation you have some hub that connects to devices which then 124 | # you want to create multiple sensors for each device, you would do something 125 | # like. 126 | # 127 | # f"{DOMAIN}-{HUB_MAC_ADDRESS}-{DEVICE_UID}-{ENTITY_NAME}"" 128 | # 129 | # This is even more important if your integration supports multiple instances. 130 | # ---------------------------------------------------------------------------- 131 | return f"{DOMAIN}-{self.coordinator.get_device_parameter(self.device_id, "device_uid")}-{self.parameter}" 132 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor setup for our Integration.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, 7 | BinarySensorEntity, 8 | ) 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from . import MyConfigEntry 13 | from .base import ExampleBaseEntity 14 | from .coordinator import ExampleCoordinator 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | async def async_setup_entry( 20 | hass: HomeAssistant, 21 | config_entry: MyConfigEntry, 22 | async_add_entities: AddEntitiesCallback, 23 | ): 24 | """Set up the Binary Sensors.""" 25 | # This gets the data update coordinator from hass.data as specified in your __init__.py 26 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 27 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 28 | 29 | # ---------------------------------------------------------------------------- 30 | # Here we are going to add some binary sensors for the contact sensors in our 31 | # mock data. So we add an instance of our ExampleBinarySensor class for each 32 | # contact sensor we have in our data. 33 | # ---------------------------------------------------------------------------- 34 | binary_sensors = [ 35 | ExampleBinarySensor(coordinator, device, "state") 36 | for device in coordinator.data 37 | if device.get("device_type") == "CONTACT_SENSOR" 38 | ] 39 | 40 | # Create the binary sensors. 41 | async_add_entities(binary_sensors) 42 | 43 | 44 | class ExampleBinarySensor(ExampleBaseEntity, BinarySensorEntity): 45 | """Implementation of a sensor. 46 | 47 | This inherits our ExampleBaseEntity to set common properties. 48 | See base.py for this class. 49 | 50 | https://developers.home-assistant.io/docs/core/entity/binary-sensor 51 | """ 52 | 53 | # https://developers.home-assistant.io/docs/core/entity/binary-sensor#available-device-classes 54 | _attr_device_class = BinarySensorDeviceClass.DOOR 55 | 56 | @property 57 | def is_on(self) -> bool | None: 58 | """Return if the binary sensor is on.""" 59 | # This needs to enumerate to true or false 60 | return ( 61 | self.coordinator.get_device_parameter(self.device_id, self.parameter) 62 | == "OPEN" 63 | ) 64 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flows for our integration. 2 | 3 | This config flow demonstrates many aspects of possible config flows. 4 | 5 | Multi step flows 6 | Menus 7 | Using your api data in your flow 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import logging 13 | from typing import Any 14 | 15 | import voluptuous as vol 16 | 17 | from homeassistant.config_entries import ( 18 | ConfigEntry, 19 | ConfigFlow, 20 | ConfigFlowResult, 21 | OptionsFlow, 22 | ) 23 | from homeassistant.const import ( 24 | CONF_CHOOSE, 25 | CONF_DESCRIPTION, 26 | CONF_HOST, 27 | CONF_MINIMUM, 28 | CONF_PASSWORD, 29 | CONF_SCAN_INTERVAL, 30 | CONF_SENSORS, 31 | CONF_USERNAME, 32 | ) 33 | from homeassistant.core import HomeAssistant, callback 34 | from homeassistant.exceptions import HomeAssistantError 35 | from homeassistant.helpers.selector import selector 36 | 37 | from .api import API, APIAuthError, APIConnectionError 38 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL 39 | from .coordinator import ExampleCoordinator 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | # ---------------------------------------------------------------------------- 44 | # Adjust the data schema to the data that you need 45 | # ---------------------------------------------------------------------------- 46 | STEP_USER_DATA_SCHEMA = vol.Schema( 47 | { 48 | vol.Required(CONF_HOST, description={"suggested_value": "10.10.10.1"}): str, 49 | vol.Required(CONF_USERNAME, description={"suggested_value": "test"}): str, 50 | vol.Required(CONF_PASSWORD, description={"suggested_value": "1234"}): str, 51 | } 52 | ) 53 | 54 | # ---------------------------------------------------------------------------- 55 | # Example selectors 56 | # There are lots of selectors available for you to use, described at 57 | # https://www.home-assistant.io/docs/blueprint/selectors/ 58 | # ---------------------------------------------------------------------------- 59 | STEP_SETTINGS_DATA_SCHEMA = vol.Schema( 60 | { 61 | vol.Required(CONF_SENSORS): selector( 62 | {"entity": {"filter": {"integration": "sun"}}} 63 | ), 64 | # Take note of translation key and entry in strings.json and translation files. 65 | vol.Required(CONF_CHOOSE): selector( 66 | { 67 | "select": { 68 | "options": ["all", "light", "switch"], 69 | "mode": "dropdown", 70 | "translation_key": "example_selector", 71 | } 72 | } 73 | ), 74 | vol.Required(CONF_MINIMUM): selector({"number": {"min": 0, "max": 100}}), 75 | } 76 | ) 77 | 78 | 79 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 80 | """Validate the user input allows us to connect. 81 | 82 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 83 | """ 84 | try: 85 | # ---------------------------------------------------------------------------- 86 | # If your api is not async, use the executor to access it 87 | # If you cannot connect, raise CannotConnect 88 | # If the authentication is wrong, raise InvalidAuth 89 | # ---------------------------------------------------------------------------- 90 | api = API(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], mock=True) 91 | await hass.async_add_executor_job(api.get_data) 92 | except APIAuthError as err: 93 | raise InvalidAuth from err 94 | except APIConnectionError as err: 95 | raise CannotConnect from err 96 | return {"title": f"Example Integration - {data[CONF_HOST]}"} 97 | 98 | 99 | async def validate_settings(hass: HomeAssistant, data: dict[str, Any]) -> bool: 100 | """Another validation method for our config steps.""" 101 | return True 102 | 103 | 104 | class ExampleConfigFlow(ConfigFlow, domain=DOMAIN): 105 | """Handle a config flow for Example Integration.""" 106 | 107 | VERSION = 1 108 | _input_data: dict[str, Any] 109 | _title: str 110 | 111 | @staticmethod 112 | @callback 113 | def async_get_options_flow(config_entry): 114 | """Get the options flow for this handler. 115 | 116 | Remove this method and the ExampleOptionsFlowHandler class 117 | if you do not want any options for your integration. 118 | """ 119 | return ExampleOptionsFlowHandler(config_entry) 120 | 121 | async def async_step_user( 122 | self, user_input: dict[str, Any] | None = None 123 | ) -> ConfigFlowResult: 124 | """Handle the initial step. 125 | 126 | Called when you initiate adding an integration via the UI 127 | """ 128 | 129 | errors: dict[str, str] = {} 130 | 131 | if user_input is not None: 132 | # The form has been filled in and submitted, so process the data provided. 133 | try: 134 | # ---------------------------------------------------------------------------- 135 | # Validate that the setup data is valid and if not handle errors. 136 | # You can do any validation you want or no validation on each step. 137 | # The errors["base"] values match the values in your strings.json and translation files. 138 | # ---------------------------------------------------------------------------- 139 | info = await validate_input(self.hass, user_input) 140 | except CannotConnect: 141 | errors["base"] = "cannot_connect" 142 | except InvalidAuth: 143 | errors["base"] = "invalid_auth" 144 | except Exception: # pylint: disable=broad-except 145 | _LOGGER.exception("Unexpected exception") 146 | errors["base"] = "unknown" 147 | 148 | if "base" not in errors: 149 | # Validation was successful, so proceed to the next step. 150 | 151 | # ---------------------------------------------------------------------------- 152 | # Setting our unique id here just because we have the info at this stage to do that 153 | # and it will abort early on in the process if alreay setup. 154 | # You can put this in any step however. 155 | # ---------------------------------------------------------------------------- 156 | await self.async_set_unique_id(info.get("title")) 157 | self._abort_if_unique_id_configured() 158 | 159 | # Set our title variable here for use later 160 | self._title = info["title"] 161 | 162 | # ---------------------------------------------------------------------------- 163 | # You need to save the input data to a class variable as you go through each step 164 | # to ensure it is accessible across all steps. 165 | # ---------------------------------------------------------------------------- 166 | self._input_data = user_input 167 | 168 | # Call the next step 169 | return await self.async_step_settings() 170 | 171 | # Show initial form. 172 | return self.async_show_form( 173 | step_id="user", 174 | data_schema=STEP_USER_DATA_SCHEMA, 175 | errors=errors, 176 | last_step=False, # Adding last_step True/False decides whether form shows Next or Submit buttons 177 | ) 178 | 179 | async def async_step_settings( 180 | self, user_input: dict[str, Any] | None = None 181 | ) -> ConfigFlowResult: 182 | """Handle the second step. 183 | 184 | Our second config flow step. 185 | Works just the same way as the first step. 186 | Except as it is our last step, we create the config entry after any validation. 187 | """ 188 | 189 | errors: dict[str, str] = {} 190 | 191 | if user_input is not None: 192 | # The form has been filled in and submitted, so process the data provided. 193 | if not await validate_settings(self.hass, user_input): 194 | errors["base"] = "invalid_settings" 195 | 196 | if "base" not in errors: 197 | # ---------------------------------------------------------------------------- 198 | # Validation was successful, so create the config entry. 199 | # ---------------------------------------------------------------------------- 200 | self._input_data.update(user_input) 201 | return self.async_create_entry(title=self._title, data=self._input_data) 202 | 203 | # ---------------------------------------------------------------------------- 204 | # Show settings form. The step id always needs to match the bit after async_step_ in your method. 205 | # Set last_step to True here if it is last step. 206 | # ---------------------------------------------------------------------------- 207 | return self.async_show_form( 208 | step_id="settings", 209 | data_schema=STEP_SETTINGS_DATA_SCHEMA, 210 | errors=errors, 211 | last_step=True, 212 | ) 213 | 214 | async def async_step_reconfigure( 215 | self, user_input: dict[str, Any] | None = None 216 | ) -> ConfigFlowResult: 217 | """Add reconfigure step to allow to reconfigure a config entry. 218 | 219 | This methid displays a reconfigure option in the integration and is 220 | different to options. 221 | It can be used to reconfigure any of the data submitted when first installed. 222 | This is optional and can be removed if you do not want to allow reconfiguration. 223 | """ 224 | errors: dict[str, str] = {} 225 | config_entry = self.hass.config_entries.async_get_entry( 226 | self.context["entry_id"] 227 | ) 228 | 229 | if user_input is not None: 230 | try: 231 | user_input[CONF_HOST] = config_entry.data[CONF_HOST] 232 | await validate_input(self.hass, user_input) 233 | except CannotConnect: 234 | errors["base"] = "cannot_connect" 235 | except InvalidAuth: 236 | errors["base"] = "invalid_auth" 237 | except Exception: # pylint: disable=broad-except 238 | _LOGGER.exception("Unexpected exception") 239 | errors["base"] = "unknown" 240 | else: 241 | return self.async_update_reload_and_abort( 242 | config_entry, 243 | unique_id=config_entry.unique_id, 244 | data={**config_entry.data, **user_input}, 245 | reason="reconfigure_successful", 246 | ) 247 | return self.async_show_form( 248 | step_id="reconfigure", 249 | data_schema=vol.Schema( 250 | { 251 | vol.Required( 252 | CONF_USERNAME, default=config_entry.data[CONF_USERNAME] 253 | ): str, 254 | vol.Required(CONF_PASSWORD): str, 255 | } 256 | ), 257 | errors=errors, 258 | ) 259 | 260 | 261 | class ExampleOptionsFlowHandler(OptionsFlow): 262 | """Handles the options flow. 263 | 264 | Here we use an initial menu to select different options forms, 265 | and show how to use api data to populate a selector. 266 | """ 267 | 268 | def __init__(self, config_entry: ConfigEntry) -> None: 269 | """Initialize options flow.""" 270 | self.config_entry = config_entry 271 | self.options = dict(config_entry.options) 272 | 273 | async def async_step_init(self, user_input=None): 274 | """Handle options flow. 275 | 276 | Display an options menu 277 | option ids relate to step function name 278 | Also need to be in strings.json and translation files. 279 | """ 280 | 281 | return self.async_show_menu( 282 | step_id="init", 283 | menu_options=["option1", "option2"], 284 | ) 285 | 286 | async def async_step_option1(self, user_input=None): 287 | """Handle menu option 1 flow.""" 288 | if user_input is not None: 289 | options = self.config_entry.options | user_input 290 | return self.async_create_entry(title="", data=options) 291 | 292 | # ---------------------------------------------------------------------------- 293 | # It is recommended to prepopulate options fields with default values if 294 | # available. 295 | # These will be the same default values you use on your coordinator for 296 | # setting variable values if the option has not been set. 297 | # ---------------------------------------------------------------------------- 298 | data_schema = vol.Schema( 299 | { 300 | vol.Optional( 301 | CONF_SCAN_INTERVAL, 302 | default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), 303 | ): (vol.All(vol.Coerce(int), vol.Clamp(min=MIN_SCAN_INTERVAL))), 304 | vol.Optional( 305 | CONF_DESCRIPTION, 306 | default=self.options.get(CONF_DESCRIPTION), 307 | ): str, 308 | } 309 | ) 310 | 311 | return self.async_show_form(step_id="option1", data_schema=data_schema) 312 | 313 | async def async_step_option2(self, user_input=None): 314 | """Handle menu option 2 flow. 315 | 316 | In this option, we show how to use dynamic data in a selector. 317 | """ 318 | if user_input is not None: 319 | options = self.config_entry.options | user_input 320 | return self.async_create_entry(title="", data=options) 321 | 322 | coordinator: ExampleCoordinator = self.hass.data[DOMAIN][ 323 | self.config_entry.entry_id 324 | ].coordinator 325 | devices = coordinator.data 326 | data_schema = vol.Schema( 327 | { 328 | vol.Optional(CONF_CHOOSE, default=devices[0]["device_name"]): selector( 329 | { 330 | "select": { 331 | "options": [device["device_name"] for device in devices], 332 | "mode": "dropdown", 333 | "sort": True, 334 | } 335 | } 336 | ), 337 | } 338 | ) 339 | 340 | return self.async_show_form(step_id="option2", data_schema=data_schema) 341 | 342 | 343 | class CannotConnect(HomeAssistantError): 344 | """Error to indicate we cannot connect.""" 345 | 346 | 347 | class InvalidAuth(HomeAssistantError): 348 | """Error to indicate there is invalid auth.""" 349 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/const.py: -------------------------------------------------------------------------------- 1 | """Constants for our integration.""" 2 | 3 | DOMAIN = "msp_integration_101_intermediate" 4 | 5 | DEFAULT_SCAN_INTERVAL = 60 6 | MIN_SCAN_INTERVAL = 10 7 | 8 | RENAME_DEVICE_SERVICE_NAME = "rename_device_service" 9 | RESPONSE_SERVICE_NAME = "response_service" 10 | 11 | SET_OFF_TIMER_ENTITY_SERVICE_NAME = "set_off_timer" 12 | CONF_OFF_TIME = "off_time" 13 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for our integration.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import ( 9 | CONF_HOST, 10 | CONF_PASSWORD, 11 | CONF_SCAN_INTERVAL, 12 | CONF_USERNAME, 13 | ) 14 | from homeassistant.core import DOMAIN, HomeAssistant 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .api import API, APIConnectionError 18 | from .const import DEFAULT_SCAN_INTERVAL 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class ExampleCoordinator(DataUpdateCoordinator): 24 | """My example coordinator.""" 25 | 26 | data: list[dict[str, Any]] 27 | 28 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 29 | """Initialize coordinator.""" 30 | 31 | # Set variables from values entered in config flow setup 32 | self.host = config_entry.data[CONF_HOST] 33 | self.user = config_entry.data[CONF_USERNAME] 34 | self.pwd = config_entry.data[CONF_PASSWORD] 35 | 36 | # set variables from options. You need a default here in case options have not been set 37 | self.poll_interval = config_entry.options.get( 38 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 39 | ) 40 | 41 | # Initialise DataUpdateCoordinator 42 | super().__init__( 43 | hass, 44 | _LOGGER, 45 | name=f"{DOMAIN} ({config_entry.unique_id})", 46 | # Method to call on every update interval. 47 | update_method=self.async_update_data, 48 | # Polling interval. Will only be polled if you have made your 49 | # platform entities, CoordinatorEntities. 50 | # Using config option here but you can just use a fixed value. 51 | update_interval=timedelta(seconds=self.poll_interval), 52 | ) 53 | 54 | # Initialise your api here and make available to your integration. 55 | self.api = API(host=self.host, user=self.user, pwd=self.pwd, mock=True) 56 | 57 | async def async_update_data(self): 58 | """Fetch data from API endpoint. 59 | 60 | This is the place to retrieve and pre-process the data into an appropriate data structure 61 | to be used to provide values for all your entities. 62 | """ 63 | try: 64 | # ---------------------------------------------------------------------------- 65 | # Get the data from your api 66 | # NOTE: Change this to use a real api call for data 67 | # ---------------------------------------------------------------------------- 68 | data = await self.hass.async_add_executor_job(self.api.get_data) 69 | except APIConnectionError as err: 70 | _LOGGER.error(err) 71 | raise UpdateFailed(err) from err 72 | except Exception as err: 73 | # This will show entities as unavailable by raising UpdateFailed exception 74 | raise UpdateFailed(f"Error communicating with API: {err}") from err 75 | 76 | # What is returned here is stored in self.data by the DataUpdateCoordinator 77 | return data 78 | 79 | # ---------------------------------------------------------------------------- 80 | # Here we add some custom functions on our data coordinator to be called 81 | # from entity platforms to get access to the specific data they want. 82 | # 83 | # These will be specific to your api or yo may not need them at all 84 | # ---------------------------------------------------------------------------- 85 | def get_device(self, device_id: int) -> dict[str, Any]: 86 | """Get a device entity from our api data.""" 87 | try: 88 | return [ 89 | devices for devices in self.data if devices["device_id"] == device_id 90 | ][0] 91 | except (TypeError, IndexError): 92 | # In this case if the device id does not exist you will get an IndexError. 93 | # If api did not return any data, you will get TypeError. 94 | return None 95 | 96 | def get_device_parameter(self, device_id: int, parameter: str) -> Any: 97 | """Get the parameter value of one of our devices from our api data.""" 98 | if device := self.get_device(device_id): 99 | return device.get(parameter) 100 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/fan.py: -------------------------------------------------------------------------------- 1 | """Fan setup for our Integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.fan import FanEntity, FanEntityFeature 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | from homeassistant.util.percentage import percentage_to_ranged_value 10 | 11 | from . import MyConfigEntry 12 | from .base import ExampleBaseEntity 13 | from .coordinator import ExampleCoordinator 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup_entry( 19 | hass: HomeAssistant, 20 | config_entry: MyConfigEntry, 21 | async_add_entities: AddEntitiesCallback, 22 | ): 23 | """Set up the Fans.""" 24 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 25 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 26 | 27 | # ---------------------------------------------------------------------------- 28 | # Here we are going to add our fan entity for the fan in our mock data. 29 | # ---------------------------------------------------------------------------- 30 | 31 | # Fans 32 | fans = [ 33 | ExampleFan(coordinator, device, "state") 34 | for device in coordinator.data 35 | if device.get("device_type") == "FAN" 36 | ] 37 | 38 | # Create the fans. 39 | async_add_entities(fans) 40 | 41 | 42 | class ExampleFan(ExampleBaseEntity, FanEntity): 43 | """Implementation of a fan. 44 | 45 | This inherits our ExampleBaseEntity to set common properties. 46 | See base.py for this class. 47 | 48 | https://developers.home-assistant.io/docs/core/entity/fan/ 49 | """ 50 | 51 | _attr_speed_count = 3 52 | _attr_supported_features = FanEntityFeature.OSCILLATE | FanEntityFeature.SET_SPEED 53 | 54 | _speed_parameter = "speed" 55 | _oscillating_parameter = "oscillating" 56 | 57 | @property 58 | def is_on(self) -> bool | None: 59 | """Return if the fan is on.""" 60 | # This needs to enumerate to true or false 61 | return ( 62 | self.coordinator.get_device_parameter(self.device_id, self.parameter) 63 | == "ON" 64 | ) 65 | 66 | @property 67 | def oscillating(self) -> bool | None: 68 | """Return whether or not the fan is currently oscillating.""" 69 | # This needs to enumerate to true or false 70 | return ( 71 | self.coordinator.get_device_parameter( 72 | self.device_id, self._oscillating_parameter 73 | ) 74 | == "ON" 75 | ) 76 | 77 | @property 78 | def percentage(self) -> int | None: 79 | """Return the current speed as a percentage.""" 80 | speed = self.coordinator.get_device_parameter( 81 | self.device_id, self._speed_parameter 82 | ) 83 | # Need to return a percentage but our fan has speeds 0,1,2,3 84 | return int(self.percentage_step * speed) 85 | 86 | async def async_turn_on( 87 | self, 88 | percentage: int | None = None, 89 | preset_mode: str | None = None, 90 | **kwargs: Any, 91 | ) -> None: 92 | """Turn on the fan. 93 | 94 | A turn on command can be sent with or without a %, so we 95 | need to check that and turn on and set speed if requested. 96 | """ 97 | 98 | await self.hass.async_add_executor_job( 99 | self.coordinator.api.set_data, self.device_id, self.parameter, "ON" 100 | ) 101 | 102 | if percentage: 103 | self.async_set_fan_speed(percentage) 104 | # ---------------------------------------------------------------------------- 105 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 106 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 107 | # to trying to batch requests. 108 | # ---------------------------------------------------------------------------- 109 | await self.coordinator.async_refresh() 110 | 111 | async def async_turn_off(self, **kwargs: Any) -> None: 112 | """Turn the fan off.""" 113 | 114 | await self.hass.async_add_executor_job( 115 | self.coordinator.api.set_data, self.device_id, self.parameter, "OFF" 116 | ) 117 | await self.coordinator.async_refresh() 118 | 119 | async def async_set_percentage(self, percentage: int) -> None: 120 | """Set the speed of the fan, as a percentage. 121 | 122 | Here we need to apply some logic if % is 0 to turn the fan 123 | off instead of setting its speed. If the fan is off, turn it on with 124 | the speed setting, otherwise just set the speed setting. 125 | """ 126 | 127 | if percentage == 0: 128 | await self.async_turn_off() 129 | elif not self.is_on: 130 | await self.async_turn_on(percentage) 131 | else: 132 | await self.async_set_fan_speed(percentage) 133 | await self.coordinator.async_refresh() 134 | 135 | async def async_oscillate(self, oscillating: bool) -> None: 136 | """Oscillate the fan.""" 137 | 138 | await self.hass.async_add_executor_job( 139 | self.coordinator.api.set_data, 140 | self.device_id, 141 | self._oscillating_parameter, 142 | "ON" if oscillating else "OFF", 143 | ) 144 | await self.coordinator.async_refresh() 145 | 146 | # ---------------------------------------------------------------------------- 147 | # Added a custom method to make our code simpler 148 | # ---------------------------------------------------------------------------- 149 | 150 | async def async_set_fan_speed(self, percentage: int) -> None: 151 | """Set fan speed.""" 152 | await self.hass.async_add_executor_job( 153 | self.coordinator.api.set_data, 154 | self.device_id, 155 | self._speed_parameter, 156 | percentage_to_ranged_value(1, self.speed_count, percentage), 157 | ) 158 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/light.py: -------------------------------------------------------------------------------- 1 | """Light setup for our Integration.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers import config_validation as cv, entity_platform 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from . import MyConfigEntry 15 | from .base import ExampleBaseEntity 16 | from .const import CONF_OFF_TIME, SET_OFF_TIMER_ENTITY_SERVICE_NAME 17 | from .coordinator import ExampleCoordinator 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: MyConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ): 27 | """Set up the Binary Sensors.""" 28 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 29 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 30 | 31 | # ---------------------------------------------------------------------------- 32 | # Here we are going to add some lights entities for the lights in our mock data. 33 | # We have an on/off light and a dimmable light in our mock data, so add each 34 | # specific class based on the light type. 35 | # ---------------------------------------------------------------------------- 36 | lights = [] 37 | 38 | # On/Off lights 39 | lights.extend( 40 | [ 41 | ExampleOnOffLight(coordinator, device, "state") 42 | for device in coordinator.data 43 | if device.get("device_type") == "ON_OFF_LIGHT" 44 | ] 45 | ) 46 | 47 | # Dimmable lights 48 | lights.extend( 49 | [ 50 | ExampleDimmableLight(coordinator, device, "state") 51 | for device in coordinator.data 52 | if device.get("device_type") == "DIMMABLE_LIGHT" 53 | ] 54 | ) 55 | 56 | # Create the lights. 57 | async_add_entities(lights) 58 | 59 | # ---------------------------------------------------------------------------- 60 | # Add an Entity Service 61 | # 62 | # This example creates a service to set an off timer for our mock device. 63 | # You can have your schema as anything you need and must relate to the entry 64 | # in services.yaml. Best to set your target filters in serivces.yaml to match 65 | # what this can be called on, so your users cannot pick inappropriate entities. 66 | # If an entity is supplied that does not support this service, nothing will 67 | # happen. 68 | # 69 | # The function async_set_off_timer has to be part of your entity class and 70 | # is shown below. 71 | # 72 | # You will see the off timer sensor on a light update to reflect the time you 73 | # set. 74 | # ---------------------------------------------------------------------------- 75 | platform = entity_platform.async_get_current_platform() 76 | platform.async_register_entity_service( 77 | SET_OFF_TIMER_ENTITY_SERVICE_NAME, 78 | { 79 | vol.Required(CONF_OFF_TIME): cv.time_period, 80 | }, 81 | "async_set_off_timer", 82 | ) 83 | 84 | 85 | class ExampleOnOffLight(ExampleBaseEntity, LightEntity): 86 | """Implementation of an on/off light. 87 | 88 | This inherits our ExampleBaseEntity to set common properties. 89 | See base.py for this class. 90 | 91 | https://developers.home-assistant.io/docs/core/entity/light/ 92 | """ 93 | 94 | _attr_supported_color_modes = {ColorMode.ONOFF} 95 | _attr_color_mode = ColorMode.ONOFF 96 | 97 | @property 98 | def is_on(self) -> bool | None: 99 | """Return if the binary sensor is on.""" 100 | # This needs to enumerate to true or false 101 | return ( 102 | self.coordinator.get_device_parameter(self.device_id, self.parameter) 103 | == "ON" 104 | ) 105 | 106 | async def async_turn_on(self, **kwargs: Any) -> None: 107 | """Turn the entity on.""" 108 | await self.hass.async_add_executor_job( 109 | self.coordinator.api.set_data, self.device_id, self.parameter, "ON" 110 | ) 111 | # ---------------------------------------------------------------------------- 112 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 113 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 114 | # to trying to batch requests. 115 | # ---------------------------------------------------------------------------- 116 | await self.coordinator.async_refresh() 117 | 118 | async def async_turn_off(self, **kwargs: Any) -> None: 119 | """Turn the entity off.""" 120 | await self.hass.async_add_executor_job( 121 | self.coordinator.api.set_data, self.device_id, self.parameter, "OFF" 122 | ) 123 | # ---------------------------------------------------------------------------- 124 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 125 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 126 | # to trying to batch requests. 127 | # ---------------------------------------------------------------------------- 128 | await self.coordinator.async_refresh() 129 | 130 | async def async_set_off_timer(self, off_time: timedelta) -> None: 131 | """Handle the set off timer service call. 132 | 133 | Important here to have your service parameters included in your 134 | function as they are passed as named parameters. 135 | """ 136 | await self.hass.async_add_executor_job( 137 | self.coordinator.api.set_data, 138 | self.device_id, 139 | "off_timer", 140 | ":".join(str(off_time).split(":")[:2]), 141 | ) 142 | # We have made a change to our device, so call a refresh to get updated data. 143 | # We use async_request_refresh here to batch the updates in case you select 144 | # multiple entities. 145 | await self.coordinator.async_request_refresh() 146 | 147 | 148 | class ExampleDimmableLight(ExampleOnOffLight): 149 | """Implementation of a dimmable light.""" 150 | 151 | _attr_supported_color_modes = {ColorMode.BRIGHTNESS} 152 | _attr_color_mode = ColorMode.BRIGHTNESS 153 | 154 | @property 155 | def brightness(self) -> int: 156 | """Return the brightness of this light between 0..255.""" 157 | # Our light is in range 0..100, so convert 158 | return int( 159 | self.coordinator.get_device_parameter(self.device_id, "brightness") 160 | * (255 / 100) 161 | ) 162 | 163 | async def async_turn_on(self, **kwargs: Any) -> None: 164 | """Turn the entity on.""" 165 | if ATTR_BRIGHTNESS in kwargs: 166 | brightness = int(kwargs[ATTR_BRIGHTNESS] * (100 / 255)) 167 | await self.hass.async_add_executor_job( 168 | self.coordinator.api.set_data, self.device_id, "brightness", brightness 169 | ) 170 | else: 171 | await self.hass.async_add_executor_job( 172 | self.coordinator.api.set_data, self.device_id, self.parameter, "ON" 173 | ) 174 | # ---------------------------------------------------------------------------- 175 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 176 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 177 | # to trying to batch requests and cause wierd UI behaviour. 178 | # ---------------------------------------------------------------------------- 179 | await self.coordinator.async_refresh() 180 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "msp_integration_101_intermediate", 3 | "name": "MSP Integration 101 Intermediate Example", 4 | "codeowners": [ 5 | "@msp1974" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/msp1974/HAIntegrationExamples", 10 | "homekit": {}, 11 | "iot_class": "local_polling", 12 | "requirements": [], 13 | "single_config_entry": false, 14 | "ssdp": [], 15 | "version": "1.0.1", 16 | "zeroconf": [] 17 | } -------------------------------------------------------------------------------- /msp_integration_101_intermediate/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor setup for our Integration. 2 | 3 | Here we use a different method to define some of our entity classes. 4 | As, in our example, so much is common, we use our base entity class to define 5 | many properties, then our base sensor class to define the property to get the 6 | value of the sensor. 7 | 8 | As such, for all our other sensor types, we can just set the _attr_ value to 9 | keep our code small and easily readable. You can do this for all entity properties(attributes) 10 | if you so wish, or mix and match to suit. 11 | """ 12 | 13 | from dataclasses import dataclass 14 | import logging 15 | 16 | from homeassistant.components.sensor import ( 17 | SensorDeviceClass, 18 | SensorEntity, 19 | SensorStateClass, 20 | ) 21 | from homeassistant.const import ( 22 | UnitOfElectricCurrent, 23 | UnitOfElectricPotential, 24 | UnitOfEnergy, 25 | UnitOfTemperature, 26 | ) 27 | from homeassistant.core import HomeAssistant 28 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 29 | 30 | from . import MyConfigEntry 31 | from .base import ExampleBaseEntity 32 | from .coordinator import ExampleCoordinator 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | 37 | @dataclass 38 | class SensorTypeClass: 39 | """Class for holding sensor type to sensor class.""" 40 | 41 | type: str 42 | sensor_class: object 43 | 44 | 45 | async def async_setup_entry( 46 | hass: HomeAssistant, 47 | config_entry: MyConfigEntry, 48 | async_add_entities: AddEntitiesCallback, 49 | ): 50 | """Set up the Sensors.""" 51 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 52 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 53 | 54 | # ---------------------------------------------------------------------------- 55 | # Here we enumerate the sensors in your data value from your 56 | # DataUpdateCoordinator and add an instance of your sensor class to a list 57 | # for each one. 58 | # This maybe different in your specific case, depending on how your data is 59 | # structured 60 | # ---------------------------------------------------------------------------- 61 | 62 | sensor_types = [ 63 | SensorTypeClass("current", ExampleCurrentSensor), 64 | SensorTypeClass("energy_delivered", ExampleEnergySensor), 65 | SensorTypeClass("off_timer", ExampleOffTimerSensor), 66 | SensorTypeClass("temperature", ExampleTemperatureSensor), 67 | SensorTypeClass("voltage", ExampleVoltageSensor), 68 | ] 69 | 70 | sensors = [] 71 | 72 | for sensor_type in sensor_types: 73 | sensors.extend( 74 | [ 75 | sensor_type.sensor_class(coordinator, device, sensor_type.type) 76 | for device in coordinator.data 77 | if device.get(sensor_type.type) 78 | ] 79 | ) 80 | 81 | # Now create the sensors. 82 | async_add_entities(sensors) 83 | 84 | 85 | class ExampleBaseSensor(ExampleBaseEntity, SensorEntity): 86 | """Implementation of a sensor. 87 | 88 | This inherits our ExampleBaseEntity to set common properties. 89 | See base.py for this class. 90 | 91 | https://developers.home-assistant.io/docs/core/entity/sensor 92 | """ 93 | 94 | @property 95 | def native_value(self) -> int | float: 96 | """Return the state of the entity.""" 97 | # Using native value and native unit of measurement, allows you to change units 98 | # in Lovelace and HA will automatically calculate the correct value. 99 | return self.coordinator.get_device_parameter(self.device_id, self.parameter) 100 | 101 | 102 | class ExampleCurrentSensor(ExampleBaseSensor): 103 | """Class to handle current sensors. 104 | 105 | This inherits the ExampleBaseSensor and so uses all the properties and methods 106 | from that class and then overrides specific attributes relevant to this sensor type. 107 | """ 108 | 109 | _attr_device_class = SensorDeviceClass.CURRENT 110 | _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE 111 | _attr_suggested_display_precision = 2 112 | 113 | 114 | class ExampleEnergySensor(ExampleBaseSensor): 115 | """Class to handle energy sensors. 116 | 117 | This inherits the ExampleBaseSensor and so uses all the properties and methods 118 | from that class and then overrides specific attributes relevant to this sensor type. 119 | """ 120 | 121 | _attr_device_class = SensorDeviceClass.ENERGY 122 | _attr_state_class = SensorStateClass.TOTAL_INCREASING 123 | _attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR 124 | 125 | 126 | class ExampleOffTimerSensor(ExampleBaseSensor): 127 | """Class to handle off timer sensors. 128 | 129 | This inherits the ExampleBaseSensor and so uses all the properties and methods 130 | from that class and then overrides specific attributes relevant to this sensor type. 131 | """ 132 | 133 | 134 | class ExampleTemperatureSensor(ExampleBaseSensor): 135 | """Class to handle temperature sensors. 136 | 137 | This inherits the ExampleBaseSensor and so uses all the properties and methods 138 | from that class and then overrides specific attributes relevant to this sensor type. 139 | """ 140 | 141 | _attr_device_class = SensorDeviceClass.TEMPERATURE 142 | _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS 143 | _attr_suggested_display_precision = 1 144 | 145 | 146 | class ExampleVoltageSensor(ExampleBaseSensor): 147 | """Class to handle voltage sensors. 148 | 149 | This inherits the ExampleBaseSensor and so uses all the properties and methods 150 | from that class and then overrides specific attributes relevant to this sensor type. 151 | """ 152 | 153 | _attr_device_class = SensorDeviceClass.VOLTAGE 154 | _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT 155 | _attr_suggested_display_precision = 0 156 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/services.py: -------------------------------------------------------------------------------- 1 | """Global services file. 2 | 3 | This needs to be viewed with the services.yaml file 4 | to demonstrate the different setup for using these services in the UI 5 | 6 | IMPORTANT NOTES: 7 | To ensure your service runs on the event loop, either make service function async 8 | or decorate with @callback. However, ensure that your function is non blocking or, 9 | if it is, run in the executor. 10 | Both examples are shown here. Running services on different threads can cause issues. 11 | 12 | https://developers.home-assistant.io/docs/dev_101_services/ 13 | """ 14 | 15 | import voluptuous as vol 16 | 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME 19 | from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback 20 | from homeassistant.exceptions import HomeAssistantError 21 | import homeassistant.helpers.device_registry as dr 22 | 23 | from .const import DOMAIN, RENAME_DEVICE_SERVICE_NAME, RESPONSE_SERVICE_NAME 24 | from .coordinator import ExampleCoordinator 25 | 26 | ATTR_TEXT = "text" 27 | 28 | # Services schemas 29 | RENAME_DEVICE_SERVICE_SCHEMA = vol.Schema( 30 | { 31 | vol.Required(ATTR_DEVICE_ID): int, 32 | vol.Required(ATTR_NAME): str, 33 | } 34 | ) 35 | 36 | RESPONSE_SERVICE_SCHEMA = vol.Schema( 37 | { 38 | vol.Required(ATTR_DEVICE_ID): int, 39 | } 40 | ) 41 | 42 | 43 | class ExampleServicesSetup: 44 | """Class to handle Integration Services.""" 45 | 46 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 47 | """Initialise services.""" 48 | self.hass = hass 49 | self.config_entry = config_entry 50 | self.coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 51 | 52 | self.setup_services() 53 | 54 | def setup_services(self): 55 | """Initialise the services in Hass.""" 56 | # ---------------------------------------------------------------------------- 57 | # A simple definition of a service with 2 parameters, as denoted by the 58 | # RENAME_DEVICE_SERVICE_SCHEMA 59 | # ---------------------------------------------------------------------------- 60 | self.hass.services.async_register( 61 | DOMAIN, 62 | RENAME_DEVICE_SERVICE_NAME, 63 | self.rename_device, 64 | schema=RENAME_DEVICE_SERVICE_SCHEMA, 65 | ) 66 | 67 | # ---------------------------------------------------------------------------- 68 | # The definition here for a response service is the same as before but you 69 | # must include supports_response = only/optional 70 | # https://developers.home-assistant.io/docs/dev_101_services/#supporting-response-data 71 | # ---------------------------------------------------------------------------- 72 | self.hass.services.async_register( 73 | DOMAIN, 74 | RESPONSE_SERVICE_NAME, 75 | self.async_response_service, 76 | schema=RESPONSE_SERVICE_SCHEMA, 77 | supports_response=SupportsResponse.ONLY, 78 | ) 79 | 80 | async def rename_device(self, service_call: ServiceCall) -> None: 81 | """Execute rename device service call function. 82 | 83 | This will send a command to the api which will rename the 84 | device and then update the device registry to match. 85 | 86 | Data from the service call will be in service_call.data 87 | as seen below. 88 | """ 89 | device_id = service_call.data[ATTR_DEVICE_ID] 90 | device_name = service_call.data[ATTR_NAME] 91 | 92 | # check for valid device id 93 | try: 94 | assert self.coordinator.get_device(device_id) is not None 95 | except AssertionError as ex: 96 | raise HomeAssistantError( 97 | "Error calling service: The device ID does not exist" 98 | ) from ex 99 | else: 100 | result = await self.hass.async_add_executor_job( 101 | self.coordinator.api.set_data, device_id, "device_name", device_name 102 | ) 103 | 104 | if result: 105 | # ---------------------------------------------------------------------------- 106 | # In this scenario, we would need to update the device registry name here 107 | # as it will not automatically update. 108 | # ---------------------------------------------------------------------------- 109 | 110 | # Get our device from coordinator data to retrieve its devie_uid as that is 111 | # what we used in the devices identifiers. 112 | device = self.coordinator.get_device(device_id) 113 | 114 | # Get the device registry 115 | device_registry = dr.async_get(self.hass) 116 | 117 | # Get the device entry in the registry by its identifiers. This is the same as 118 | # we used to set them in base.py 119 | device_entry = device_registry.async_get_device( 120 | [(DOMAIN, device["device_uid"])] 121 | ) 122 | 123 | # Update our device entry with the new name. You will see this change in the UI 124 | device_registry.async_update_device(device_entry.id, name=device_name) 125 | 126 | await self.coordinator.async_request_refresh() 127 | 128 | @callback 129 | def async_response_service(self, service_call: ServiceCall) -> None: 130 | """Execute response service call function. 131 | 132 | This will take a device id and return json data for the 133 | devices info on the api. 134 | 135 | If the device does not exist, it will raise an error. 136 | """ 137 | device_id = service_call.data[ATTR_DEVICE_ID] 138 | response = self.coordinator.get_device(device_id) 139 | 140 | try: 141 | assert response is not None 142 | except AssertionError as ex: 143 | raise HomeAssistantError( 144 | "Error calling service: The device ID does not exist" 145 | ) from ex 146 | else: 147 | return response 148 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/services.yaml: -------------------------------------------------------------------------------- 1 | rename_device_service: 2 | name: Example 101 Rename API device 3 | description: > 4 | Change the name of a device on the example API 5 | fields: 6 | device_id: 7 | name: Device ID 8 | description: The device ID to rename 9 | example: 1 10 | required: true 11 | selector: 12 | number: 13 | min: 1 14 | step: 1 15 | mode: box 16 | name: 17 | name: Device name 18 | description: New name for the device 19 | example: "Kitchen Light 3" 20 | required: true 21 | selector: 22 | text: 23 | 24 | response_service: 25 | name: Example 101 Response Service 26 | description: A simple response service 27 | fields: 28 | device_id: 29 | name: Device ID 30 | description: The name of the entity to perform the service on 31 | example: 1 32 | required: true 33 | selector: 34 | number: 35 | min: 1 36 | step: 1 37 | mode: box 38 | 39 | 40 | set_off_timer: 41 | name: Set off timer 42 | decription: Set an off timer for a light 43 | target: 44 | entity: 45 | integration: msp_integration_101_intermediate 46 | domain: 47 | - light 48 | fields: 49 | off_time: 50 | name: "Turn off in" 51 | description: "The time to countdown to turn off" 52 | example: "12:00:00" 53 | default: "12:00:00" 54 | required: true 55 | selector: 56 | time: 57 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Advanced Config Flow Example", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful", 7 | "already_in_progress": "A setup is already in progress for this integration" 8 | }, 9 | "error": { 10 | "cannot_connect": "Failed to connect", 11 | "invalid_auth": "Invalid authentication", 12 | "invalid_settings": "Invalid settings", 13 | "unknown": "Unexpected error" 14 | }, 15 | "step": { 16 | "user": { 17 | "title": "Multi Step Config - Step 1", 18 | "data": { 19 | "host": "Host", 20 | "password": "Password", 21 | "username": "Username" 22 | } 23 | }, 24 | "settings": { 25 | "title": "Multi Step Config - Step 2", 26 | "data": { 27 | "sensors": "Sensor", 28 | "choose": "Selector", 29 | "minimum": "Minimum Value" 30 | } 31 | }, 32 | "reconfigure": { 33 | "data": { 34 | "host": "Host", 35 | "password": "Password", 36 | "username": "Username" 37 | } 38 | } 39 | } 40 | }, 41 | "options": { 42 | "step": { 43 | "init": { 44 | "title": "Example2 Options", 45 | "description": "Select which options to amend.", 46 | "menu_options": { 47 | "option1": "Options Set 1", 48 | "option2": "Options Set 2" 49 | } 50 | }, 51 | "option1": { 52 | "title": "Example2 Options", 53 | "description": "Option Set 1", 54 | "data": { 55 | "scan_interval": "Scan Interval (seconds)", 56 | "description": "My Description" 57 | } 58 | }, 59 | "option2": { 60 | "title": "Example2 Options", 61 | "description": "Option Set 2", 62 | "data": { 63 | "chose": "Pick a device" 64 | } 65 | } 66 | } 67 | }, 68 | "selector": { 69 | "example_selector": { 70 | "options": { 71 | "all": "All", 72 | "light": "Light", 73 | "switch": "Switch" 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /msp_integration_101_intermediate/switch.py: -------------------------------------------------------------------------------- 1 | """Switch setup for our Integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | 10 | from . import MyConfigEntry 11 | from .base import ExampleBaseEntity 12 | from .coordinator import ExampleCoordinator 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, 19 | config_entry: MyConfigEntry, 20 | async_add_entities: AddEntitiesCallback, 21 | ): 22 | """Set up the Binary Sensors.""" 23 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 24 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 25 | 26 | # ---------------------------------------------------------------------------- 27 | # Here we enumerate the switches in your data value from your 28 | # DataUpdateCoordinator and add an instance of your switch class to a list 29 | # for each one. 30 | # This maybe different in your specific case, depending on how your data is 31 | # structured 32 | # ---------------------------------------------------------------------------- 33 | 34 | switches = [ 35 | ExampleSwitch(coordinator, device, "state") 36 | for device in coordinator.data 37 | if device.get("device_type") == "SOCKET" 38 | ] 39 | 40 | # Create the binary sensors. 41 | async_add_entities(switches) 42 | 43 | 44 | class ExampleSwitch(ExampleBaseEntity, SwitchEntity): 45 | """Implementation of a switch. 46 | 47 | This inherits our ExampleBaseEntity to set common properties. 48 | See base.py for this class. 49 | 50 | https://developers.home-assistant.io/docs/core/entity/switch 51 | """ 52 | 53 | _attr_device_class = SwitchDeviceClass.SWITCH 54 | 55 | @property 56 | def is_on(self) -> bool | None: 57 | """Return if the binary sensor is on.""" 58 | # This needs to enumerate to true or false 59 | return ( 60 | self.coordinator.get_device_parameter(self.device_id, self.parameter) 61 | == "ON" 62 | ) 63 | 64 | async def async_turn_on(self, **kwargs: Any) -> None: 65 | """Turn the entity on.""" 66 | await self.hass.async_add_executor_job( 67 | self.coordinator.api.set_data, self.device_id, self.parameter, "ON" 68 | ) 69 | # ---------------------------------------------------------------------------- 70 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 71 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 72 | # to trying to batch requests. 73 | # ---------------------------------------------------------------------------- 74 | await self.coordinator.async_refresh() 75 | 76 | async def async_turn_off(self, **kwargs: Any) -> None: 77 | """Turn the entity off.""" 78 | await self.hass.async_add_executor_job( 79 | self.coordinator.api.set_data, self.device_id, self.parameter, "OFF" 80 | ) 81 | # ---------------------------------------------------------------------------- 82 | # Use async_refresh on the DataUpdateCoordinator to perform immediate update. 83 | # Using self.async_update or self.coordinator.async_request_refresh may delay update due 84 | # to trying to batch requests. 85 | # ---------------------------------------------------------------------------- 86 | await self.coordinator.async_refresh() 87 | 88 | @property 89 | def extra_state_attributes(self): 90 | """Return the extra state attributes.""" 91 | # Add any additional attributes you want on your sensor. 92 | attrs = {} 93 | attrs["last_rebooted"] = self.coordinator.get_device_parameter( 94 | self.device_id, "last_reboot" 95 | ) 96 | return attrs 97 | -------------------------------------------------------------------------------- /msp_integration_101_intermediate/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Advanced Config Flow Example", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful", 7 | "already_in_progress": "A setup is already in progress for this integration" 8 | }, 9 | "error": { 10 | "cannot_connect": "Failed to connect", 11 | "invalid_auth": "Invalid authentication", 12 | "invalid_settings": "Invalid settings", 13 | "unknown": "Unexpected error" 14 | }, 15 | "step": { 16 | "user": { 17 | "title": "Multi Step Config - Step 1", 18 | "data": { 19 | "host": "Host", 20 | "password": "Password", 21 | "username": "Username" 22 | } 23 | }, 24 | "settings": { 25 | "title": "Multi Step Config - Step 2", 26 | "data": { 27 | "sensors": "Sensor", 28 | "choose": "Selector", 29 | "minimum": "Minimum Value" 30 | } 31 | }, 32 | "reconfigure": { 33 | "data": { 34 | "host": "Host", 35 | "password": "Password", 36 | "username": "Username" 37 | } 38 | } 39 | } 40 | }, 41 | "options": { 42 | "step": { 43 | "init": { 44 | "title": "Example2 Options", 45 | "description": "Select which options to amend.", 46 | "menu_options": { 47 | "option1": "Options Set 1", 48 | "option2": "Options Set 2" 49 | } 50 | }, 51 | "option1": { 52 | "title": "Example2 Options", 53 | "description": "Option Set 1", 54 | "data": { 55 | "scan_interval": "Scan Interval (seconds)", 56 | "description": "My Description" 57 | } 58 | }, 59 | "option2": { 60 | "title": "Example2 Options", 61 | "description": "Option Set 2", 62 | "data": { 63 | "chose": "Pick a device" 64 | } 65 | } 66 | } 67 | }, 68 | "selector": { 69 | "example_selector": { 70 | "options": { 71 | "all": "All", 72 | "light": "Light", 73 | "switch": "Switch" 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /msp_integration_101_template/README.md: -------------------------------------------------------------------------------- 1 | # Integration 101 Template 2 | 3 | So this is your starting point into writing your first Home Assistant integration (or maybe just advancing your knowledge and improving something you have already written). 4 | 5 | Well, firstly, I hope you enjoy doing it. There is something very satisfying to be able to build something into Home Assistant that controls your devices! 6 | 7 | So, below is a more detailed explanaition of the major building blocks demonstrated in this example. 8 | 9 | If you get stuck, either post a forum question or an issue on this github repo and I'll try my best to help you. As a note, it always helps if I can see your code, so please make sure you provide a link to that. 10 | 11 | 1. **Config Flow** 12 | 13 | This is the functionality to provide setup via the UI. Many new starters to coding, start with a yaml config as it seems easier, but once you understand how to write a config flow (and it is quite simple), this is a much better way to setup and manage your integration from the start. 14 | 15 | See the config_flow.py file with comments to see how it works. This is much enhanced from the scaffold version to include a reconfigure flow and options flow. 16 | 17 | It is possible (and quite simple) to do multi step flows, which will be covered in another later example. 18 | 19 | 2. **The DataUpdateCoordinator** 20 | 21 | To me, this should be a default for any integration that gets its data from an api (whether it be a pull (polling) or push type api). It provides much of the functionality to manage polling, receive a websocket message, process your data and update all your entities without you having to do much coding and ensures that all api code is ring fenced within this class. 22 | 23 | 3. **Devices** 24 | 25 | These are a nice way to group your entities that relate to the same physical device. Again, this is often very confusing how to create these for an integration. However, with simple explained code, this can be quite straight forward. 26 | 27 | 4. **Platform Entities** 28 | 29 | These are your sensors, switches, lights etc, and this example covers the 2 most simple ones of binary sensors, things that only have 2 states, ie On/Off or Open/Closed or Hot/Cold etc and sensors, things that can have many states ie temperature, power, luminance etc. 30 | 31 | There are within Home Assistant things called device classes that describe what your sensor is and set icons, units etc for it. 32 | -------------------------------------------------------------------------------- /msp_integration_101_template/__init__.py: -------------------------------------------------------------------------------- 1 | """The Integration 101 Template integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | import logging 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import Platform 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import ConfigEntryNotReady 13 | from homeassistant.helpers.device_registry import DeviceEntry 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 15 | 16 | from .coordinator import ExampleCoordinator 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] 21 | 22 | type MyConfigEntry = ConfigEntry[RuntimeData] 23 | 24 | 25 | @dataclass 26 | class RuntimeData: 27 | """Class to hold your data.""" 28 | 29 | coordinator: DataUpdateCoordinator 30 | 31 | 32 | async def async_setup_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 33 | """Set up Example Integration from a config entry.""" 34 | 35 | # Initialise the coordinator that manages data updates from your api. 36 | # This is defined in coordinator.py 37 | coordinator = ExampleCoordinator(hass, config_entry) 38 | 39 | # Perform an initial data load from api. 40 | # async_config_entry_first_refresh() is special in that it does not log errors if it fails 41 | await coordinator.async_config_entry_first_refresh() 42 | 43 | # Test to see if api initialised correctly, else raise ConfigNotReady to make HA retry setup 44 | # TODO: Change this to match how your api will know if connected or successful update 45 | if not coordinator.api.connected: 46 | raise ConfigEntryNotReady 47 | 48 | # Initialise a listener for config flow options changes. 49 | # This will be removed automatically if the integraiton is unloaded. 50 | # See config_flow for defining an options setting that shows up as configure 51 | # on the integration. 52 | # If you do not want any config flow options, no need to have listener. 53 | config_entry.async_on_unload( 54 | config_entry.add_update_listener(_async_update_listener) 55 | ) 56 | 57 | # Add the coordinator and update listener to config runtime data to make 58 | # accessible throughout your integration 59 | config_entry.runtime_data = RuntimeData(coordinator) 60 | 61 | # Setup platforms (based on the list of entity types in PLATFORMS defined above) 62 | # This calls the async_setup method in each of your entity type files. 63 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 64 | 65 | # Return true to denote a successful setup. 66 | return True 67 | 68 | 69 | async def _async_update_listener(hass: HomeAssistant, config_entry): 70 | """Handle config options update.""" 71 | # Reload the integration when the options change. 72 | await hass.config_entries.async_reload(config_entry.entry_id) 73 | 74 | 75 | async def async_remove_config_entry_device( 76 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 77 | ) -> bool: 78 | """Delete device if selected from UI.""" 79 | # Adding this function shows the delete device option in the UI. 80 | # Remove this function if you do not want that option. 81 | # You may need to do some checks here before allowing devices to be removed. 82 | return True 83 | 84 | 85 | async def async_unload_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 86 | """Unload a config entry.""" 87 | # This is called when you remove your integration or shutdown HA. 88 | # If you have created any custom services, they need to be removed here too. 89 | 90 | # Unload platforms and return result 91 | return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 92 | -------------------------------------------------------------------------------- /msp_integration_101_template/api.py: -------------------------------------------------------------------------------- 1 | """API Placeholder. 2 | 3 | You should create your api seperately and have it hosted on PYPI. This is included here for the sole purpose 4 | of making this example code executable. 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from enum import StrEnum 9 | import logging 10 | from random import choice, randrange 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class DeviceType(StrEnum): 16 | """Device types.""" 17 | 18 | TEMP_SENSOR = "temp_sensor" 19 | DOOR_SENSOR = "door_sensor" 20 | OTHER = "other" 21 | 22 | 23 | DEVICES = [ 24 | {"id": 1, "type": DeviceType.TEMP_SENSOR}, 25 | {"id": 2, "type": DeviceType.TEMP_SENSOR}, 26 | {"id": 3, "type": DeviceType.TEMP_SENSOR}, 27 | {"id": 4, "type": DeviceType.TEMP_SENSOR}, 28 | {"id": 1, "type": DeviceType.DOOR_SENSOR}, 29 | {"id": 2, "type": DeviceType.DOOR_SENSOR}, 30 | {"id": 3, "type": DeviceType.DOOR_SENSOR}, 31 | {"id": 4, "type": DeviceType.DOOR_SENSOR}, 32 | ] 33 | 34 | 35 | @dataclass 36 | class Device: 37 | """API device.""" 38 | 39 | device_id: int 40 | device_unique_id: str 41 | device_type: DeviceType 42 | name: str 43 | state: int | bool 44 | 45 | 46 | class API: 47 | """Class for example API.""" 48 | 49 | def __init__(self, host: str, user: str, pwd: str) -> None: 50 | """Initialise.""" 51 | self.host = host 52 | self.user = user 53 | self.pwd = pwd 54 | self.connected: bool = False 55 | 56 | @property 57 | def controller_name(self) -> str: 58 | """Return the name of the controller.""" 59 | return self.host.replace(".", "_") 60 | 61 | def connect(self) -> bool: 62 | """Connect to api.""" 63 | if self.user == "test" and self.pwd == "1234": 64 | self.connected = True 65 | return True 66 | raise APIAuthError("Error connecting to api. Invalid username or password.") 67 | 68 | def disconnect(self) -> bool: 69 | """Disconnect from api.""" 70 | self.connected = False 71 | return True 72 | 73 | def get_devices(self) -> list[Device]: 74 | """Get devices on api.""" 75 | return [ 76 | Device( 77 | device_id=device.get("id"), 78 | device_unique_id=self.get_device_unique_id( 79 | device.get("id"), device.get("type") 80 | ), 81 | device_type=device.get("type"), 82 | name=self.get_device_name(device.get("id"), device.get("type")), 83 | state=self.get_device_value(device.get("id"), device.get("type")), 84 | ) 85 | for device in DEVICES 86 | ] 87 | 88 | def get_device_unique_id(self, device_id: str, device_type: DeviceType) -> str: 89 | """Return a unique device id.""" 90 | if device_type == DeviceType.DOOR_SENSOR: 91 | return f"{self.controller_name}_D{device_id}" 92 | if device_type == DeviceType.TEMP_SENSOR: 93 | return f"{self.controller_name}_T{device_id}" 94 | return f"{self.controller_name}_Z{device_id}" 95 | 96 | def get_device_name(self, device_id: str, device_type: DeviceType) -> str: 97 | """Return the device name.""" 98 | if device_type == DeviceType.DOOR_SENSOR: 99 | return f"DoorSensor{device_id}" 100 | if device_type == DeviceType.TEMP_SENSOR: 101 | return f"TempSensor{device_id}" 102 | return f"OtherSensor{device_id}" 103 | 104 | def get_device_value(self, device_id: str, device_type: DeviceType) -> int | bool: 105 | """Get device random value.""" 106 | if device_type == DeviceType.DOOR_SENSOR: 107 | return choice([True, False]) 108 | if device_type == DeviceType.TEMP_SENSOR: 109 | return randrange(15, 28) 110 | return randrange(1, 10) 111 | 112 | 113 | class APIAuthError(Exception): 114 | """Exception class for auth error.""" 115 | 116 | 117 | class APIConnectionError(Exception): 118 | """Exception class for connection error.""" 119 | -------------------------------------------------------------------------------- /msp_integration_101_template/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Integration 101 Template api sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, 7 | BinarySensorEntity, 8 | ) 9 | from homeassistant.core import HomeAssistant, callback 10 | from homeassistant.helpers.device_registry import DeviceInfo 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 13 | 14 | from . import MyConfigEntry 15 | from .api import Device, DeviceType 16 | from .const import DOMAIN 17 | from .coordinator import ExampleCoordinator 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: MyConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ): 27 | """Set up the Binary Sensors.""" 28 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 29 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 30 | 31 | # Enumerate all the binary sensors in your data value from your DataUpdateCoordinator and add an instance of your binary sensor class 32 | # to a list for each one. 33 | # This maybe different in your specific case, depending on how your data is structured 34 | binary_sensors = [ 35 | ExampleBinarySensor(coordinator, device) 36 | for device in coordinator.data.devices 37 | if device.device_type == DeviceType.DOOR_SENSOR 38 | ] 39 | 40 | # Create the binary sensors. 41 | async_add_entities(binary_sensors) 42 | 43 | 44 | class ExampleBinarySensor(CoordinatorEntity, BinarySensorEntity): 45 | """Implementation of a sensor.""" 46 | 47 | def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None: 48 | """Initialise sensor.""" 49 | super().__init__(coordinator) 50 | self.device = device 51 | self.device_id = device.device_id 52 | 53 | @callback 54 | def _handle_coordinator_update(self) -> None: 55 | """Update sensor with latest data from coordinator.""" 56 | # This method is called by your DataUpdateCoordinator when a successful update runs. 57 | self.device = self.coordinator.get_device_by_id( 58 | self.device.device_type, self.device_id 59 | ) 60 | _LOGGER.debug("Device: %s", self.device) 61 | self.async_write_ha_state() 62 | 63 | @property 64 | def device_class(self) -> str: 65 | """Return device class.""" 66 | # https://developers.home-assistant.io/docs/core/entity/binary-sensor#available-device-classes 67 | return BinarySensorDeviceClass.DOOR 68 | 69 | @property 70 | def device_info(self) -> DeviceInfo: 71 | """Return device information.""" 72 | # Identifiers are what group entities into the same device. 73 | # If your device is created elsewhere, you can just specify the indentifiers parameter. 74 | # If your device connects via another device, add via_device parameter with the indentifiers of that device. 75 | return DeviceInfo( 76 | name=f"ExampleDevice{self.device.device_id}", 77 | manufacturer="ACME Manufacturer", 78 | model="Door&Temp v1", 79 | sw_version="1.0", 80 | identifiers={ 81 | ( 82 | DOMAIN, 83 | f"{self.coordinator.data.controller_name}-{self.device.device_id}", 84 | ) 85 | }, 86 | ) 87 | 88 | @property 89 | def name(self) -> str: 90 | """Return the name of the sensor.""" 91 | return self.device.name 92 | 93 | @property 94 | def is_on(self) -> bool | None: 95 | """Return if the binary sensor is on.""" 96 | # This needs to enumerate to true or false 97 | return self.device.state 98 | 99 | @property 100 | def unique_id(self) -> str: 101 | """Return unique id.""" 102 | # All entities must have a unique id. Think carefully what you want this to be as 103 | # changing it later will cause HA to create new entities. 104 | return f"{DOMAIN}-{self.device.device_unique_id}" 105 | 106 | @property 107 | def extra_state_attributes(self): 108 | """Return the extra state attributes.""" 109 | # Add any additional attributes you want on your sensor. 110 | attrs = {} 111 | attrs["extra_info"] = "Extra Info" 112 | return attrs 113 | -------------------------------------------------------------------------------- /msp_integration_101_template/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Integration 101 Template integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ( 11 | ConfigEntry, 12 | ConfigFlow, 13 | ConfigFlowResult, 14 | OptionsFlow, 15 | ) 16 | from homeassistant.const import ( 17 | CONF_HOST, 18 | CONF_PASSWORD, 19 | CONF_SCAN_INTERVAL, 20 | CONF_USERNAME, 21 | ) 22 | from homeassistant.core import HomeAssistant, callback 23 | from homeassistant.exceptions import HomeAssistantError 24 | 25 | from .api import API, APIAuthError, APIConnectionError 26 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | # TODO adjust the data schema to the data that you need 31 | STEP_USER_DATA_SCHEMA = vol.Schema( 32 | { 33 | vol.Required(CONF_HOST, description={"suggested_value": "10.10.10.1"}): str, 34 | vol.Required(CONF_USERNAME, description={"suggested_value": "test"}): str, 35 | vol.Required(CONF_PASSWORD, description={"suggested_value": "1234"}): str, 36 | } 37 | ) 38 | 39 | 40 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 41 | """Validate the user input allows us to connect. 42 | 43 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 44 | """ 45 | # TODO validate the data can be used to set up a connection. 46 | 47 | # If your PyPI package is not built with async, pass your methods 48 | # to the executor: 49 | # await hass.async_add_executor_job( 50 | # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] 51 | # ) 52 | 53 | api = API(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) 54 | try: 55 | await hass.async_add_executor_job(api.connect) 56 | # If you cannot connect, raise CannotConnect 57 | # If the authentication is wrong, raise InvalidAuth 58 | except APIAuthError as err: 59 | raise InvalidAuth from err 60 | except APIConnectionError as err: 61 | raise CannotConnect from err 62 | return {"title": f"Example Integration - {data[CONF_HOST]}"} 63 | 64 | 65 | class ExampleConfigFlow(ConfigFlow, domain=DOMAIN): 66 | """Handle a config flow for Example Integration.""" 67 | 68 | VERSION = 1 69 | _input_data: dict[str, Any] 70 | 71 | @staticmethod 72 | @callback 73 | def async_get_options_flow(config_entry): 74 | """Get the options flow for this handler.""" 75 | # Remove this method and the ExampleOptionsFlowHandler class 76 | # if you do not want any options for your integration. 77 | return ExampleOptionsFlowHandler(config_entry) 78 | 79 | async def async_step_user( 80 | self, user_input: dict[str, Any] | None = None 81 | ) -> ConfigFlowResult: 82 | """Handle the initial step.""" 83 | # Called when you initiate adding an integration via the UI 84 | errors: dict[str, str] = {} 85 | 86 | if user_input is not None: 87 | # The form has been filled in and submitted, so process the data provided. 88 | try: 89 | # Validate that the setup data is valid and if not handle errors. 90 | # The errors["base"] values match the values in your strings.json and translation files. 91 | info = await validate_input(self.hass, user_input) 92 | except CannotConnect: 93 | errors["base"] = "cannot_connect" 94 | except InvalidAuth: 95 | errors["base"] = "invalid_auth" 96 | except Exception: # pylint: disable=broad-except 97 | _LOGGER.exception("Unexpected exception") 98 | errors["base"] = "unknown" 99 | 100 | if "base" not in errors: 101 | # Validation was successful, so create a unique id for this instance of your integration 102 | # and create the config entry. 103 | await self.async_set_unique_id(info.get("title")) 104 | self._abort_if_unique_id_configured() 105 | return self.async_create_entry(title=info["title"], data=user_input) 106 | 107 | # Show initial form. 108 | return self.async_show_form( 109 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 110 | ) 111 | 112 | async def async_step_reconfigure( 113 | self, user_input: dict[str, Any] | None = None 114 | ) -> ConfigFlowResult: 115 | """Add reconfigure step to allow to reconfigure a config entry.""" 116 | # This methid displays a reconfigure option in the integration and is 117 | # different to options. 118 | # It can be used to reconfigure any of the data submitted when first installed. 119 | # This is optional and can be removed if you do not want to allow reconfiguration. 120 | errors: dict[str, str] = {} 121 | config_entry = self.hass.config_entries.async_get_entry( 122 | self.context["entry_id"] 123 | ) 124 | 125 | if user_input is not None: 126 | try: 127 | user_input[CONF_HOST] = config_entry.data[CONF_HOST] 128 | await validate_input(self.hass, user_input) 129 | except CannotConnect: 130 | errors["base"] = "cannot_connect" 131 | except InvalidAuth: 132 | errors["base"] = "invalid_auth" 133 | except Exception: # pylint: disable=broad-except 134 | _LOGGER.exception("Unexpected exception") 135 | errors["base"] = "unknown" 136 | else: 137 | return self.async_update_reload_and_abort( 138 | config_entry, 139 | unique_id=config_entry.unique_id, 140 | data={**config_entry.data, **user_input}, 141 | reason="reconfigure_successful", 142 | ) 143 | return self.async_show_form( 144 | step_id="reconfigure", 145 | data_schema=vol.Schema( 146 | { 147 | vol.Required( 148 | CONF_USERNAME, default=config_entry.data[CONF_USERNAME] 149 | ): str, 150 | vol.Required(CONF_PASSWORD): str, 151 | } 152 | ), 153 | errors=errors, 154 | ) 155 | 156 | 157 | class ExampleOptionsFlowHandler(OptionsFlow): 158 | """Handles the options flow.""" 159 | 160 | def __init__(self, config_entry: ConfigEntry) -> None: 161 | """Initialize options flow.""" 162 | self.config_entry = config_entry 163 | self.options = dict(config_entry.options) 164 | 165 | async def async_step_init(self, user_input=None): 166 | """Handle options flow.""" 167 | if user_input is not None: 168 | options = self.config_entry.options | user_input 169 | return self.async_create_entry(title="", data=options) 170 | 171 | # It is recommended to prepopulate options fields with default values if available. 172 | # These will be the same default values you use on your coordinator for setting variable values 173 | # if the option has not been set. 174 | data_schema = vol.Schema( 175 | { 176 | vol.Required( 177 | CONF_SCAN_INTERVAL, 178 | default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), 179 | ): (vol.All(vol.Coerce(int), vol.Clamp(min=MIN_SCAN_INTERVAL))), 180 | } 181 | ) 182 | 183 | return self.async_show_form(step_id="init", data_schema=data_schema) 184 | 185 | 186 | class CannotConnect(HomeAssistantError): 187 | """Error to indicate we cannot connect.""" 188 | 189 | 190 | class InvalidAuth(HomeAssistantError): 191 | """Error to indicate there is invalid auth.""" 192 | -------------------------------------------------------------------------------- /msp_integration_101_template/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Integration 101 Template integration.""" 2 | 3 | DOMAIN = "msp_integration_101_template" 4 | 5 | DEFAULT_SCAN_INTERVAL = 60 6 | MIN_SCAN_INTERVAL = 10 7 | -------------------------------------------------------------------------------- /msp_integration_101_template/coordinator.py: -------------------------------------------------------------------------------- 1 | """Integration 101 Template integration using DataUpdateCoordinator.""" 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import ( 9 | CONF_HOST, 10 | CONF_PASSWORD, 11 | CONF_SCAN_INTERVAL, 12 | CONF_USERNAME, 13 | ) 14 | from homeassistant.core import DOMAIN, HomeAssistant 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .api import API, APIAuthError, Device, DeviceType 18 | from .const import DEFAULT_SCAN_INTERVAL 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | @dataclass 24 | class ExampleAPIData: 25 | """Class to hold api data.""" 26 | 27 | controller_name: str 28 | devices: list[Device] 29 | 30 | 31 | class ExampleCoordinator(DataUpdateCoordinator): 32 | """My example coordinator.""" 33 | 34 | data: ExampleAPIData 35 | 36 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 37 | """Initialize coordinator.""" 38 | 39 | # Set variables from values entered in config flow setup 40 | self.host = config_entry.data[CONF_HOST] 41 | self.user = config_entry.data[CONF_USERNAME] 42 | self.pwd = config_entry.data[CONF_PASSWORD] 43 | 44 | # set variables from options. You need a default here incase options have not been set 45 | self.poll_interval = config_entry.options.get( 46 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 47 | ) 48 | 49 | # Initialise DataUpdateCoordinator 50 | super().__init__( 51 | hass, 52 | _LOGGER, 53 | name=f"{DOMAIN} ({config_entry.unique_id})", 54 | # Method to call on every update interval. 55 | update_method=self.async_update_data, 56 | # Polling interval. Will only be polled if there are subscribers. 57 | # Using config option here but you can just use a value. 58 | update_interval=timedelta(seconds=self.poll_interval), 59 | ) 60 | 61 | # Initialise your api here 62 | self.api = API(host=self.host, user=self.user, pwd=self.pwd) 63 | 64 | async def async_update_data(self): 65 | """Fetch data from API endpoint. 66 | 67 | This is the place to pre-process the data to lookup tables 68 | so entities can quickly look up their data. 69 | """ 70 | try: 71 | if not self.api.connected: 72 | await self.hass.async_add_executor_job(self.api.connect) 73 | devices = await self.hass.async_add_executor_job(self.api.get_devices) 74 | except APIAuthError as err: 75 | _LOGGER.error(err) 76 | raise UpdateFailed(err) from err 77 | except Exception as err: 78 | # This will show entities as unavailable by raising UpdateFailed exception 79 | raise UpdateFailed(f"Error communicating with API: {err}") from err 80 | 81 | # What is returned here is stored in self.data by the DataUpdateCoordinator 82 | return ExampleAPIData(self.api.controller_name, devices) 83 | 84 | def get_device_by_id( 85 | self, device_type: DeviceType, device_id: int 86 | ) -> Device | None: 87 | """Return device by device id.""" 88 | # Called by the binary sensors and sensors to get their updated data from self.data 89 | try: 90 | return [ 91 | device 92 | for device in self.data.devices 93 | if device.device_type == device_type and device.device_id == device_id 94 | ][0] 95 | except IndexError: 96 | return None 97 | -------------------------------------------------------------------------------- /msp_integration_101_template/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "msp_integration_101_template", 3 | "name": "MSP Integration 101 Template Example", 4 | "codeowners": [ 5 | "@msp1974" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/msp1974/HAIntegrationExamples", 10 | "homekit": {}, 11 | "iot_class": "local_polling", 12 | "requirements": [], 13 | "single_config_entry": false, 14 | "ssdp": [], 15 | "version": "1.0.1", 16 | "zeroconf": [] 17 | } -------------------------------------------------------------------------------- /msp_integration_101_template/sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Integration 101 Template api sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorEntity, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.const import UnitOfTemperature 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.helpers.device_registry import DeviceInfo 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 15 | 16 | from . import MyConfigEntry 17 | from .api import Device, DeviceType 18 | from .const import DOMAIN 19 | from .coordinator import ExampleCoordinator 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | config_entry: MyConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ): 29 | """Set up the Sensors.""" 30 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 31 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 32 | 33 | # Enumerate all the sensors in your data value from your DataUpdateCoordinator and add an instance of your sensor class 34 | # to a list for each one. 35 | # This maybe different in your specific case, depending on how your data is structured 36 | sensors = [ 37 | ExampleSensor(coordinator, device) 38 | for device in coordinator.data.devices 39 | if device.device_type == DeviceType.TEMP_SENSOR 40 | ] 41 | 42 | # Create the sensors. 43 | async_add_entities(sensors) 44 | 45 | 46 | class ExampleSensor(CoordinatorEntity, SensorEntity): 47 | """Implementation of a sensor.""" 48 | 49 | def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None: 50 | """Initialise sensor.""" 51 | super().__init__(coordinator) 52 | self.device = device 53 | self.device_id = device.device_id 54 | 55 | @callback 56 | def _handle_coordinator_update(self) -> None: 57 | """Update sensor with latest data from coordinator.""" 58 | # This method is called by your DataUpdateCoordinator when a successful update runs. 59 | self.device = self.coordinator.get_device_by_id( 60 | self.device.device_type, self.device_id 61 | ) 62 | _LOGGER.debug("Device: %s", self.device) 63 | self.async_write_ha_state() 64 | 65 | @property 66 | def device_class(self) -> str: 67 | """Return device class.""" 68 | # https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes 69 | return SensorDeviceClass.TEMPERATURE 70 | 71 | @property 72 | def device_info(self) -> DeviceInfo: 73 | """Return device information.""" 74 | # Identifiers are what group entities into the same device. 75 | # If your device is created elsewhere, you can just specify the indentifiers parameter. 76 | # If your device connects via another device, add via_device parameter with the indentifiers of that device. 77 | return DeviceInfo( 78 | name=f"ExampleDevice{self.device.device_id}", 79 | manufacturer="ACME Manufacturer", 80 | model="Door&Temp v1", 81 | sw_version="1.0", 82 | identifiers={ 83 | ( 84 | DOMAIN, 85 | f"{self.coordinator.data.controller_name}-{self.device.device_id}", 86 | ) 87 | }, 88 | ) 89 | 90 | @property 91 | def name(self) -> str: 92 | """Return the name of the sensor.""" 93 | return self.device.name 94 | 95 | @property 96 | def native_value(self) -> int | float: 97 | """Return the state of the entity.""" 98 | # Using native value and native unit of measurement, allows you to change units 99 | # in Lovelace and HA will automatically calculate the correct value. 100 | return float(self.device.state) 101 | 102 | @property 103 | def native_unit_of_measurement(self) -> str | None: 104 | """Return unit of temperature.""" 105 | return UnitOfTemperature.CELSIUS 106 | 107 | @property 108 | def state_class(self) -> str | None: 109 | """Return state class.""" 110 | # https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes 111 | return SensorStateClass.MEASUREMENT 112 | 113 | @property 114 | def unique_id(self) -> str: 115 | """Return unique id.""" 116 | # All entities must have a unique id. Think carefully what you want this to be as 117 | # changing it later will cause HA to create new entities. 118 | return f"{DOMAIN}-{self.device.device_unique_id}" 119 | 120 | @property 121 | def extra_state_attributes(self): 122 | """Return the extra state attributes.""" 123 | # Add any additional attributes you want on your sensor. 124 | attrs = {} 125 | attrs["extra_info"] = "Extra Info" 126 | return attrs 127 | -------------------------------------------------------------------------------- /msp_integration_101_template/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Integration 101 Template Integration", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful" 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Password", 18 | "username": "Username" 19 | } 20 | }, 21 | "reconfigure": { 22 | "data": { 23 | "host": "Host", 24 | "password": "Password", 25 | "username": "Username" 26 | } 27 | } 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "init": { 33 | "data": { 34 | "scan_interval": "Scan Interval (seconds)" 35 | }, 36 | "description": "Amend your options.", 37 | "title": "Example Integration Options" 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /msp_integration_101_template/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Integration 101 Template Integration", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful" 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Password", 18 | "username": "Username" 19 | } 20 | }, 21 | "reconfigure": { 22 | "data": { 23 | "host": "Host", 24 | "password": "Password", 25 | "username": "Username" 26 | } 27 | } 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "init": { 33 | "data": { 34 | "scan_interval": "Scan Interval (seconds)" 35 | }, 36 | "description": "Amend your options.", 37 | "title": "Example Integration Options" 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /msp_push_data_example/__init__.py: -------------------------------------------------------------------------------- 1 | """The Websocket Callback Example integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | import logging 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import Platform 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import ConfigEntryNotReady 13 | from homeassistant.helpers.device_registry import DeviceEntry 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 15 | 16 | from .const import DOMAIN 17 | from .coordinator import ExampleCoordinator 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] 22 | 23 | type MyConfigEntry = ConfigEntry[RuntimeData] 24 | 25 | @dataclass 26 | class RuntimeData: 27 | """Class to hold your data.""" 28 | 29 | coordinator: DataUpdateCoordinator 30 | cancel_update_listener: Callable 31 | 32 | 33 | async def async_setup_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 34 | """Set up Example Integration from a config entry.""" 35 | 36 | # Initialise the coordinator that manages data updates from your api. 37 | # This is defined in coordinator.py 38 | coordinator = ExampleCoordinator(hass, config_entry) 39 | 40 | # Perform an initial data load from api. 41 | # async_config_entry_first_refresh() is special in that it does not log errors if it fails 42 | await coordinator.async_config_entry_first_refresh() 43 | 44 | 45 | # Test to see if api initialised correctly, else raise ConfigNotReady to make HA retry setup 46 | # TODO: Change this to match how your api will know if connected or successful update 47 | if not coordinator.api.connected: 48 | raise ConfigEntryNotReady 49 | 50 | # Initialise a listener for config flow options changes. 51 | # This will be removed automatically if the integraiton is unloaded. 52 | # See config_flow for defining an options setting that shows up as configure 53 | # on the integration. 54 | # If you do not want any config flow options, no need to have listener. 55 | cancel_update_listener = config_entry.async_on_unload( 56 | config_entry.add_update_listener(_async_update_listener) 57 | ) 58 | 59 | # Add the coordinator and update listener to config runtime data to make 60 | # accessible throughout your integration 61 | config_entry.runtime_data = RuntimeData(coordinator, cancel_update_listener) 62 | 63 | # Setup platforms (based on the list of entity types in PLATFORMS defined above) 64 | # This calls the async_setup method in each of your entity type files. 65 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 66 | 67 | # Return true to denote a successful setup. 68 | return True 69 | 70 | 71 | async def _async_update_listener(hass: HomeAssistant, config_entry): 72 | """Handle config options update.""" 73 | # Reload the integration when the options change. 74 | await hass.config_entries.async_reload(config_entry.entry_id) 75 | 76 | 77 | async def async_remove_config_entry_device( 78 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 79 | ) -> bool: 80 | """Delete device if selected from UI.""" 81 | # Adding this function shows the delete device option in the UI. 82 | # Remove this function if you do not want that option. 83 | # You may need to do some checks here before allowing devices to be removed. 84 | return True 85 | 86 | 87 | async def async_unload_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 88 | """Unload a config entry.""" 89 | # This is called when you remove your integration or shutdown HA. 90 | # If you have created any custom services, they need to be removed here too. 91 | 92 | # Unload platforms and return result 93 | return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 94 | -------------------------------------------------------------------------------- /msp_push_data_example/api.py: -------------------------------------------------------------------------------- 1 | """API Placeholder. 2 | 3 | You should create your api seperately and have it hosted on PYPI. This is included here for the sole purpose 4 | of making this example code executable. 5 | """ 6 | 7 | import asyncio 8 | from collections.abc import Callable 9 | from dataclasses import dataclass 10 | from enum import StrEnum 11 | import logging 12 | from random import choice, randrange 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class DeviceType(StrEnum): 18 | """Device types.""" 19 | 20 | TEMP_SENSOR = "temp_sensor" 21 | DOOR_SENSOR = "door_sensor" 22 | OTHER = "other" 23 | 24 | 25 | DEVICES = [ 26 | {"id": 1, "type": DeviceType.TEMP_SENSOR}, 27 | {"id": 2, "type": DeviceType.TEMP_SENSOR}, 28 | {"id": 3, "type": DeviceType.TEMP_SENSOR}, 29 | {"id": 4, "type": DeviceType.TEMP_SENSOR}, 30 | {"id": 1, "type": DeviceType.DOOR_SENSOR}, 31 | {"id": 2, "type": DeviceType.DOOR_SENSOR}, 32 | {"id": 3, "type": DeviceType.DOOR_SENSOR}, 33 | {"id": 4, "type": DeviceType.DOOR_SENSOR}, 34 | ] 35 | 36 | 37 | @dataclass 38 | class Device: 39 | """API device.""" 40 | 41 | device_id: int 42 | device_unique_id: str 43 | device_type: DeviceType 44 | name: str 45 | state: int | bool 46 | 47 | 48 | class API: 49 | """Class for example API.""" 50 | 51 | def __init__(self, host: str, user: str, pwd: str) -> None: 52 | """Initialise.""" 53 | self.host = host 54 | self.user = user 55 | self.pwd = pwd 56 | self.connected: bool = False 57 | 58 | @property 59 | def controller_name(self) -> str: 60 | """Return the name of the controller.""" 61 | return self.host.replace(".", "_") 62 | 63 | def connect(self) -> bool: 64 | """Connect to api.""" 65 | if self.user == "test" and self.pwd == "1234": 66 | self.connected = True 67 | return True 68 | raise APIAuthError("Error connecting to api. Invalid username or password.") 69 | 70 | def disconnect(self) -> bool: 71 | """Disconnect from api.""" 72 | self.connected = False 73 | return True 74 | 75 | def get_devices(self) -> list[Device]: 76 | """Get devices on api.""" 77 | return [ 78 | Device( 79 | device_id=device.get("id"), 80 | device_unique_id=self.get_device_unique_id( 81 | device.get("id"), device.get("type") 82 | ), 83 | device_type=device.get("type"), 84 | name=self.get_device_name(device.get("id"), device.get("type")), 85 | state=self.get_device_value(device.get("id"), device.get("type")), 86 | ) 87 | for device in DEVICES 88 | ] 89 | 90 | def get_device_unique_id(self, device_id: str, device_type: DeviceType) -> str: 91 | """Return a unique device id.""" 92 | if device_type == DeviceType.DOOR_SENSOR: 93 | return f"{self.controller_name}_D{device_id}" 94 | if device_type == DeviceType.TEMP_SENSOR: 95 | return f"{self.controller_name}_T{device_id}" 96 | return f"{self.controller_name}_Z{device_id}" 97 | 98 | def get_device_name(self, device_id: str, device_type: DeviceType) -> str: 99 | """Return the device name.""" 100 | if device_type == DeviceType.DOOR_SENSOR: 101 | return f"DoorSensor{device_id}" 102 | if device_type == DeviceType.TEMP_SENSOR: 103 | return f"TempSensor{device_id}" 104 | return f"OtherSensor{device_id}" 105 | 106 | def get_device_value(self, device_id: str, device_type: DeviceType) -> int | bool: 107 | """Get device random value.""" 108 | if device_type == DeviceType.DOOR_SENSOR: 109 | return choice([True, False]) 110 | if device_type == DeviceType.TEMP_SENSOR: 111 | return randrange(15, 28) 112 | return randrange(1, 10) 113 | 114 | 115 | class PushAPI(API): 116 | """Mimic for a push api.""" 117 | 118 | def __init__( 119 | self, host: str, user: str, pwd: str, message_callback: Callable | None = None 120 | ) -> None: 121 | """Initialise.""" 122 | super().__init__(host, user, pwd) 123 | self.message_callback = message_callback 124 | self._task: asyncio.Task = None 125 | 126 | async def async_connect(self) -> bool: 127 | """Connect tothe api. 128 | 129 | In this case we will create a task to add the device update function call 130 | to the event loop and return. 131 | """ 132 | if super().connect(): 133 | if self.message_callback: 134 | loop = asyncio.get_running_loop() 135 | self._task = loop.create_task(self.async_update_devices()) 136 | return True 137 | 138 | async def async_disconnect(self) -> bool: 139 | """Disconnect from api.""" 140 | if self._task: 141 | self._task.cancel() 142 | super().disconnect() 143 | return True 144 | 145 | async def async_get_devices(self) -> list[Device]: 146 | """Async version of get_devices.""" 147 | loop = asyncio.get_running_loop() 148 | return await loop.run_in_executor(None, self.get_devices) 149 | 150 | async def async_update_devices(self) -> None: 151 | """Loop to send updated device data every 15s.""" 152 | while self.connected: 153 | delay = randrange(10, 12) 154 | _LOGGER.debug("Next update for devices in %is", delay) 155 | await asyncio.sleep(delay) 156 | devices = await self.get_devices() 157 | if asyncio.iscoroutinefunction(self.message_callback): 158 | await self.message_callback(devices) 159 | else: 160 | self.message_callback(devices) 161 | 162 | 163 | class APIAuthError(Exception): 164 | """Exception class for auth error.""" 165 | 166 | 167 | class APIConnectionError(Exception): 168 | """Exception class for connection error.""" 169 | -------------------------------------------------------------------------------- /msp_push_data_example/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Example api sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, 7 | BinarySensorEntity, 8 | ) 9 | from homeassistant.core import HomeAssistant, callback 10 | from homeassistant.helpers.device_registry import DeviceInfo 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 13 | 14 | from . import MyConfigEntry 15 | from .api import Device, DeviceType 16 | from .const import DOMAIN 17 | from .coordinator import ExampleCoordinator 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: MyConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ): 27 | """Set up the Binary Sensors.""" 28 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 29 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 30 | 31 | # Enumerate all the binary sensors in your data value from your DataUpdateCoordinator and add an instance of your binary sensor class 32 | # to a list for each one. 33 | # This maybe different in your specific case, depending on how your data is structured 34 | binary_sensors = [ 35 | ExampleBinarySensor(coordinator, device) 36 | for device in coordinator.data.devices 37 | if device.device_type == DeviceType.DOOR_SENSOR 38 | ] 39 | 40 | # Create the binary sensors. 41 | async_add_entities(binary_sensors) 42 | 43 | 44 | class ExampleBinarySensor(CoordinatorEntity, BinarySensorEntity): 45 | """Implementation of a sensor.""" 46 | 47 | def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None: 48 | """Initialise sensor.""" 49 | super().__init__(coordinator) 50 | self.device = device 51 | self.device_id = device.device_id 52 | 53 | @callback 54 | def _handle_coordinator_update(self) -> None: 55 | """Update sensor with latest data from coordinator.""" 56 | # This method is called by your DataUpdateCoordinator when a successful update runs. 57 | self.device = self.coordinator.get_device_by_id( 58 | self.device.device_type, self.device_id 59 | ) 60 | self.async_write_ha_state() 61 | 62 | @property 63 | def device_class(self) -> str: 64 | """Return device class.""" 65 | # https://developers.home-assistant.io/docs/core/entity/binary-sensor#available-device-classes 66 | return BinarySensorDeviceClass.DOOR 67 | 68 | @property 69 | def device_info(self) -> DeviceInfo: 70 | """Return device information.""" 71 | # Identifiers are what group entities into the same device. 72 | # If your device is created elsewhere, you can just specify the indentifiers parameter. 73 | # If your device connects via another device, add via_device parameter with the indentifiers of that device. 74 | return DeviceInfo( 75 | name=f"ExampleDevice{self.device.device_id}", 76 | manufacturer="ACME Manufacturer", 77 | model="Door&Temp v1", 78 | sw_version="1.0", 79 | identifiers={ 80 | ( 81 | DOMAIN, 82 | f"{self.coordinator.data.controller_name}-{self.device.device_id}", 83 | ) 84 | }, 85 | ) 86 | 87 | @property 88 | def name(self) -> str: 89 | """Return the name of the sensor.""" 90 | return self.device.name 91 | 92 | @property 93 | def is_on(self) -> bool | None: 94 | """Return if the binary sensor is on.""" 95 | # This needs to enumerate to true or false 96 | return self.device.state 97 | 98 | @property 99 | def unique_id(self) -> str: 100 | """Return unique id.""" 101 | # All entities must have a unique id. Think carefully what you want this to be as 102 | # changing it later will cause HA to create new entities. 103 | return f"{DOMAIN}-{self.device.device_unique_id}" 104 | 105 | @property 106 | def extra_state_attributes(self): 107 | """Return the extra state attributes.""" 108 | # Add any additional attributes you want on your sensor. 109 | attrs = {} 110 | attrs["extra_info"] = "Extra Info" 111 | return attrs 112 | -------------------------------------------------------------------------------- /msp_push_data_example/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Example Integration integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ( 11 | ConfigEntry, 12 | ConfigFlow, 13 | ConfigFlowResult, 14 | OptionsFlow, 15 | ) 16 | from homeassistant.const import ( 17 | CONF_HOST, 18 | CONF_PASSWORD, 19 | CONF_SCAN_INTERVAL, 20 | CONF_USERNAME, 21 | ) 22 | from homeassistant.core import HomeAssistant, callback 23 | from homeassistant.exceptions import HomeAssistantError 24 | 25 | from .api import APIAuthError, APIConnectionError, PushAPI 26 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | # TODO adjust the data schema to the data that you need 31 | STEP_USER_DATA_SCHEMA = vol.Schema( 32 | { 33 | vol.Required(CONF_HOST, description={"suggested_value": "10.10.10.1"}): str, 34 | vol.Required(CONF_USERNAME, description={"suggested_value": "test"}): str, 35 | vol.Required(CONF_PASSWORD, description={"suggested_value": "1234"}): str, 36 | } 37 | ) 38 | 39 | 40 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 41 | """Validate the user input allows us to connect. 42 | 43 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 44 | """ 45 | # TODO validate the data can be used to set up a connection. 46 | 47 | # If your PyPI package is not built with async, pass your methods 48 | # to the executor: 49 | # await hass.async_add_executor_job( 50 | # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] 51 | # ) 52 | 53 | api = PushAPI(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) 54 | try: 55 | await api.async_connect() 56 | # If you cannot connect, raise CannotConnect 57 | # If the authentication is wrong, raise InvalidAuth 58 | except APIAuthError as err: 59 | raise InvalidAuth from err 60 | except APIConnectionError as err: 61 | raise CannotConnect from err 62 | return {"title": f"Example Integration - {data[CONF_HOST]}"} 63 | 64 | 65 | class ExampleConfigFlow(ConfigFlow, domain=DOMAIN): 66 | """Handle a config flow for Example Integration.""" 67 | 68 | VERSION = 1 69 | _input_data: dict[str, Any] 70 | 71 | @staticmethod 72 | @callback 73 | def async_get_options_flow(config_entry): 74 | """Get the options flow for this handler.""" 75 | # Remove this method and the ExampleOptionsFlowHandler class 76 | # if you do not want any options for your integration. 77 | return ExampleOptionsFlowHandler(config_entry) 78 | 79 | async def async_step_user( 80 | self, user_input: dict[str, Any] | None = None 81 | ) -> ConfigFlowResult: 82 | """Handle the initial step.""" 83 | # Called when you initiate adding an integration via the UI 84 | errors: dict[str, str] = {} 85 | 86 | if user_input is not None: 87 | # The form has been filled in and submitted, so process the data provided. 88 | try: 89 | # Validate that the setup data is valid and if not handle errors. 90 | # The errors["base"] values match the values in your strings.json and translation files. 91 | info = await validate_input(self.hass, user_input) 92 | except CannotConnect: 93 | errors["base"] = "cannot_connect" 94 | except InvalidAuth: 95 | errors["base"] = "invalid_auth" 96 | except Exception: # pylint: disable=broad-except 97 | _LOGGER.exception("Unexpected exception") 98 | errors["base"] = "unknown" 99 | 100 | if "base" not in errors: 101 | # Validation was successful, so create a unique id for this instance of your integration 102 | # and create the config entry. 103 | await self.async_set_unique_id(info.get("title")) 104 | self._abort_if_unique_id_configured() 105 | return self.async_create_entry(title=info["title"], data=user_input) 106 | 107 | # Show initial form. 108 | return self.async_show_form( 109 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 110 | ) 111 | 112 | async def async_step_reconfigure( 113 | self, user_input: dict[str, Any] | None = None 114 | ) -> ConfigFlowResult: 115 | """Add reconfigure step to allow to reconfigure a config entry.""" 116 | # This methid displays a reconfigure option in the integration and is 117 | # different to options. 118 | # It can be used to reconfigure any of the data submitted when first installed. 119 | # This is optional and can be removed if you do not want to allow reconfiguration. 120 | errors: dict[str, str] = {} 121 | config_entry = self.hass.config_entries.async_get_entry( 122 | self.context["entry_id"] 123 | ) 124 | 125 | if user_input is not None: 126 | try: 127 | user_input[CONF_HOST] = config_entry.data[CONF_HOST] 128 | await validate_input(self.hass, user_input) 129 | except CannotConnect: 130 | errors["base"] = "cannot_connect" 131 | except InvalidAuth: 132 | errors["base"] = "invalid_auth" 133 | except Exception: # pylint: disable=broad-except 134 | _LOGGER.exception("Unexpected exception") 135 | errors["base"] = "unknown" 136 | else: 137 | return self.async_update_reload_and_abort( 138 | config_entry, 139 | unique_id=config_entry.unique_id, 140 | data={**config_entry.data, **user_input}, 141 | reason="reconfigure_successful", 142 | ) 143 | return self.async_show_form( 144 | step_id="reconfigure", 145 | data_schema=vol.Schema( 146 | { 147 | vol.Required( 148 | CONF_USERNAME, default=config_entry.data[CONF_USERNAME] 149 | ): str, 150 | vol.Required(CONF_PASSWORD): str, 151 | } 152 | ), 153 | errors=errors, 154 | ) 155 | 156 | 157 | class ExampleOptionsFlowHandler(OptionsFlow): 158 | """Handles the options flow.""" 159 | 160 | def __init__(self, config_entry: ConfigEntry) -> None: 161 | """Initialize options flow.""" 162 | self.config_entry = config_entry 163 | self.options = dict(config_entry.options) 164 | 165 | async def async_step_init(self, user_input=None): 166 | """Handle options flow.""" 167 | if user_input is not None: 168 | options = self.config_entry.options | user_input 169 | return self.async_create_entry(title="", data=options) 170 | 171 | # It is recommended to prepopulate options fields with default values if available. 172 | # These will be the same default values you use on your coordinator for setting variable values 173 | # if the option has not been set. 174 | data_schema = vol.Schema( 175 | { 176 | vol.Required( 177 | CONF_SCAN_INTERVAL, 178 | default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), 179 | ): (vol.All(vol.Coerce(int), vol.Clamp(min=MIN_SCAN_INTERVAL))), 180 | } 181 | ) 182 | 183 | return self.async_show_form(step_id="init", data_schema=data_schema) 184 | 185 | 186 | class CannotConnect(HomeAssistantError): 187 | """Error to indicate we cannot connect.""" 188 | 189 | 190 | class InvalidAuth(HomeAssistantError): 191 | """Error to indicate there is invalid auth.""" 192 | -------------------------------------------------------------------------------- /msp_push_data_example/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Push Data Example integration.""" 2 | 3 | DOMAIN = "msp_push_data_example" 4 | 5 | DEFAULT_SCAN_INTERVAL = 60 6 | MIN_SCAN_INTERVAL = 10 7 | -------------------------------------------------------------------------------- /msp_push_data_example/coordinator.py: -------------------------------------------------------------------------------- 1 | """Example integration using DataUpdateCoordinator.""" 2 | 3 | from dataclasses import dataclass 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import ( 8 | CONF_HOST, 9 | CONF_PASSWORD, 10 | CONF_SCAN_INTERVAL, 11 | CONF_USERNAME, 12 | ) 13 | from homeassistant.core import DOMAIN, HomeAssistant 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 15 | 16 | from .api import APIAuthError, Device, DeviceType, PushAPI 17 | from .const import DEFAULT_SCAN_INTERVAL 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class ExampleAPIData: 24 | """Class to hold api data.""" 25 | 26 | controller_name: str 27 | devices: list[Device] 28 | 29 | 30 | class ExampleCoordinator(DataUpdateCoordinator): 31 | """My example coordinator.""" 32 | 33 | data: ExampleAPIData 34 | 35 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 36 | """Initialize coordinator.""" 37 | 38 | # Set variables from values entered in config flow setup 39 | self.host = config_entry.data[CONF_HOST] 40 | self.user = config_entry.data[CONF_USERNAME] 41 | self.pwd = config_entry.data[CONF_PASSWORD] 42 | 43 | # set variables from options. You need a default here incase options have not been set 44 | self.poll_interval = config_entry.options.get( 45 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 46 | ) 47 | 48 | # Initialise DataUpdateCoordinator 49 | super().__init__( 50 | hass, 51 | _LOGGER, 52 | name=f"{DOMAIN} ({config_entry.unique_id})", 53 | # Set update method to get devices on first load. 54 | update_method=self.async_update_data, 55 | # Do not set a polling interval as data will be pushed. 56 | # You can remove this line but left here for explanatory purposes. 57 | update_interval=None, 58 | ) 59 | 60 | # Initialise your api here 61 | self.api = PushAPI( 62 | host=self.host, 63 | user=self.user, 64 | pwd=self.pwd, 65 | message_callback=self.devices_update_callback, 66 | ) 67 | 68 | async def devices_update_callback(self, devices: list[Device]): 69 | """Receive callback from api with device update.""" 70 | self.async_set_updated_data(ExampleAPIData(self.api.controller_name, devices)) 71 | 72 | async def connect_api(self): 73 | """Connect to api.""" 74 | await self.api.async_connect() 75 | 76 | async def disconnect_api(self): 77 | """Disconnect form api.""" 78 | await self.api.async_disconnect() 79 | 80 | async def async_update_data(self): 81 | """Fetch data from API endpoint. 82 | 83 | This is the place to pre-process the data to lookup tables 84 | so entities can quickly look up their data. 85 | """ 86 | try: 87 | if not self.api.connected: 88 | await self.connect_api() 89 | devices = await self.api.async_get_devices() 90 | except APIAuthError as err: 91 | _LOGGER.error(err) 92 | raise UpdateFailed(err) from err 93 | except Exception as err: 94 | # This will show entities as unavailable by raising UpdateFailed exception 95 | raise UpdateFailed(f"Error communicating with API: {err}") from err 96 | 97 | # What is returned here is stored in self.data by the DataUpdateCoordinator 98 | return ExampleAPIData(self.api.controller_name, devices) 99 | 100 | async def async_shutdown(self) -> None: 101 | """Run shutdown clean up.""" 102 | await super().async_shutdown() 103 | await self.disconnect_api() 104 | 105 | def get_device_by_id( 106 | self, device_type: DeviceType, device_id: int 107 | ) -> Device | None: 108 | """Return device by device id.""" 109 | # Called by the binary sensors and sensors to get their updated data from self.data 110 | try: 111 | return [ 112 | device 113 | for device in self.data.devices 114 | if device.device_type == device_type and device.device_id == device_id 115 | ][0] 116 | except IndexError: 117 | return None 118 | -------------------------------------------------------------------------------- /msp_push_data_example/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "msp_push_data_example", 3 | "name": "MSP Push Data Example Integration", 4 | "codeowners": [ 5 | "@msp1974" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/msp1974/HAIntegrationExamples", 10 | "homekit": {}, 11 | "iot_class": "local_polling", 12 | "requirements": [], 13 | "single_config_entry": false, 14 | "ssdp": [], 15 | "version": "1.0.1", 16 | "zeroconf": [] 17 | } -------------------------------------------------------------------------------- /msp_push_data_example/sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Example api sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorEntity, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.const import UnitOfTemperature 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.helpers.device_registry import DeviceInfo 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 15 | 16 | from . import MyConfigEntry 17 | from .api import Device, DeviceType 18 | from .const import DOMAIN 19 | from .coordinator import ExampleCoordinator 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | config_entry: MyConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ): 29 | """Set up the Binary Sensors.""" 30 | # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py 31 | coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator 32 | 33 | # Enumerate all the sensors in your data value from your DataUpdateCoordinator and add an instance of your sensor class 34 | # to a list for each one. 35 | # This maybe different in your specific case, depending on how your data is structured 36 | sensors = [ 37 | ExampleSensor(coordinator, device) 38 | for device in coordinator.data.devices 39 | if device.device_type == DeviceType.TEMP_SENSOR 40 | ] 41 | 42 | # Create the sensors. 43 | async_add_entities(sensors) 44 | 45 | 46 | class ExampleSensor(CoordinatorEntity, SensorEntity): 47 | """Implementation of a sensor.""" 48 | 49 | def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None: 50 | """Initialise sensor.""" 51 | super().__init__(coordinator) 52 | self.device = device 53 | self.device_id = device.device_id 54 | 55 | @callback 56 | def _handle_coordinator_update(self) -> None: 57 | """Update sensor with latest data from coordinator.""" 58 | # This method is called by your DataUpdateCoordinator when a successful update runs. 59 | self.device = self.coordinator.get_device_by_id( 60 | self.device.device_type, self.device_id 61 | ) 62 | _LOGGER.debug("Device: %s", self.device) 63 | self.async_write_ha_state() 64 | 65 | @property 66 | def device_class(self) -> str: 67 | """Return device class.""" 68 | # https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes 69 | return SensorDeviceClass.TEMPERATURE 70 | 71 | @property 72 | def device_info(self) -> DeviceInfo: 73 | """Return device information.""" 74 | # Identifiers are what group entities into the same device. 75 | # If your device is created elsewhere, you can just specify the indentifiers parameter. 76 | # If your device connects via another device, add via_device parameter with the indentifiers of that device. 77 | return DeviceInfo( 78 | name=f"ExampleDevice{self.device.device_id}", 79 | manufacturer="ACME Manufacturer", 80 | model="Door&Temp v1", 81 | sw_version="1.0", 82 | identifiers={ 83 | ( 84 | DOMAIN, 85 | f"{self.coordinator.data.controller_name}-{self.device.device_id}", 86 | ) 87 | }, 88 | ) 89 | 90 | @property 91 | def name(self) -> str: 92 | """Return the name of the sensor.""" 93 | return self.device.name 94 | 95 | @property 96 | def native_value(self) -> int | float: 97 | """Return the state of the entity.""" 98 | # Using native value and native unit of measurement, allows you to change units 99 | # in Lovelace and HA will automatically calculate the correct value. 100 | return float(self.device.state) 101 | 102 | @property 103 | def native_unit_of_measurement(self) -> str | None: 104 | """Return unit of temperature.""" 105 | return UnitOfTemperature.CELSIUS 106 | 107 | @property 108 | def state_class(self) -> str | None: 109 | """Return state class.""" 110 | # https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes 111 | return SensorStateClass.MEASUREMENT 112 | 113 | @property 114 | def unique_id(self) -> str: 115 | """Return unique id.""" 116 | # All entities must have a unique id. Think carefully what you want this to be as 117 | # changing it later will cause HA to create new entities. 118 | return f"{DOMAIN}-{self.device.device_unique_id}" 119 | 120 | @property 121 | def extra_state_attributes(self): 122 | """Return the extra state attributes.""" 123 | # Add any additional attributes you want on your sensor. 124 | attrs = {} 125 | attrs["extra_info"] = "Extra Info" 126 | return attrs 127 | -------------------------------------------------------------------------------- /msp_push_data_example/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Example Integration", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful" 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Password", 18 | "username": "Username" 19 | } 20 | }, 21 | "reconfigure": { 22 | "data": { 23 | "host": "Host", 24 | "password": "Password", 25 | "username": "Username" 26 | } 27 | } 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "init": { 33 | "data": { 34 | "scan_interval": "Scan Interval (seconds)" 35 | }, 36 | "description": "Amend your options.", 37 | "title": "Example Integration Options" 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /msp_push_data_example/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Example Integration", 4 | "abort": { 5 | "already_configured": "Device is already configured", 6 | "reconfigure_successful": "Reconfiguration successful" 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Password", 18 | "username": "Username" 19 | } 20 | }, 21 | "reconfigure": { 22 | "data": { 23 | "host": "Host", 24 | "password": "Password", 25 | "username": "Username" 26 | } 27 | } 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "init": { 33 | "data": { 34 | "scan_interval": "Scan Interval (seconds)" 35 | }, 36 | "description": "Amend your options.", 37 | "title": "Example Integration Options" 38 | } 39 | } 40 | } 41 | } --------------------------------------------------------------------------------