├── hacs.json ├── images ├── addon.png ├── logo.png ├── setup.png ├── success.png ├── integration.png ├── addintegration.png └── integrationadded.png ├── custom_components └── bacnet_interface │ ├── icons.json │ ├── manifest.json │ ├── services.yaml │ ├── const.py │ ├── coordinator.py │ ├── translations │ ├── en.json │ └── nl.json │ ├── binary_sensor.py │ ├── __init__.py │ ├── sensor.py │ ├── switch.py │ ├── select.py │ ├── number.py │ ├── config_flow.py │ └── helper.py ├── .github ├── workflows │ ├── hassfest.yaml │ └── validate.yaml └── ISSUE_TEMPLATE │ └── bug_report.md ├── README.md ├── .gitignore └── LICENSE /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bepacom BACnet/IP Integration", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /images/addon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/addon.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/setup.png -------------------------------------------------------------------------------- /images/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/success.png -------------------------------------------------------------------------------- /images/integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/integration.png -------------------------------------------------------------------------------- /custom_components/bacnet_interface/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "write_release": "mdi:hand-back-left-off" 4 | } 5 | } -------------------------------------------------------------------------------- /images/addintegration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/addintegration.png -------------------------------------------------------------------------------- /images/integrationadded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/HEAD/images/integrationadded.png -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" -------------------------------------------------------------------------------- /custom_components/bacnet_interface/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bacnet_interface", 3 | "name": "Bepacom BACnet/IP Interface", 4 | "codeowners": [ "@Bepacom-Raalte", "@GravySeal" ], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration", 8 | "integration_type": "hub", 9 | "iot_class": "local_push", 10 | "issue_tracker": "https://github.com/Bepacom-Raalte/Bepacom-BACnet-IP-Integration/issues", 11 | "requirements": [ "aioecopanel==0.0.14" ], 12 | "version": "0.2.3" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/services.yaml: -------------------------------------------------------------------------------- 1 | write_release: 2 | fields: 3 | priority: 4 | default: 15 5 | selector: 6 | number: 7 | min: 0 8 | max: 16 9 | mode: box 10 | entity_id: 11 | selector: 12 | entity: 13 | integration: bacnet_interface 14 | domain: 15 | - number 16 | - select 17 | - switch 18 | write_property: 19 | fields: 20 | property: 21 | default: presentValue 22 | selector: 23 | text: 24 | value: 25 | selector: 26 | text: 27 | priority: 28 | default: 15 29 | selector: 30 | number: 31 | min: 0 32 | max: 16 33 | mode: box 34 | array_index: 35 | selector: 36 | number: 37 | min: 0 38 | max: 16 39 | mode: box 40 | entity_id: 41 | selector: 42 | entity: 43 | integration: bacnet_interface 44 | domain: 45 | - number 46 | - select 47 | - switch -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | **Is it a bug of the integration or add-on?** 2 | The BACnet interface consists of 2 parts, the integration and the add-on. 3 | If the add-on isn't functioning correctly, please make an issue [here](https://github.com/Bepacom-Raalte/bepacom-HA-Addons)! 4 | 5 | **Upload log files** 6 | Please upload a couple of log files that could help understand the problem. 7 | 1. Upload Home Assistant logs from Settings > System > Logs. Press the download button at this page. 8 | 2. Upload the logs from the add-on web UI. There is a button to download logs. 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Bepacom BACnet/IP integration.""" 2 | 3 | import logging 4 | from datetime import timedelta 5 | 6 | import voluptuous as vol 7 | from homeassistant.const import ATTR_ENTITY_ID 8 | from homeassistant.helpers import config_validation as cv 9 | 10 | # This is the internal name of the integration, it should also match the directory 11 | # name for the integration. 12 | DOMAIN = "bacnet_interface" 13 | 14 | LOGGER = logging.getLogger(__package__) 15 | SCAN_INTERVAL = timedelta(seconds=60) 16 | 17 | STATETEXT_OFFSET = 1 # JCO 18 | 19 | NAME_OPTIONS = ["object_name", "description", "object_identifier"] 20 | 21 | WRITE_OPTIONS = ["presentValue", "relinquishDefault"] 22 | 23 | WRITE_RELEASE_SERVICE_NAME = "write_release" 24 | ATTR_PRIORITY = "priority" 25 | WRITE_RELEASE_SCHEMA = vol.Schema( 26 | { 27 | vol.Required(ATTR_ENTITY_ID): cv.entity_ids, 28 | vol.Optional(ATTR_PRIORITY): int, 29 | } 30 | ) 31 | 32 | WRITE_PROPERTY_SERVICE_NAME = "write_property" 33 | ATTR_PROPERTY = "property" 34 | ATTR_VALUE = "value" 35 | ATTR_INDEX = "array_index" 36 | WRITE_PROPERTY_SCHEMA = vol.Schema( 37 | { 38 | vol.Required(ATTR_ENTITY_ID): cv.entity_ids, 39 | vol.Optional(ATTR_PROPERTY): str, 40 | vol.Optional(ATTR_VALUE): str, 41 | vol.Optional(ATTR_INDEX): int, 42 | vol.Optional(ATTR_PRIORITY): int, 43 | } 44 | ) 45 | 46 | CONF_ANALOG_OUTPUT = "analog_output" 47 | CONF_ANALOG_VALUE = "analog_value" 48 | CONF_BINARY_OUTPUT = "binary_output" 49 | CONF_BINARY_VALUE = "binary_value" 50 | CONF_MULTISTATE_OUTPUT = "multistate_output" 51 | CONF_MULTISTATE_VALUE = "multistate_value" 52 | 53 | CONST_COMPARE_SIZE = 60 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bepacom EcoPanel BACnet Interface 2 | 3 | 4 | 5 | This integration is intended to display the data from the Bepacom EcoPanel BACnet Interface add-on on the Lovelace UI of Home Assistant. 6 | 7 | It currently supports these BACnet object types: 8 | 9 | - Analog Input 10 | - Analog Output 11 | - Analog Value 12 | - Binary Input 13 | - Binary Output 14 | - Binary Value 15 | - Multi State Input 16 | - Multi State Output 17 | - Multi State Value 18 | 19 | 20 | 21 | 22 | # HACS 23 | 24 | This repository can be added to your HACS instance! 25 | 26 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Bepacom-Raalte&repository=Bepacom-BACnet-IP-Integration&category=Integration) 27 | 28 | 29 | # Installation 30 | 31 | Firstly, install the Bepacom BACnet/IP add-on from here: 32 | 33 | [BACnet/IP-addon](https://github.com/Bepacom-Raalte/bepacom-HA-Addons/tree/main/bacnetinterface) 34 | 35 | 36 | 37 | Download and copy this integration to your Home Assistant's 'custom_components' folder located in /config/. 38 | 39 | If you don't know where this is located, follow this small explanation here. 40 | 41 | Through the 'Samba share' add-on, you can make this folder available on your network. 42 | 43 | To add this on your Windows PC, go to "This PC", right click and select add networklocation, and then follow the wizard. 44 | 45 | Your home assistant address should be something like \\homeassistant.local\config. 46 | 47 | When you got this done, create the 'custom_components' folder and paste the bacnet_interface integration folder there. 48 | 49 | Restart Home Assistant after putting this integration in /config/. 50 | 51 | 52 | # Configuration 53 | 54 | 1. Add a new instance of the Bepacom BACnet/IP Interface. 55 | 56 | 57 | 58 | 2. Specify your connection details and preferences. Below the options are explained. 59 | 60 | 61 | 62 | 3. Success! 63 | 64 | 65 | 66 | ## IP Address 67 | 68 | The IP address of the add-on. You can use an IP address or use the hostname of the add-on. 69 | The integration tries to automatically set an IP for you, but if this fails, you can use "127.0.0.1" as address. 70 | When using the hostname, you can write it as: "97683af0-bacnetinterface" 71 | 72 | ## Port 73 | 74 | This is the port the integration will use for communication with the add-on. 75 | The port can be whichever port you set in the add-on. If you set the port to blank in the add-on, use 8099 for this setting. 76 | Port 8099 is always open in add-ons. 77 | 78 | ## Entity enabled by default 79 | 80 | When setting up the integration for the first time, this setting will determine whether entities will be enabled by default or not. 81 | If you want to adjust this after setting up the integration for the first time, you have to enable the entities by hand. 82 | 83 | ## Entity name based on 84 | 85 | This setting will determine with what name an entity will appear in Home Assistant. There are 2 options which can be chosen from. 86 | Option "Object name" will set the name to the object name property of the BACnet object. 87 | Option "Description" will set the name to the description property of the BACnet object. 88 | 89 | 90 | # Errors 91 | 92 | If there is an error, be sure to check your supervisor logs. 93 | Make sure the add-on is running. If there was a problem with the add-on, reload the integration after solving the issue. 94 | If you think there's a bug or can't figure it out, feel free to contact the developers on [GitHub!](https://github.com/Bepacom-Raalte/bepacom-custom_components) 95 | 96 | 97 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for EcoPanel.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from datetime import timedelta 7 | 8 | from aioecopanel import (DeviceDict, DeviceDictError, EcoPanelConnectionClosed, 9 | EcoPanelError, Interface) 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP 12 | from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback 13 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 14 | from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, 15 | UpdateFailed) 16 | 17 | from .const import DOMAIN, LOGGER, SCAN_INTERVAL 18 | 19 | 20 | class EcoPanelDataUpdateCoordinator(DataUpdateCoordinator[DeviceDict]): 21 | """EcoPanel Data Update Coordinator""" 22 | 23 | config_entry: ConfigEntry 24 | 25 | def __init__( 26 | self, 27 | hass: HomeAssistant, 28 | entry: ConfigEntry, 29 | ) -> None: 30 | """Initialize EcoPanel data updater""" 31 | 32 | self.interface = Interface( 33 | host=entry.data[CONF_HOST], 34 | port=entry.data[CONF_PORT], 35 | session=async_get_clientsession(hass), 36 | ) 37 | self.unsub: CALLBACK_TYPE | None = None 38 | 39 | super().__init__( 40 | hass, 41 | LOGGER, 42 | name=DOMAIN, 43 | update_interval=SCAN_INTERVAL, 44 | ) 45 | 46 | @callback 47 | def _use_websocket(self) -> None: 48 | """Use websockets for updating""" 49 | 50 | def check_data(data) -> None: 51 | LOGGER.debug("check_data") 52 | if not isinstance(data, DeviceDict): 53 | LOGGER.warning(f"Received data is not DeviceDict type! {data}") 54 | elif data.devices is None: 55 | LOGGER.warning(f"Received data.devices is NoneType!") 56 | else: 57 | self.async_set_updated_data(data) 58 | 59 | async def listen() -> None: 60 | """Listen for state changes through websocket""" 61 | try: 62 | # Connect to websocket 63 | await self.interface.connect() 64 | except EcoPanelError as e: 65 | self.logger.info(e) 66 | # If shutting down... shut down gracefully 67 | if self.unsub: 68 | self.unsub() 69 | self.unsub = None 70 | LOGGER.debug(f"Unsub after failing to connect") 71 | return 72 | 73 | LOGGER.debug("Connected websocket") 74 | 75 | try: 76 | # This will stay running in the background. 77 | # It calls DataUpdateCoordinator.async_set_updated_data when a message is received on the websocket. 78 | # The data will then be accessable on coordinator.data where coordinator is the variable name of EcoPanelDataUpdateCoordinator. 79 | await self.interface.listen(callback=check_data) 80 | 81 | except EcoPanelConnectionClosed as err: 82 | self.last_update_success = False 83 | self.logger.info(err) 84 | except EcoPanelError as err: 85 | self.last_update_success = False 86 | self.async_update_listeners() 87 | self.logger.error(err) 88 | except Exception as err: 89 | self.last_update_success = False 90 | self.async_update_listeners() 91 | self.logger.error(err) 92 | 93 | LOGGER.debug("Disconnecting websocket after listening") 94 | 95 | # Make sure we are disconnected 96 | await self.interface.disconnect() 97 | if self.unsub: 98 | self.unsub() 99 | self.unsub = None 100 | 101 | async def close_websocket(_: Event) -> None: 102 | """Close WebSocket connection.""" 103 | LOGGER.debug("close_websocket") 104 | self.unsub = None 105 | await self.interface.disconnect() 106 | 107 | LOGGER.debug("Set unsub listener") 108 | 109 | # Clean disconnect WebSocket on Home Assistant shutdown 110 | self.unsub = self.hass.bus.async_listen_once( 111 | EVENT_HOMEASSISTANT_STOP, close_websocket 112 | ) 113 | 114 | # Start listening 115 | self.config_entry.async_create_background_task( 116 | self.hass, listen(), "bacnet-listen" 117 | ) 118 | 119 | async def _async_update_data(self) -> DeviceDict: 120 | try: 121 | devicedict = await self.interface.update( 122 | full_update=not self.last_update_success 123 | ) 124 | except (EcoPanelError, DeviceDictError) as error: 125 | raise UpdateFailed(f"Invalid response from API: {error}") from error 126 | 127 | if devicedict is not None and not self.interface.connected and not self.unsub: 128 | self._use_websocket() 129 | 130 | return devicedict 131 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Bepacom BACnet/IP Interface", 6 | "description": "Specify the BACnet/IP add-on IP-address and port.", 7 | "data": { 8 | "host": "IP address", 9 | "port": "Port", 10 | "enabled": "Entity enabled by default", 11 | "name": "Entity name based on:" 12 | } 13 | }, 14 | "hassio_confirm": { 15 | "title": "Bepacom BACnet/IP Interface via Home Assistant add-on", 16 | "description": "Do you want to configure Home Assistant to connect to the Bepacom BACnet/IP Interface provided by the add-on: {addon}?" 17 | }, 18 | "host": { 19 | "title": "Bepacom BACnet/IP Interface", 20 | "description": "Specify the BACnet/IP add-on IP-address and port.", 21 | "data": { 22 | "host": "IP address", 23 | "port": "Port" 24 | } 25 | }, 26 | "naming": { 27 | "title": "Bepacom BACnet/IP Interface", 28 | "description": "Specify what property the entity naming should be based on.", 29 | "data": { 30 | "name": "Entity name based on:", 31 | "enabled": "Entity enabled by default", 32 | "customize": "Enable advanced customisation" 33 | } 34 | }, 35 | "writing": { 36 | "title": "Bepacom BACnet/IP Interface", 37 | "description": "Specify the BACnet property written to when changing a value.", 38 | "data": { 39 | "analog_output": "Analog Output", 40 | "analog_value": "Analog Value", 41 | "binary_output": "Binary Output", 42 | "binary_value": "Binary Value", 43 | "multistate_output": "Multi State Output", 44 | "multistate_value": "Multi State Value" 45 | } 46 | } 47 | }, 48 | "error": { 49 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 50 | "cannot_connect": "Can't connect to the add-on.", 51 | "empty_response": "Empty response from the add-on. Is the add-on on the right network?" 52 | }, 53 | "abort": { 54 | "single_instance_allowed": "Only one instance of this integration is allowed!" 55 | } 56 | }, 57 | "options": { 58 | "step": { 59 | "init": { 60 | "title": "Bepacom BACnet/IP Interface", 61 | "description": "Specify the BACnet/IP add-on IP-address and port.", 62 | "data": { 63 | "host": "IP address", 64 | "port": "Port", 65 | "enabled": "Entity enabled by default", 66 | "name": "Entity name based on:" 67 | } 68 | }, 69 | "host": { 70 | "title": "Bepacom BACnet/IP Interface", 71 | "description": "Specify the BACnet/IP add-on IP-address and port.", 72 | "data": { 73 | "host": "IP address", 74 | "port": "Port" 75 | } 76 | }, 77 | "naming": { 78 | "title": "Bepacom BACnet/IP Interface", 79 | "description": "Specify what property the entity naming should be based on.", 80 | "data": { 81 | "name": "Entity name based on:", 82 | "customize": "Enable advanced customisation" 83 | } 84 | }, 85 | "writing": { 86 | "title": "Bepacom BACnet/IP Interface", 87 | "description": "Specify the BACnet property written to when changing a value.", 88 | "data": { 89 | "analog_output": "Analog Output", 90 | "analog_value": "Analog Value", 91 | "binary_output": "Binary Output", 92 | "binary_value": "Binary Value", 93 | "multistate_output": "Multi State Output", 94 | "multistate_value": "Multi State Value" 95 | } 96 | } 97 | }, 98 | "error": { 99 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 100 | "cannot_connect": "Can't connect to the add-on.", 101 | "empty_response": "Empty response from the add-on. Is the add-on on the right network?" 102 | }, 103 | "abort": { 104 | "single_instance_allowed": "Only one instance of this integration is allowed!" 105 | } 106 | }, 107 | "selector": { 108 | "name_select": { 109 | "options": { 110 | "object_name": "Object name", 111 | "description": "Description BACnet property", 112 | "object_identifier": "Object identifier property" 113 | } 114 | }, 115 | "write_options": { 116 | "options": { 117 | "present_value": "Present Value", 118 | "relinquish_default": "Relinquish Default" 119 | } 120 | } 121 | }, 122 | "services": { 123 | "write_release": { 124 | "name": "Send Release", 125 | "description": "Send an empty presentValue to an entity object to release manual control.", 126 | "fields": { 127 | "entity_id": { 128 | "name": "Entity", 129 | "description": "Name of entity that represents the object to write to." 130 | }, 131 | "priority": { 132 | "name": "Priority", 133 | "description": "The BACnet priority the empty write request has to be written with." 134 | } 135 | } 136 | }, 137 | "write_property": { 138 | "name": "Write property", 139 | "description": "Write any property of an BACnet object represented by an entity.", 140 | "fields": { 141 | "entity_id": { 142 | "name": "Entity", 143 | "description": "Name of entity that represents the object to write to." 144 | }, 145 | "priority": { 146 | "name": "Priority", 147 | "description": "The BACnet priority the empty write request has to be written with." 148 | }, 149 | "property": { 150 | "name": "Property", 151 | "description": "The BACnet property the write request has to be written to." 152 | }, 153 | "value": { 154 | "name": "Value", 155 | "description": "The value the BACnet property has to be set to." 156 | }, 157 | "array_index": { 158 | "name": "Array index", 159 | "description": "The array index to be written to. Usually left empty." 160 | } 161 | } 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /custom_components/bacnet_interface/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Bepacom BACnet/IP Interface", 6 | "description": "Voer het IP adres en de poort van de BACnet/IP add-on in.", 7 | "data": { 8 | "host": "IP-adres", 9 | "port": "Poort", 10 | "enabled": "Entity standaard ingeschakeld", 11 | "name": "Entity naam gebaseerd op:" 12 | } 13 | }, 14 | "hassio_confirm": { 15 | "title": "Bepacom BACnet/IP Interface via Home Assistant add-on", 16 | "description": "Wil je Home Assistant configureren om te verbinden met Bepacom BACnet/IP Interface door middel van: {addon}?" 17 | }, 18 | "host": { 19 | "title": "Bepacom BACnet/IP Interface", 20 | "description": "Voer het IP adres en de poort van de BACnet/IP add-on in.", 21 | "data": { 22 | "host": "IP-adres", 23 | "port": "Poort" 24 | } 25 | }, 26 | "naming": { 27 | "title": "Bepacom BACnet/IP Interface", 28 | "description": "Specificeer waar de entity benaming op gebaseerd moet worden.", 29 | "data": { 30 | "name": "Entity naam gebaseerd op:", 31 | "enabled": "Entity standaard ingeschakeld", 32 | "customize": "Geavanceerde instellingen" 33 | } 34 | }, 35 | "writing": { 36 | "title": "Bepacom BACnet/IP Interface", 37 | "description": "Specificeer welke property geschreven moet worden voor het wijzigen van waardes.", 38 | "data": { 39 | "analog_output": "Analog Output", 40 | "analog_value": "Analog Value", 41 | "binary_output": "Binary Output", 42 | "binary_value": "Binary Value", 43 | "multistate_output": "Multi State Output", 44 | "multistate_value": "Multi State Value" 45 | } 46 | } 47 | }, 48 | "error": { 49 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 50 | "cannot_connect": "Kan niet verbinden met de add-on.", 51 | "empty_response": "Lege reactie van de add-on. Zit de add-on op het juiste netwerk?" 52 | }, 53 | "abort": { 54 | "single_instance_allowed": "Er is maar één instantie van deze integration nodig!" 55 | } 56 | }, 57 | "options": { 58 | "step": { 59 | "init": { 60 | "title": "Bepacom BACnet/IP Interface", 61 | "description": "Specify the BACnet/IP add-on IP-address and port.", 62 | "data": { 63 | "host": "IP address", 64 | "port": "Port", 65 | "enabled": "Entity enabled by default", 66 | "name": "Entity name based on:" 67 | } 68 | }, 69 | "host": { 70 | "title": "Bepacom BACnet/IP Interface", 71 | "description": "Voer het IP adres en de poort van de BACnet/IP add-on in.", 72 | "data": { 73 | "host": "IP-adres", 74 | "port": "Poort" 75 | } 76 | }, 77 | "naming": { 78 | "title": "Bepacom BACnet/IP Interface", 79 | "description": "Specificeer waar de entity benaming op gebaseerd moet worden.", 80 | "data": { 81 | "name": "Entity naam gebaseerd op:", 82 | "customize": "Geavanceerde instellingen" 83 | } 84 | }, 85 | "writing": { 86 | "title": "Bepacom BACnet/IP Interface", 87 | "description": "Specificeer welke property geschreven moet worden voor het wijzigen van waardes.", 88 | "data": { 89 | "analog_output": "Analog Output", 90 | "analog_value": "Analog Value", 91 | "binary_output": "Binary Output", 92 | "binary_value": "Binary Value", 93 | "multistate_output": "Multi State Output", 94 | "multistate_value": "Multi State Value" 95 | } 96 | } 97 | }, 98 | "error": { 99 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 100 | "cannot_connect": "Kan niet verbinden met de add-on.", 101 | "empty_response": "Lege reactie van de add-on. Zit de add-on op het juiste netwerk?" 102 | }, 103 | "abort": { 104 | "single_instance_allowed": "Er is maar één instantie van deze integration nodig!" 105 | } 106 | }, 107 | "selector": { 108 | "name_select": { 109 | "options": { 110 | "object_name": "Object naam", 111 | "description": "'Description' BACnet eigenschap", 112 | "object_identifier": "'Object identifier' BACnet eigenschap" 113 | } 114 | }, 115 | "write_options": { 116 | "options": { 117 | "present_value": "Present Value", 118 | "relinquish_default": "Relinquish Default" 119 | } 120 | } 121 | }, 122 | "services": { 123 | "write_release": { 124 | "name": "Stuur Vrijgave", 125 | "description": "Stuur een lege presentValue naar een object om handmatige besturing vrij te geven.", 126 | "fields": { 127 | "entity_id": { 128 | "name": "Entiteit", 129 | "description": "Naam van een entiteit dat het BACnet object representateert." 130 | }, 131 | "priority": { 132 | "name": "Prioriteit", 133 | "description": "De BACnet prioriteit waarmee geschreven moet worden." 134 | } 135 | } 136 | }, 137 | "write_property": { 138 | "name": "Schrijf property", 139 | "description": "Schrijf een property van een BACnet object dat gerepresenteerd wordt door een entity.", 140 | "fields": { 141 | "entity_id": { 142 | "name": "Entiteit", 143 | "description": "Naam van een entiteit dat het BACnet object representateert." 144 | }, 145 | "priority": { 146 | "name": "Prioriteit", 147 | "description": "De BACnet prioriteit waarmee geschreven moet worden." 148 | }, 149 | "property": { 150 | "name": "Property", 151 | "description": "De BACnet property waarnaar geschreven moet worden." 152 | }, 153 | "value": { 154 | "name": "Waarde", 155 | "description": "De waarde wat naar de BACnet property geschreven met worden." 156 | }, 157 | "array_index": { 158 | "name": "Array index", 159 | "description": "De index voor de array waarnaar geschreven wordt. Meestal moet je deze niet gebruiken." 160 | } 161 | } 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /custom_components/bacnet_interface/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription) 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_ENABLED, CONF_NAME 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.typing import StateType 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | from homeassistant.util.dt import utcnow 15 | 16 | from .const import DOMAIN, LOGGER 17 | from .coordinator import EcoPanelDataUpdateCoordinator 18 | 19 | 20 | async def async_setup_entry( 21 | hass: HomeAssistant, 22 | entry: ConfigEntry, 23 | async_add_entities: AddEntitiesCallback, 24 | ) -> None: 25 | """Set up EcoPanel sensor based on a config entry.""" 26 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 27 | entity_list: list = [] 28 | 29 | # Collect from all devices the objects that can become a binary sensor 30 | for deviceid in coordinator.data.devices: 31 | if not coordinator.data.devices[deviceid].objects: 32 | LOGGER.warning(f"No objects in {deviceid}!") 33 | continue 34 | 35 | for objectid in coordinator.data.devices[deviceid].objects: 36 | if ( 37 | not coordinator.data.devices[deviceid] 38 | .objects[objectid] 39 | .objectIdentifier 40 | ): 41 | LOGGER.warning(f"No object identifier for {objectid} in {deviceid}!") 42 | continue 43 | if ( 44 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 45 | == "binaryInput" 46 | ): 47 | entity_list.append( 48 | BinaryInputEntity( 49 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 50 | ) 51 | ) 52 | async_add_entities(entity_list) 53 | 54 | 55 | class BinaryInputEntity( 56 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], BinarySensorEntity 57 | ): 58 | _attr_has_entity_name = True 59 | 60 | def __init__( 61 | self, 62 | coordinator: EcoPanelDataUpdateCoordinator, 63 | deviceid: str, 64 | objectid: str, 65 | ): 66 | """Initialize a BACnet Binary Input object as entity.""" 67 | super().__init__(coordinator=coordinator) 68 | self.deviceid = deviceid 69 | self.objectid = objectid 70 | 71 | @property 72 | def unique_id(self) -> str: 73 | return f"{self.deviceid}_{self.objectid}" 74 | 75 | @property 76 | def name(self) -> str: 77 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 78 | if name == "description": 79 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 80 | elif name == "object_identifier": 81 | identifier = ( 82 | self.coordinator.data.devices[self.deviceid] 83 | .objects[self.objectid] 84 | .objectIdentifier 85 | ) 86 | return f"{identifier[0]}:{identifier[1]}" 87 | else: 88 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 89 | 90 | @property 91 | def entity_registry_enabled_default(self) -> bool: 92 | """Return if the entity should be enabled when first added to the entity registry.""" 93 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 94 | 95 | @property 96 | def is_on(self) -> bool: 97 | if ( 98 | self.coordinator.data.devices[self.deviceid] 99 | .objects[self.objectid] 100 | .presentValue 101 | == "active" 102 | ): 103 | return True 104 | elif ( 105 | self.coordinator.data.devices[self.deviceid] 106 | .objects[self.objectid] 107 | .presentValue 108 | == True 109 | ): 110 | return True 111 | elif ( 112 | self.coordinator.data.devices[self.deviceid] 113 | .objects[self.objectid] 114 | .presentValue 115 | == "inactive" 116 | ): 117 | return False 118 | elif ( 119 | self.coordinator.data.devices[self.deviceid] 120 | .objects[self.objectid] 121 | .presentValue 122 | == False 123 | ): 124 | return False 125 | 126 | @property 127 | def icon(self): 128 | return "mdi:lightbulb-outline" 129 | 130 | @property 131 | def device_info(self) -> DeviceInfo: 132 | return DeviceInfo( 133 | identifiers={(DOMAIN, self.deviceid)}, 134 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 135 | manufacturer=self.coordinator.data.devices[self.deviceid] 136 | .objects[self.deviceid] 137 | .vendorName, 138 | model=self.coordinator.data.devices[self.deviceid] 139 | .objects[self.deviceid] 140 | .modelName, 141 | ) 142 | 143 | @property 144 | def extra_state_attributes(self) -> dict[str, Any]: 145 | return { 146 | "inAlarm": bool( 147 | self.coordinator.data.devices[self.deviceid] 148 | .objects[self.objectid] 149 | .statusFlags[0] 150 | ), 151 | "fault": bool( 152 | self.coordinator.data.devices[self.deviceid] 153 | .objects[self.objectid] 154 | .statusFlags[1] 155 | ), 156 | "overridden": bool( 157 | self.coordinator.data.devices[self.deviceid] 158 | .objects[self.objectid] 159 | .statusFlags[2] 160 | ), 161 | "outOfService": bool( 162 | self.coordinator.data.devices[self.deviceid] 163 | .objects[self.objectid] 164 | .statusFlags[3] 165 | ), 166 | } 167 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/__init__.py: -------------------------------------------------------------------------------- 1 | """The Bepacom EcoPanel BACnet/IP integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from asyncio import sleep 6 | from copy import copy 7 | 8 | import voluptuous as vol 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import (ATTR_ENTITY_ID, CONF_ENABLED, CONF_HOST, 11 | CONF_NAME, CONF_PORT) 12 | from homeassistant.core import (HomeAssistant, ServiceCall, ServiceResponse, 13 | SupportsResponse) 14 | from homeassistant.helpers import config_validation as cv 15 | from homeassistant.helpers import entity_registry as er 16 | from homeassistant.helpers.device_registry import DeviceEntry, async_get 17 | from homeassistant.util.json import JsonObjectType 18 | 19 | from .const import (ATTR_INDEX, ATTR_PRIORITY, ATTR_PROPERTY, ATTR_VALUE, 20 | CONST_COMPARE_SIZE, DOMAIN, LOGGER, WRITE_PROPERTY_SCHEMA, 21 | WRITE_PROPERTY_SERVICE_NAME, WRITE_RELEASE_SCHEMA, 22 | WRITE_RELEASE_SERVICE_NAME) 23 | from .coordinator import EcoPanelDataUpdateCoordinator 24 | 25 | # List of platforms to support. There should be a matching .py file for each, 26 | # eg and 27 | PLATFORMS: list[str] = [ 28 | "binary_sensor", 29 | "sensor", 30 | "number", 31 | "switch", 32 | "select", 33 | ] 34 | 35 | 36 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 37 | """Set up EcoPanel BACnet/IP interface from a config entry.""" 38 | 39 | # Store an instance of the "connecting" class that does the work of speaking 40 | # with your actual devices. 41 | 42 | # entry = validate_entry(entry) 43 | 44 | coordinator = EcoPanelDataUpdateCoordinator(hass, entry=entry) 45 | 46 | await coordinator.async_config_entry_first_refresh() 47 | 48 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator 49 | 50 | # This creates each HA object for each platform your device requires. 51 | # It's done by calling the `async_setup_entry` function in each platform module. 52 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 53 | 54 | # Reload entry when its updated. 55 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 56 | 57 | entry.async_create_background_task( 58 | hass, async_monitor_data_size(hass, entry, coordinator), "bacnet-monitor-data" 59 | ) 60 | 61 | async def write_release(call: ServiceCall) -> ServiceResponse: 62 | """Write empty presentValue that serves to release higher priority write request.""" 63 | 64 | entity_registry = er.async_get(hass) 65 | 66 | entity_data = entity_registry.async_get(call.data[ATTR_ENTITY_ID][0]) 67 | 68 | device_id, object_id = entity_data.unique_id.split("_") 69 | 70 | if call.data.get(ATTR_PRIORITY): 71 | LOGGER.warning( 72 | "Priority is currently not functioning. Writing default value." 73 | ) 74 | 75 | await coordinator.interface.write_property( 76 | deviceid=device_id, objectid=object_id 77 | ) 78 | 79 | return {"status": "successfull!"} 80 | 81 | async def write_property(call: ServiceCall) -> ServiceResponse: 82 | """Write property with value to an object.""" 83 | 84 | entity_registry = er.async_get(hass) 85 | 86 | entity_data = entity_registry.async_get(call.data[ATTR_ENTITY_ID][0]) 87 | 88 | device_id, object_id = entity_data.unique_id.split("_") 89 | 90 | if priority := call.data.get(ATTR_PRIORITY): 91 | pass 92 | 93 | if property_id := call.data.get(ATTR_PROPERTY): 94 | pass 95 | 96 | if value := call.data.get(ATTR_VALUE): 97 | pass 98 | 99 | if array_index := call.data.get(ATTR_INDEX): 100 | pass 101 | 102 | await coordinator.interface.write_property_v2( 103 | deviceid=device_id, 104 | objectid=object_id, 105 | propertyid=property_id, 106 | value=value, 107 | array_index=array_index, 108 | priority=priority, 109 | ) 110 | 111 | return {"status": "successfull!"} 112 | 113 | hass.services.async_register( 114 | DOMAIN, 115 | WRITE_RELEASE_SERVICE_NAME, 116 | write_release, 117 | schema=WRITE_RELEASE_SCHEMA, 118 | supports_response=SupportsResponse.OPTIONAL, 119 | ) 120 | hass.services.async_register( 121 | DOMAIN, 122 | WRITE_PROPERTY_SERVICE_NAME, 123 | write_property, 124 | schema=WRITE_PROPERTY_SCHEMA, 125 | supports_response=SupportsResponse.OPTIONAL, 126 | ) 127 | 128 | return True 129 | 130 | 131 | def validate_entry(entry: ConfigEntry) -> ConfigEntry: 132 | """Check if all values are filled in, otherwise replace""" 133 | 134 | if not entry.data.get(CONF_PORT): 135 | entry.data.update({CONF_PORT: 8099}) 136 | 137 | if not entry.data.get(CONF_ENABLED): 138 | entry.data.update({CONF_ENABLED: True}) 139 | 140 | if not entry.data.get(CONF_HOST): 141 | entry.data.update({CONF_HOST: "127.0.0.1"}) 142 | 143 | if not entry.data.get(CONF_NAME): 144 | entry.data.update({CONF_NAME: "object_name"}) 145 | 146 | return entry 147 | 148 | 149 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 150 | """Unload a config entry.""" 151 | # This is called when an entry/configured device is to be removed. The class 152 | # needs to unload itself, and remove callbacks. See the classes for further 153 | # details 154 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 155 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 156 | 157 | await coordinator.interface.disconnect() 158 | if coordinator.unsub: 159 | coordinator.unsub() 160 | 161 | del hass.data[DOMAIN][entry.entry_id] 162 | 163 | return unload_ok 164 | 165 | 166 | async def async_remove_config_entry_device( 167 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 168 | ) -> bool: 169 | """Remove config entry from a device.""" 170 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][ 171 | config_entry.entry_id 172 | ] 173 | 174 | for domain, device_id in device_entry.identifiers: 175 | try: 176 | coordinator.logger.info( 177 | f"(Removing device {coordinator.data.devices.get(device_id)}" 178 | ) 179 | coordinator.data.devices.pop(device_id) 180 | except KeyError: 181 | continue 182 | 183 | return True 184 | 185 | 186 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 187 | """Reload the config entry when it changed.""" 188 | await hass.config_entries.async_reload(entry.entry_id) 189 | 190 | 191 | async def async_monitor_data_size( 192 | hass: HomeAssistant, entry: ConfigEntry, coordinator: EcoPanelDataUpdateCoordinator 193 | ) -> None: 194 | """Monitor data size, and reload if it increases.""" 195 | 196 | old_devices = copy(coordinator.data.devices) 197 | old_devices_dict = {} 198 | 199 | for device in old_devices: 200 | objects = {device: copy(coordinator.data.devices[device].objects)} 201 | old_devices_dict.update(objects) 202 | 203 | while True: 204 | await sleep(CONST_COMPARE_SIZE) 205 | 206 | if len(coordinator.data.devices) > len(old_devices): 207 | LOGGER.debug(f"Reloading after new device detected!") 208 | 209 | await hass.config_entries.async_schedule_reload(entry.entry_id) 210 | 211 | for device in coordinator.data.devices: 212 | if len(coordinator.data.devices[device].objects) > len( 213 | old_devices_dict[device] 214 | ): 215 | LOGGER.debug(f"Increased object size") 216 | 217 | await hass.config_entries.async_schedule_reload(entry.entry_id) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudio 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio 3 | 4 | ### VisualStudio ### 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | ## 8 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 9 | 10 | # User-specific files 11 | *.rsuser 12 | *.suo 13 | *.user 14 | *.userosscache 15 | *.sln.docstates 16 | 17 | # User-specific files (MonoDevelop/Xamarin Studio) 18 | *.userprefs 19 | 20 | # Mono auto generated files 21 | mono_crash.* 22 | 23 | # Build results 24 | [Dd]ebug/ 25 | [Dd]ebugPublic/ 26 | [Rr]elease/ 27 | [Rr]eleases/ 28 | x64/ 29 | x86/ 30 | [Ww][Ii][Nn]32/ 31 | [Aa][Rr][Mm]/ 32 | [Aa][Rr][Mm]64/ 33 | bld/ 34 | [Bb]in/ 35 | [Oo]bj/ 36 | [Ll]og/ 37 | [Ll]ogs/ 38 | 39 | # Visual Studio 2015/2017 cache/options directory 40 | .vs/ 41 | # Uncomment if you have tasks that create the project's static files in wwwroot 42 | #wwwroot/ 43 | 44 | # Visual Studio 2017 auto generated files 45 | Generated\ Files/ 46 | 47 | # MSTest test Results 48 | [Tt]est[Rr]esult*/ 49 | [Bb]uild[Ll]og.* 50 | 51 | # NUnit 52 | *.VisualState.xml 53 | TestResult.xml 54 | nunit-*.xml 55 | 56 | # Build Results of an ATL Project 57 | [Dd]ebugPS/ 58 | [Rr]eleasePS/ 59 | dlldata.c 60 | 61 | # Benchmark Results 62 | BenchmarkDotNet.Artifacts/ 63 | 64 | # .NET Core 65 | project.lock.json 66 | project.fragment.lock.json 67 | artifacts/ 68 | 69 | # ASP.NET Scaffolding 70 | ScaffoldingReadMe.txt 71 | 72 | # StyleCop 73 | StyleCopReport.xml 74 | 75 | # Files built by Visual Studio 76 | *_i.c 77 | *_p.c 78 | *_h.h 79 | *.ilk 80 | *.meta 81 | *.obj 82 | *.iobj 83 | *.pch 84 | *.pdb 85 | *.ipdb 86 | *.pgc 87 | *.pgd 88 | *.rsp 89 | *.sbr 90 | *.tlb 91 | *.tli 92 | *.tlh 93 | *.tmp 94 | *.tmp_proj 95 | *_wpftmp.csproj 96 | *.log 97 | *.tlog 98 | *.vspscc 99 | *.vssscc 100 | .builds 101 | *.pidb 102 | *.svclog 103 | *.scc 104 | 105 | # Chutzpah Test files 106 | _Chutzpah* 107 | 108 | # Visual C++ cache files 109 | ipch/ 110 | *.aps 111 | *.ncb 112 | *.opendb 113 | *.opensdf 114 | *.sdf 115 | *.cachefile 116 | *.VC.db 117 | *.VC.VC.opendb 118 | 119 | # Visual Studio profiler 120 | *.psess 121 | *.vsp 122 | *.vspx 123 | *.sap 124 | 125 | # Visual Studio Trace Files 126 | *.e2e 127 | 128 | # TFS 2012 Local Workspace 129 | $tf/ 130 | 131 | # Guidance Automation Toolkit 132 | *.gpState 133 | 134 | # ReSharper is a .NET coding add-in 135 | _ReSharper*/ 136 | *.[Rr]e[Ss]harper 137 | *.DotSettings.user 138 | 139 | # TeamCity is a build add-in 140 | _TeamCity* 141 | 142 | # DotCover is a Code Coverage Tool 143 | *.dotCover 144 | 145 | # AxoCover is a Code Coverage Tool 146 | .axoCover/* 147 | !.axoCover/settings.json 148 | 149 | # Coverlet is a free, cross platform Code Coverage Tool 150 | coverage*.json 151 | coverage*.xml 152 | coverage*.info 153 | 154 | # Visual Studio code coverage results 155 | *.coverage 156 | *.coveragexml 157 | 158 | # NCrunch 159 | _NCrunch_* 160 | .*crunch*.local.xml 161 | nCrunchTemp_* 162 | 163 | # MightyMoose 164 | *.mm.* 165 | AutoTest.Net/ 166 | 167 | # Web workbench (sass) 168 | .sass-cache/ 169 | 170 | # Installshield output folder 171 | [Ee]xpress/ 172 | 173 | # DocProject is a documentation generator add-in 174 | DocProject/buildhelp/ 175 | DocProject/Help/*.HxT 176 | DocProject/Help/*.HxC 177 | DocProject/Help/*.hhc 178 | DocProject/Help/*.hhk 179 | DocProject/Help/*.hhp 180 | DocProject/Help/Html2 181 | DocProject/Help/html 182 | 183 | # Click-Once directory 184 | publish/ 185 | 186 | # Publish Web Output 187 | *.[Pp]ublish.xml 188 | *.azurePubxml 189 | # Note: Comment the next line if you want to checkin your web deploy settings, 190 | # but database connection strings (with potential passwords) will be unencrypted 191 | *.pubxml 192 | *.publishproj 193 | 194 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 195 | # checkin your Azure Web App publish settings, but sensitive information contained 196 | # in these scripts will be unencrypted 197 | PublishScripts/ 198 | 199 | # NuGet Packages 200 | *.nupkg 201 | # NuGet Symbol Packages 202 | *.snupkg 203 | # The packages folder can be ignored because of Package Restore 204 | **/[Pp]ackages/* 205 | # except build/, which is used as an MSBuild target. 206 | !**/[Pp]ackages/build/ 207 | # Uncomment if necessary however generally it will be regenerated when needed 208 | #!**/[Pp]ackages/repositories.config 209 | # NuGet v3's project.json files produces more ignorable files 210 | *.nuget.props 211 | *.nuget.targets 212 | 213 | # Microsoft Azure Build Output 214 | csx/ 215 | *.build.csdef 216 | 217 | # Microsoft Azure Emulator 218 | ecf/ 219 | rcf/ 220 | 221 | # Windows Store app package directories and files 222 | AppPackages/ 223 | BundleArtifacts/ 224 | Package.StoreAssociation.xml 225 | _pkginfo.txt 226 | *.appx 227 | *.appxbundle 228 | *.appxupload 229 | 230 | # Visual Studio cache files 231 | # files ending in .cache can be ignored 232 | *.[Cc]ache 233 | # but keep track of directories ending in .cache 234 | !?*.[Cc]ache/ 235 | 236 | # Others 237 | ClientBin/ 238 | ~$* 239 | *~ 240 | *.dbmdl 241 | *.dbproj.schemaview 242 | *.jfm 243 | *.pfx 244 | *.publishsettings 245 | orleans.codegen.cs 246 | 247 | # Including strong name files can present a security risk 248 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 249 | #*.snk 250 | 251 | # Since there are multiple workflows, uncomment next line to ignore bower_components 252 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 253 | #bower_components/ 254 | 255 | # RIA/Silverlight projects 256 | Generated_Code/ 257 | 258 | # Backup & report files from converting an old project file 259 | # to a newer Visual Studio version. Backup files are not needed, 260 | # because we have git ;-) 261 | _UpgradeReport_Files/ 262 | Backup*/ 263 | UpgradeLog*.XML 264 | UpgradeLog*.htm 265 | ServiceFabricBackup/ 266 | *.rptproj.bak 267 | 268 | # SQL Server files 269 | *.mdf 270 | *.ldf 271 | *.ndf 272 | 273 | # Business Intelligence projects 274 | *.rdl.data 275 | *.bim.layout 276 | *.bim_*.settings 277 | *.rptproj.rsuser 278 | *- [Bb]ackup.rdl 279 | *- [Bb]ackup ([0-9]).rdl 280 | *- [Bb]ackup ([0-9][0-9]).rdl 281 | 282 | # Microsoft Fakes 283 | FakesAssemblies/ 284 | 285 | # GhostDoc plugin setting file 286 | *.GhostDoc.xml 287 | 288 | # Node.js Tools for Visual Studio 289 | .ntvs_analysis.dat 290 | node_modules/ 291 | 292 | # Visual Studio 6 build log 293 | *.plg 294 | 295 | # Visual Studio 6 workspace options file 296 | *.opt 297 | 298 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 299 | *.vbw 300 | 301 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 302 | *.vbp 303 | 304 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 305 | *.dsw 306 | *.dsp 307 | 308 | # Visual Studio 6 technical files 309 | 310 | # Visual Studio LightSwitch build output 311 | **/*.HTMLClient/GeneratedArtifacts 312 | **/*.DesktopClient/GeneratedArtifacts 313 | **/*.DesktopClient/ModelManifest.xml 314 | **/*.Server/GeneratedArtifacts 315 | **/*.Server/ModelManifest.xml 316 | _Pvt_Extensions 317 | 318 | # Paket dependency manager 319 | .paket/paket.exe 320 | paket-files/ 321 | 322 | # FAKE - F# Make 323 | .fake/ 324 | 325 | # CodeRush personal settings 326 | .cr/personal 327 | 328 | # Python Tools for Visual Studio (PTVS) 329 | __pycache__/ 330 | *.pyc 331 | 332 | # Cake - Uncomment if you are using it 333 | # tools/** 334 | # !tools/packages.config 335 | 336 | # Tabs Studio 337 | *.tss 338 | 339 | # Telerik's JustMock configuration file 340 | *.jmconfig 341 | 342 | # BizTalk build output 343 | *.btp.cs 344 | *.btm.cs 345 | *.odx.cs 346 | *.xsd.cs 347 | 348 | # OpenCover UI analysis results 349 | OpenCover/ 350 | 351 | # Azure Stream Analytics local run output 352 | ASALocalRun/ 353 | 354 | # MSBuild Binary and Structured Log 355 | *.binlog 356 | 357 | # NVidia Nsight GPU debugger configuration file 358 | *.nvuser 359 | 360 | # MFractors (Xamarin productivity tool) working folder 361 | .mfractor/ 362 | 363 | # Local History for Visual Studio 364 | .localhistory/ 365 | 366 | # Visual Studio History (VSHistory) files 367 | .vshistory/ 368 | 369 | # BeatPulse healthcheck temp database 370 | healthchecksdb 371 | 372 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 373 | MigrationBackup/ 374 | 375 | # Ionide (cross platform F# VS Code tools) working folder 376 | .ionide/ 377 | 378 | # Fody - auto-generated XML schema 379 | FodyWeavers.xsd 380 | 381 | # VS Code files for those working on multiple tools 382 | .vscode/* 383 | !.vscode/settings.json 384 | !.vscode/tasks.json 385 | !.vscode/launch.json 386 | !.vscode/extensions.json 387 | *.code-workspace 388 | 389 | # Local History for Visual Studio Code 390 | .history/ 391 | 392 | # Windows Installer files from build outputs 393 | *.cab 394 | *.msi 395 | *.msix 396 | *.msm 397 | *.msp 398 | 399 | # JetBrains Rider 400 | *.sln.iml 401 | 402 | ### VisualStudio Patch ### 403 | # Additional files built by Visual Studio 404 | 405 | # End of https://www.toptal.com/developers/gitignore/api/visualstudio 406 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/sensor.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from math import log10 4 | from typing import Any 5 | 6 | from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, 7 | SensorEntityDescription, 8 | SensorStateClass) 9 | from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import (CONF_ENABLED, CONF_NAME, UnitOfEnergy, 12 | UnitOfVolume) 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.typing import StateType 17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 18 | from homeassistant.util.dt import utcnow 19 | 20 | from .const import STATETEXT_OFFSET # JCO 21 | from .const import DOMAIN, LOGGER 22 | from .coordinator import EcoPanelDataUpdateCoordinator 23 | from .helper import (bacnet_to_device_class, bacnet_to_ha_units, 24 | decimal_places_needed) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Set up EcoPanel sensor based on a config entry.""" 33 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 34 | entity_list: list = [] 35 | 36 | # Collect from all devices the objects that can become a sensor 37 | for deviceid in coordinator.data.devices: 38 | if not coordinator.data.devices[deviceid].objects: 39 | LOGGER.warning(f"No objects in {deviceid}!") 40 | continue 41 | 42 | for objectid in coordinator.data.devices[deviceid].objects: 43 | if ( 44 | not coordinator.data.devices[deviceid] 45 | .objects[objectid] 46 | .objectIdentifier 47 | ): 48 | LOGGER.warning(f"No object identifier for {objectid} in {deviceid}!") 49 | continue 50 | 51 | if ( 52 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 53 | == "analogInput" 54 | ): 55 | entity_list.append( 56 | AnalogInputEntity( 57 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 58 | ) 59 | ) 60 | # elif coordinator.data.devices[deviceid].objects[objectid].objectType == 'accumulator': 61 | # entity_list.append(AnalogInputEntity(coordinator=coordinator, deviceid=deviceid, objectid=objectid)) 62 | # elif coordinator.data.devices[deviceid].objects[objectid].objectType == 'averaging': 63 | # entity_list.append(AveragingEntity(coordinator=coordinator, deviceid=deviceid, objectid=objectid)) 64 | elif ( 65 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 66 | == "multiStateInput" 67 | ): 68 | entity_list.append( 69 | MultiStateInputEntity( 70 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 71 | ) 72 | ) 73 | 74 | async_add_entities(entity_list) 75 | 76 | 77 | class AnalogInputEntity(CoordinatorEntity[EcoPanelDataUpdateCoordinator], SensorEntity): 78 | _attr_has_entity_name = True 79 | 80 | def __init__( 81 | self, 82 | coordinator: EcoPanelDataUpdateCoordinator, 83 | deviceid: str, 84 | objectid: str, 85 | ): 86 | """Initialize a BACnet AnalogInput object as entity.""" 87 | super().__init__(coordinator=coordinator) 88 | self.deviceid = deviceid 89 | self.objectid = objectid 90 | 91 | @property 92 | def unique_id(self) -> str: 93 | return f"{self.deviceid}_{self.objectid}" 94 | 95 | @property 96 | def name(self) -> str: 97 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 98 | if name == "description": 99 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 100 | elif name == "object_identifier": 101 | identifier = ( 102 | self.coordinator.data.devices[self.deviceid] 103 | .objects[self.objectid] 104 | .objectIdentifier 105 | ) 106 | return f"{identifier[0]}:{identifier[1]}" 107 | else: 108 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 109 | 110 | @property 111 | def entity_registry_enabled_default(self) -> bool: 112 | """Return if the entity should be enabled when first added to the entity registry.""" 113 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 114 | 115 | @property 116 | def native_value(self): 117 | value = ( 118 | self.coordinator.data.devices[self.deviceid] 119 | .objects[self.objectid] 120 | .presentValue 121 | ) 122 | 123 | if value is None: 124 | return value 125 | 126 | if ( 127 | resolution := self.coordinator.data.devices[self.deviceid] 128 | .objects[self.objectid] 129 | .resolution 130 | ): 131 | if resolution >= 1: 132 | return int(value) 133 | resolution = decimal_places_needed(resolution) 134 | #LOGGER.warning(f"Val {value} Res {resolution}!") 135 | return round(value, resolution) 136 | elif ( 137 | covIncrement := self.coordinator.data.devices[self.deviceid] 138 | .objects[self.objectid] 139 | .covIncrement 140 | ): 141 | if covIncrement >= 1: 142 | return int(value) 143 | covIncrement = decimal_places_needed(covIncrement) 144 | return round(value, covIncrement) 145 | 146 | return round(value, 1) 147 | 148 | @property 149 | def icon(self): 150 | return "mdi:gauge" 151 | 152 | @property 153 | def device_class(self) -> str | None: 154 | if ( 155 | units := self.coordinator.data.devices[self.deviceid] 156 | .objects[self.objectid] 157 | .units 158 | ): 159 | return bacnet_to_device_class(units, DEVICE_CLASS_UNITS) 160 | else: 161 | return None 162 | 163 | @property 164 | def native_unit_of_measurement(self) -> str | None: 165 | if ( 166 | units := self.coordinator.data.devices[self.deviceid] 167 | .objects[self.objectid] 168 | .units 169 | ): 170 | return bacnet_to_ha_units(units) 171 | else: 172 | return None 173 | 174 | @property 175 | def extra_state_attributes(self) -> dict[str, Any]: 176 | return { 177 | "inAlarm": bool( 178 | self.coordinator.data.devices[self.deviceid] 179 | .objects[self.objectid] 180 | .statusFlags[0] 181 | ), 182 | "fault": bool( 183 | self.coordinator.data.devices[self.deviceid] 184 | .objects[self.objectid] 185 | .statusFlags[1] 186 | ), 187 | "overridden": bool( 188 | self.coordinator.data.devices[self.deviceid] 189 | .objects[self.objectid] 190 | .statusFlags[2] 191 | ), 192 | "outOfService": bool( 193 | self.coordinator.data.devices[self.deviceid] 194 | .objects[self.objectid] 195 | .statusFlags[3] 196 | ), 197 | } 198 | 199 | @property 200 | def device_info(self) -> DeviceInfo: 201 | return DeviceInfo( 202 | identifiers={(DOMAIN, self.deviceid)}, 203 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 204 | manufacturer=self.coordinator.data.devices[self.deviceid] 205 | .objects[self.deviceid] 206 | .vendorName, 207 | model=self.coordinator.data.devices[self.deviceid] 208 | .objects[self.deviceid] 209 | .modelName, 210 | ) 211 | 212 | @property 213 | def state_class(self) -> str: 214 | if self.native_unit_of_measurement in UnitOfEnergy: 215 | return "total" 216 | elif self.native_unit_of_measurement in UnitOfVolume: 217 | return "total" 218 | else: 219 | return "measurement" 220 | 221 | 222 | class MultiStateInputEntity( 223 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], SensorEntity 224 | ): 225 | _attr_has_entity_name = True 226 | 227 | def __init__( 228 | self, 229 | coordinator: EcoPanelDataUpdateCoordinator, 230 | deviceid: str, 231 | objectid: str, 232 | ): 233 | """Initialize a BACnet MultiStateInput object as entity.""" 234 | super().__init__(coordinator=coordinator) 235 | self.deviceid = deviceid 236 | self.objectid = objectid 237 | 238 | @property 239 | def unique_id(self) -> str: 240 | return f"{self.deviceid}_{self.objectid}" 241 | 242 | @property 243 | def name(self) -> str: 244 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 245 | if name == "description": 246 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 247 | elif name == "object_identifier": 248 | identifier = ( 249 | self.coordinator.data.devices[self.deviceid] 250 | .objects[self.objectid] 251 | .objectIdentifier 252 | ) 253 | return f"{identifier[0]}:{identifier[1]}" 254 | else: 255 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 256 | 257 | @property 258 | def entity_registry_enabled_default(self) -> bool: 259 | """Return if the entity should be enabled when first added to the entity registry.""" 260 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 261 | 262 | @property 263 | def native_value(self): 264 | state_val = ( 265 | self.coordinator.data.devices[self.deviceid] 266 | .objects[self.objectid] 267 | .presentValue 268 | ) 269 | 270 | if ( 271 | state_text := self.coordinator.data.devices[self.deviceid] 272 | .objects[self.objectid] 273 | .stateText 274 | ): 275 | return state_text[state_val - STATETEXT_OFFSET] # JCO 276 | else: 277 | return state_val 278 | 279 | @property 280 | def icon(self): 281 | return "mdi:menu" 282 | 283 | @property 284 | def extra_state_attributes(self) -> dict[str, Any]: 285 | return { 286 | "inAlarm": bool( 287 | self.coordinator.data.devices[self.deviceid] 288 | .objects[self.objectid] 289 | .statusFlags[0] 290 | ), 291 | "fault": bool( 292 | self.coordinator.data.devices[self.deviceid] 293 | .objects[self.objectid] 294 | .statusFlags[1] 295 | ), 296 | "overridden": bool( 297 | self.coordinator.data.devices[self.deviceid] 298 | .objects[self.objectid] 299 | .statusFlags[2] 300 | ), 301 | "outOfService": bool( 302 | self.coordinator.data.devices[self.deviceid] 303 | .objects[self.objectid] 304 | .statusFlags[3] 305 | ), 306 | } 307 | 308 | @property 309 | def device_info(self) -> DeviceInfo: 310 | return DeviceInfo( 311 | identifiers={(DOMAIN, self.deviceid)}, 312 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 313 | manufacturer=self.coordinator.data.devices[self.deviceid] 314 | .objects[self.deviceid] 315 | .vendorName, 316 | model=self.coordinator.data.devices[self.deviceid] 317 | .objects[self.deviceid] 318 | .modelName, 319 | ) 320 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/switch.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from homeassistant.components.switch import (SwitchDeviceClass, SwitchEntity, 6 | SwitchEntityDescription) 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_ENABLED, CONF_NAME 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.typing import StateType 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | from homeassistant.util.dt import utcnow 15 | 16 | from .const import CONF_BINARY_OUTPUT, CONF_BINARY_VALUE, DOMAIN, LOGGER 17 | from .coordinator import EcoPanelDataUpdateCoordinator 18 | from .helper import key_to_property 19 | 20 | 21 | async def async_setup_entry( 22 | hass: HomeAssistant, 23 | entry: ConfigEntry, 24 | async_add_entities: AddEntitiesCallback, 25 | ) -> None: 26 | """Set up EcoPanel sensor based on a config entry.""" 27 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 28 | entity_list: list = [] 29 | 30 | # Collect from all devices the objects that can become a sensor 31 | for deviceid in coordinator.data.devices: 32 | if not coordinator.data.devices[deviceid].objects: 33 | LOGGER.warning(f"No objects in {deviceid}!") 34 | continue 35 | 36 | for objectid in coordinator.data.devices[deviceid].objects: 37 | if ( 38 | not coordinator.data.devices[deviceid] 39 | .objects[objectid] 40 | .objectIdentifier 41 | ): 42 | LOGGER.warning(f"No object identifier for {objectid} in {deviceid}!") 43 | continue 44 | 45 | if ( 46 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 47 | == "binaryValue" 48 | ): 49 | entity_list.append( 50 | BinaryValueEntity( 51 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 52 | ) 53 | ) 54 | elif ( 55 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 56 | == "binaryOutput" 57 | ): 58 | entity_list.append( 59 | BinaryOutputEntity( 60 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 61 | ) 62 | ) 63 | async_add_entities(entity_list) 64 | 65 | 66 | class BinaryValueEntity(CoordinatorEntity[EcoPanelDataUpdateCoordinator], SwitchEntity): 67 | _attr_has_entity_name = True 68 | 69 | def __init__( 70 | self, 71 | coordinator: EcoPanelDataUpdateCoordinator, 72 | deviceid: str, 73 | objectid: str, 74 | ): 75 | """Initialize a BACnet BinaryValue object as entity.""" 76 | super().__init__(coordinator=coordinator) 77 | self.deviceid = deviceid 78 | self.objectid = objectid 79 | 80 | @property 81 | def unique_id(self) -> str: 82 | return f"{self.deviceid}_{self.objectid}" 83 | 84 | @property 85 | def name(self) -> str: 86 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 87 | if name == "description": 88 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 89 | elif name == "object_identifier": 90 | identifier = ( 91 | self.coordinator.data.devices[self.deviceid] 92 | .objects[self.objectid] 93 | .objectIdentifier 94 | ) 95 | return f"{identifier[0]}:{identifier[1]}" 96 | else: 97 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 98 | 99 | @property 100 | def entity_registry_enabled_default(self) -> bool: 101 | """Return if the entity should be enabled when first added to the entity registry.""" 102 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 103 | 104 | @property 105 | def is_on(self) -> bool: 106 | pres_val = ( 107 | self.coordinator.data.devices[self.deviceid] 108 | .objects[self.objectid] 109 | .presentValue 110 | ) 111 | 112 | if isinstance(pres_val, str): 113 | return pres_val in {"active", "1"} 114 | elif isinstance(pres_val, int): 115 | return pres_val == 1 116 | elif isinstance(pres_val, bool): 117 | return pres_val 118 | else: 119 | self.coordinator.logger.debug( 120 | f"Unknown type for: {self.objectid} {self.coordinator.data.devices[self.deviceid] 121 | .objects[self.objectid] 122 | .presentValue}" 123 | ) 124 | return pres_val 125 | 126 | @property 127 | def icon(self): 128 | return "mdi:lightbulb-outline" 129 | 130 | @property 131 | def device_info(self) -> DeviceInfo: 132 | return DeviceInfo( 133 | identifiers={(DOMAIN, self.deviceid)}, 134 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 135 | manufacturer=self.coordinator.data.devices[self.deviceid] 136 | .objects[self.deviceid] 137 | .vendorName, 138 | model=self.coordinator.data.devices[self.deviceid] 139 | .objects[self.deviceid] 140 | .modelName, 141 | ) 142 | 143 | @property 144 | def extra_state_attributes(self) -> dict[str, Any]: 145 | return { 146 | "inAlarm": bool( 147 | self.coordinator.data.devices[self.deviceid] 148 | .objects[self.objectid] 149 | .statusFlags[0] 150 | ), 151 | "fault": bool( 152 | self.coordinator.data.devices[self.deviceid] 153 | .objects[self.objectid] 154 | .statusFlags[1] 155 | ), 156 | "overridden": bool( 157 | self.coordinator.data.devices[self.deviceid] 158 | .objects[self.objectid] 159 | .statusFlags[2] 160 | ), 161 | "outOfService": bool( 162 | self.coordinator.data.devices[self.deviceid] 163 | .objects[self.objectid] 164 | .statusFlags[3] 165 | ), 166 | } 167 | 168 | async def async_turn_on(self, **kwargs: Any) -> None: 169 | """Set BinaryValue object to active""" 170 | 171 | propertyid = self.coordinator.config_entry.data.get( 172 | CONF_BINARY_VALUE, "present_value" 173 | ) 174 | 175 | await self.coordinator.interface.write_property_v2( 176 | deviceid=self.deviceid, 177 | objectid=self.objectid, 178 | propertyid=key_to_property(propertyid), 179 | value=1, 180 | array_index=None, 181 | priority=None, 182 | ) 183 | 184 | async def async_turn_off(self): 185 | """Set BinaryValue object to active.""" 186 | 187 | propertyid = self.coordinator.config_entry.data.get( 188 | CONF_BINARY_VALUE, "present_value" 189 | ) 190 | 191 | await self.coordinator.interface.write_property_v2( 192 | deviceid=self.deviceid, 193 | objectid=self.objectid, 194 | propertyid=key_to_property(propertyid), 195 | value=0, 196 | array_index=None, 197 | priority=None, 198 | ) 199 | 200 | 201 | class BinaryOutputEntity( 202 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], SwitchEntity 203 | ): 204 | _attr_has_entity_name = True 205 | 206 | def __init__( 207 | self, 208 | coordinator: EcoPanelDataUpdateCoordinator, 209 | deviceid: str, 210 | objectid: str, 211 | ): 212 | """Initialize a BACnet BinaryOutput object as entity.""" 213 | super().__init__(coordinator=coordinator) 214 | self.deviceid = deviceid 215 | self.objectid = objectid 216 | 217 | @property 218 | def unique_id(self) -> str: 219 | return f"{self.deviceid}_{self.objectid}" 220 | 221 | @property 222 | def name(self) -> str: 223 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 224 | if name == "description": 225 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 226 | elif name == "object_identifier": 227 | identifier = ( 228 | self.coordinator.data.devices[self.deviceid] 229 | .objects[self.objectid] 230 | .objectIdentifier 231 | ) 232 | return f"{identifier[0]}:{identifier[1]}" 233 | else: 234 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 235 | 236 | @property 237 | def entity_registry_enabled_default(self) -> bool: 238 | """Return if the entity should be enabled when first added to the entity registry.""" 239 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 240 | 241 | @property 242 | def is_on(self) -> bool: 243 | 244 | pres_val = ( 245 | self.coordinator.data.devices[self.deviceid] 246 | .objects[self.objectid] 247 | .presentValue 248 | ) 249 | 250 | if isinstance(pres_val, str): 251 | return pres_val in {"active", "1"} 252 | elif isinstance(pres_val, int): 253 | return pres_val == 1 254 | elif isinstance(pres_val, bool): 255 | return pres_val 256 | else: 257 | self.coordinator.logger.debug( 258 | f"Unknown type for: {self.objectid} {self.coordinator.data.devices[self.deviceid] 259 | .objects[self.objectid] 260 | .presentValue}" 261 | ) 262 | return pres_val 263 | 264 | @property 265 | def icon(self): 266 | return "mdi:lightbulb-outline" 267 | 268 | @property 269 | def device_info(self) -> DeviceInfo: 270 | return DeviceInfo( 271 | identifiers={(DOMAIN, self.deviceid)}, 272 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 273 | manufacturer=self.coordinator.data.devices[self.deviceid] 274 | .objects[self.deviceid] 275 | .vendorName, 276 | model=self.coordinator.data.devices[self.deviceid] 277 | .objects[self.deviceid] 278 | .modelName, 279 | ) 280 | 281 | @property 282 | def extra_state_attributes(self) -> dict[str, Any]: 283 | return { 284 | "inAlarm": bool( 285 | self.coordinator.data.devices[self.deviceid] 286 | .objects[self.objectid] 287 | .statusFlags[0] 288 | ), 289 | "fault": bool( 290 | self.coordinator.data.devices[self.deviceid] 291 | .objects[self.objectid] 292 | .statusFlags[1] 293 | ), 294 | "overridden": bool( 295 | self.coordinator.data.devices[self.deviceid] 296 | .objects[self.objectid] 297 | .statusFlags[2] 298 | ), 299 | "outOfService": bool( 300 | self.coordinator.data.devices[self.deviceid] 301 | .objects[self.objectid] 302 | .statusFlags[3] 303 | ), 304 | } 305 | 306 | async def async_turn_on(self, **kwargs: Any) -> None: 307 | """Set BinaryOutput object to active""" 308 | 309 | propertyid = self.coordinator.config_entry.data.get( 310 | CONF_BINARY_OUTPUT, "present_value" 311 | ) 312 | 313 | await self.coordinator.interface.write_property_v2( 314 | deviceid=self.deviceid, 315 | objectid=self.objectid, 316 | propertyid=key_to_property(propertyid), 317 | value=1, 318 | array_index=None, 319 | priority=None, 320 | ) 321 | 322 | async def async_turn_off(self): 323 | """Set BinaryOutput object to active.""" 324 | 325 | propertyid = self.coordinator.config_entry.data.get( 326 | CONF_BINARY_OUTPUT, "present_value" 327 | ) 328 | 329 | await self.coordinator.interface.write_property_v2( 330 | deviceid=self.deviceid, 331 | objectid=self.objectid, 332 | propertyid=key_to_property(propertyid), 333 | value=0, 334 | array_index=None, 335 | priority=None, 336 | ) 337 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/select.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from homeassistant.components.select import (SelectEntity, 6 | SelectEntityDescription) 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_ENABLED, CONF_NAME 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.typing import StateType 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | from homeassistant.util.dt import utcnow 15 | 16 | from .const import STATETEXT_OFFSET # JCO 17 | from .const import (CONF_MULTISTATE_OUTPUT, CONF_MULTISTATE_VALUE, DOMAIN, 18 | LOGGER) 19 | from .coordinator import EcoPanelDataUpdateCoordinator 20 | from .helper import key_to_property 21 | 22 | 23 | async def async_setup_entry( 24 | hass: HomeAssistant, 25 | entry: ConfigEntry, 26 | async_add_entities: AddEntitiesCallback, 27 | ) -> None: 28 | """Set up EcoPanel sensor based on a config entry.""" 29 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 30 | entity_list: list = [] 31 | 32 | # Collect from all devices the objects that can become a select. 33 | for deviceid in coordinator.data.devices: 34 | if deviceid is None: 35 | LOGGER.warning(f"Device ID is None!") 36 | continue 37 | 38 | if not coordinator.data.devices[deviceid].objects: 39 | LOGGER.warning(f"No objects in {deviceid}!") 40 | continue 41 | 42 | for objectid in coordinator.data.devices[deviceid].objects: 43 | if ( 44 | not coordinator.data.devices[deviceid] 45 | .objects[objectid] 46 | .objectIdentifier 47 | ): 48 | LOGGER.warning(f"No object identifier for {objectid} in {deviceid}!") 49 | continue 50 | 51 | if ( 52 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 53 | == "multiStateValue" 54 | ): 55 | if ( 56 | coordinator.data.devices[deviceid].objects[objectid].numberOfStates 57 | < 1 58 | ): 59 | LOGGER.warning( 60 | f"{deviceid} {objectid} is invalid as it has less that 1 state." 61 | ) 62 | continue 63 | 64 | entity_list.append( 65 | MultiStateValueEntity( 66 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 67 | ) 68 | ) 69 | elif ( 70 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 71 | == "multiStateOutput" 72 | ): 73 | if ( 74 | coordinator.data.devices[deviceid].objects[objectid].numberOfStates 75 | < 1 76 | ): 77 | LOGGER.warning( 78 | f"{deviceid} {objectid} is invalid as it has less that 1 state." 79 | ) 80 | continue 81 | 82 | entity_list.append( 83 | MultiStateOutputEntity( 84 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 85 | ) 86 | ) 87 | 88 | async_add_entities(entity_list) 89 | 90 | 91 | class MultiStateOutputEntity( 92 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], SelectEntity 93 | ): 94 | _attr_has_entity_name = True 95 | 96 | def __init__( 97 | self, 98 | coordinator: EcoPanelDataUpdateCoordinator, 99 | deviceid: str, 100 | objectid: str, 101 | ): 102 | """Initialize a BACnet MultiStateOutput object as entity.""" 103 | super().__init__(coordinator=coordinator) 104 | self.deviceid = deviceid 105 | self.objectid = objectid 106 | 107 | @property 108 | def unique_id(self) -> str: 109 | return f"{self.deviceid}_{self.objectid}" 110 | 111 | @property 112 | def entity_registry_enabled_default(self) -> bool: 113 | """Return if the entity should be enabled when first added to the entity registry.""" 114 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 115 | 116 | @property 117 | def name(self) -> str: 118 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 119 | if name == "description": 120 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 121 | elif name == "object_identifier": 122 | identifier = ( 123 | self.coordinator.data.devices[self.deviceid] 124 | .objects[self.objectid] 125 | .objectIdentifier 126 | ) 127 | return f"{identifier[0]}:{identifier[1]}" 128 | else: 129 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 130 | 131 | @property 132 | def icon(self): 133 | return "mdi:menu" 134 | 135 | @property 136 | def options(self) -> list: 137 | if ( 138 | state_text := self.coordinator.data.devices[self.deviceid] 139 | .objects[self.objectid] 140 | .stateText 141 | ): 142 | if any(state_text): 143 | return state_text 144 | 145 | if ( 146 | number_of_states := self.coordinator.data.devices[self.deviceid] 147 | .objects[self.objectid] 148 | .numberOfStates 149 | ): 150 | return [str(i) for i in range(1, number_of_states + 1)] 151 | 152 | else: 153 | LOGGER.error( 154 | f"{self.deviceid} {self.objectid} is missing REQUIRED numberOfStates property!" 155 | ) 156 | return [] 157 | 158 | @property 159 | def current_option(self) -> str: 160 | pres_val = int( 161 | self.coordinator.data.devices[self.deviceid] 162 | .objects[self.objectid] 163 | .presentValue 164 | ) 165 | 166 | if ( 167 | state_text := self.coordinator.data.devices[self.deviceid] 168 | .objects[self.objectid] 169 | .stateText 170 | ): 171 | if any(state_text): 172 | return state_text[pres_val - STATETEXT_OFFSET] 173 | if ( 174 | number_of_states := self.coordinator.data.devices[self.deviceid] 175 | .objects[self.objectid] 176 | .numberOfStates 177 | ): 178 | options = [str(i) for i in range(1, number_of_states + 1)] 179 | return options[pres_val - STATETEXT_OFFSET] 180 | else: 181 | return str(pres_val) 182 | 183 | @property 184 | def device_info(self) -> DeviceInfo: 185 | return DeviceInfo( 186 | identifiers={(DOMAIN, self.deviceid)}, 187 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 188 | manufacturer=self.coordinator.data.devices[self.deviceid] 189 | .objects[self.deviceid] 190 | .vendorName, 191 | model=self.coordinator.data.devices[self.deviceid] 192 | .objects[self.deviceid] 193 | .modelName, 194 | ) 195 | 196 | @property 197 | def extra_state_attributes(self) -> dict[str, Any]: 198 | return { 199 | "inAlarm": bool( 200 | self.coordinator.data.devices[self.deviceid] 201 | .objects[self.objectid] 202 | .statusFlags[0] 203 | ), 204 | "fault": bool( 205 | self.coordinator.data.devices[self.deviceid] 206 | .objects[self.objectid] 207 | .statusFlags[1] 208 | ), 209 | "overridden": bool( 210 | self.coordinator.data.devices[self.deviceid] 211 | .objects[self.objectid] 212 | .statusFlags[2] 213 | ), 214 | "outOfService": bool( 215 | self.coordinator.data.devices[self.deviceid] 216 | .objects[self.objectid] 217 | .statusFlags[3] 218 | ), 219 | } 220 | 221 | async def async_select_option(self, option: str) -> None: 222 | """Change the selected option.""" 223 | 224 | pres_val = self.options.index(option) + STATETEXT_OFFSET 225 | 226 | propertyid = self.coordinator.config_entry.data.get( 227 | CONF_MULTISTATE_OUTPUT, "present_value" 228 | ) 229 | 230 | await self.coordinator.interface.write_property_v2( 231 | deviceid=self.deviceid, 232 | objectid=self.objectid, 233 | propertyid=key_to_property(propertyid), 234 | value=pres_val, 235 | array_index=None, 236 | priority=None, 237 | ) 238 | 239 | 240 | class MultiStateValueEntity( 241 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], SelectEntity 242 | ): 243 | _attr_has_entity_name = True 244 | 245 | def __init__( 246 | self, 247 | coordinator: EcoPanelDataUpdateCoordinator, 248 | deviceid: str, 249 | objectid: str, 250 | ): 251 | """Initialize a BACnet MultiStateValue object as entity.""" 252 | super().__init__(coordinator=coordinator) 253 | self.deviceid = deviceid 254 | self.objectid = objectid 255 | 256 | @property 257 | def unique_id(self) -> str: 258 | return f"{self.deviceid}_{self.objectid}" 259 | 260 | @property 261 | def entity_registry_enabled_default(self) -> bool: 262 | """Return if the entity should be enabled when first added to the entity registry.""" 263 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 264 | 265 | @property 266 | def name(self) -> str: 267 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 268 | if name == "description": 269 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 270 | elif name == "object_identifier": 271 | identifier = ( 272 | self.coordinator.data.devices[self.deviceid] 273 | .objects[self.objectid] 274 | .objectIdentifier 275 | ) 276 | return f"{identifier[0]}:{identifier[1]}" 277 | else: 278 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 279 | 280 | @property 281 | def icon(self): 282 | return "mdi:menu" 283 | 284 | @property 285 | def options(self) -> list: 286 | if ( 287 | state_text := self.coordinator.data.devices[self.deviceid] 288 | .objects[self.objectid] 289 | .stateText 290 | ): 291 | if any(state_text): 292 | return state_text 293 | 294 | if ( 295 | number_of_states := self.coordinator.data.devices[self.deviceid] 296 | .objects[self.objectid] 297 | .numberOfStates 298 | ): 299 | return [str(i) for i in range(1, number_of_states + 1)] 300 | 301 | else: 302 | LOGGER.error( 303 | f"{self.deviceid} {self.objectid} is missing REQUIRED numberOfStates property!" 304 | ) 305 | return [] 306 | 307 | @property 308 | def current_option(self) -> str: 309 | pres_val = int( 310 | self.coordinator.data.devices[self.deviceid] 311 | .objects[self.objectid] 312 | .presentValue 313 | ) 314 | 315 | if ( 316 | state_text := self.coordinator.data.devices[self.deviceid] 317 | .objects[self.objectid] 318 | .stateText 319 | ): 320 | if any(state_text): 321 | return state_text[pres_val - STATETEXT_OFFSET] 322 | if ( 323 | number_of_states := self.coordinator.data.devices[self.deviceid] 324 | .objects[self.objectid] 325 | .numberOfStates 326 | ): 327 | options = [str(i) for i in range(1, number_of_states + 1)] 328 | return options[pres_val - STATETEXT_OFFSET] 329 | else: 330 | return str(pres_val) 331 | 332 | @property 333 | def device_info(self) -> DeviceInfo: 334 | return DeviceInfo( 335 | identifiers={(DOMAIN, self.deviceid)}, 336 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 337 | manufacturer=self.coordinator.data.devices[self.deviceid] 338 | .objects[self.deviceid] 339 | .vendorName, 340 | model=self.coordinator.data.devices[self.deviceid] 341 | .objects[self.deviceid] 342 | .modelName, 343 | ) 344 | 345 | @property 346 | def extra_state_attributes(self) -> dict[str, Any]: 347 | return { 348 | "inAlarm": bool( 349 | self.coordinator.data.devices[self.deviceid] 350 | .objects[self.objectid] 351 | .statusFlags[0] 352 | ), 353 | "fault": bool( 354 | self.coordinator.data.devices[self.deviceid] 355 | .objects[self.objectid] 356 | .statusFlags[1] 357 | ), 358 | "overridden": bool( 359 | self.coordinator.data.devices[self.deviceid] 360 | .objects[self.objectid] 361 | .statusFlags[2] 362 | ), 363 | "outOfService": bool( 364 | self.coordinator.data.devices[self.deviceid] 365 | .objects[self.objectid] 366 | .statusFlags[3] 367 | ), 368 | } 369 | 370 | async def async_select_option(self, option: str) -> None: 371 | """Change the selected option.""" 372 | 373 | pres_val = self.options.index(option) + STATETEXT_OFFSET 374 | 375 | propertyid = self.coordinator.config_entry.data.get( 376 | CONF_MULTISTATE_VALUE, "present_value" 377 | ) 378 | 379 | await self.coordinator.interface.write_property_v2( 380 | deviceid=self.deviceid, 381 | objectid=self.objectid, 382 | propertyid=key_to_property(propertyid), 383 | value=pres_val, 384 | array_index=None, 385 | priority=None, 386 | ) 387 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/number.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from statistics import mode 4 | from typing import Any 5 | 6 | from homeassistant.components.number import (NumberDeviceClass, NumberEntity, 7 | NumberEntityDescription) 8 | from homeassistant.components.number.const import DEVICE_CLASS_UNITS 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import (CONF_ENABLED, CONF_NAME, PERCENTAGE, 11 | UnitOfElectricCurrent, 12 | UnitOfElectricPotential, UnitOfInformation, 13 | UnitOfIrradiance, UnitOfTemperature) 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.exceptions import InvalidStateError 16 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.typing import StateType 19 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 20 | from homeassistant.util.dt import utcnow 21 | 22 | from .const import CONF_ANALOG_OUTPUT, CONF_ANALOG_VALUE, DOMAIN, LOGGER 23 | from .coordinator import EcoPanelDataUpdateCoordinator 24 | from .helper import bacnet_to_device_class, bacnet_to_ha_units, key_to_property 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Set up EcoPanel sensor based on a config entry.""" 33 | coordinator: EcoPanelDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 34 | entity_list: list = [] 35 | 36 | # Collect from all devices the objects that can become a sensor 37 | if not coordinator.data.devices: 38 | LOGGER.warning(f"No devices received from API!") 39 | return 40 | 41 | for deviceid in coordinator.data.devices: 42 | if not coordinator.data.devices[deviceid].objects: 43 | LOGGER.warning(f"No objects in {deviceid}!") 44 | continue 45 | 46 | for objectid in coordinator.data.devices[deviceid].objects: 47 | if ( 48 | not coordinator.data.devices[deviceid] 49 | .objects[objectid] 50 | .objectIdentifier 51 | ): 52 | LOGGER.warning(f"No object identifier for {objectid} in {deviceid}!") 53 | continue 54 | 55 | if ( 56 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 57 | == "analogOutput" 58 | ): 59 | # Object Type to ObjectIdentifier[0] 60 | entity_list.append( 61 | AnalogOutputEntity( 62 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 63 | ) 64 | ) 65 | elif ( 66 | coordinator.data.devices[deviceid].objects[objectid].objectIdentifier[0] 67 | == "analogValue" 68 | ): 69 | entity_list.append( 70 | AnalogValueEntity( 71 | coordinator=coordinator, deviceid=deviceid, objectid=objectid 72 | ) 73 | ) 74 | 75 | async_add_entities(entity_list) 76 | 77 | 78 | class AnalogOutputEntity( 79 | CoordinatorEntity[EcoPanelDataUpdateCoordinator], NumberEntity 80 | ): 81 | _attr_has_entity_name = True 82 | 83 | def __init__( 84 | self, 85 | coordinator: EcoPanelDataUpdateCoordinator, 86 | deviceid: str, 87 | objectid: str, 88 | ): 89 | """Initialize a BACnet AnalogOutput object as entity.""" 90 | super().__init__(coordinator=coordinator) 91 | self.deviceid = deviceid 92 | self.objectid = objectid 93 | 94 | @property 95 | def unique_id(self) -> str: 96 | return f"{self.deviceid}_{self.objectid}" 97 | 98 | @property 99 | def name(self) -> str: 100 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 101 | if name == "description": 102 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 103 | elif name == "object_identifier": 104 | identifier = ( 105 | self.coordinator.data.devices[self.deviceid] 106 | .objects[self.objectid] 107 | .objectIdentifier 108 | ) 109 | return f"{identifier[0]}:{identifier[1]}" 110 | else: 111 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 112 | 113 | @property 114 | def icon(self): 115 | return "mdi:gesture-swipe-vertical" 116 | 117 | @property 118 | def entity_registry_enabled_default(self) -> bool: 119 | """Return if the entity should be enabled when first added to the entity registry.""" 120 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 121 | 122 | @property 123 | def mode(self) -> str: 124 | return "box" 125 | 126 | @property 127 | def native_step(self): 128 | if ( 129 | self.coordinator.data.devices[self.deviceid] 130 | .objects[self.objectid] 131 | .resolution 132 | ): 133 | return ( 134 | self.coordinator.data.devices[self.deviceid] 135 | .objects[self.objectid] 136 | .resolution 137 | ) 138 | elif ( 139 | self.coordinator.data.devices[self.deviceid] 140 | .objects[self.objectid] 141 | .covIncrement 142 | ): 143 | return ( 144 | self.coordinator.data.devices[self.deviceid] 145 | .objects[self.objectid] 146 | .covIncrement 147 | ) 148 | else: 149 | return float(0.1) 150 | 151 | @property 152 | def native_value(self): 153 | value = self.coordinator.data.devices[self.deviceid].objects[self.objectid].presentValue 154 | 155 | if value is None: 156 | raise InvalidStateError 157 | 158 | value = float(value) 159 | return int(value) if self.native_step >= 1 else value 160 | 161 | @property 162 | def native_max_value(self): 163 | if ( 164 | self.coordinator.data.devices[self.deviceid] 165 | .objects[self.objectid] 166 | .maxPresValue 167 | ): 168 | return ( 169 | self.coordinator.data.devices[self.deviceid] 170 | .objects[self.objectid] 171 | .maxPresValue 172 | ) 173 | else: 174 | return 2147483647 175 | 176 | @property 177 | def native_min_value(self): 178 | if ( 179 | self.coordinator.data.devices[self.deviceid] 180 | .objects[self.objectid] 181 | .minPresValue 182 | ): 183 | return ( 184 | self.coordinator.data.devices[self.deviceid] 185 | .objects[self.objectid] 186 | .minPresValue 187 | ) 188 | else: 189 | return -2147483648 190 | 191 | @property 192 | def native_unit_of_measurement(self) -> str | None: 193 | if ( 194 | units := self.coordinator.data.devices[self.deviceid] 195 | .objects[self.objectid] 196 | .units 197 | ): 198 | return bacnet_to_ha_units(units) 199 | else: 200 | return None 201 | 202 | @property 203 | def device_class(self) -> str | None: 204 | if ( 205 | units := self.coordinator.data.devices[self.deviceid] 206 | .objects[self.objectid] 207 | .units 208 | ): 209 | return bacnet_to_device_class(units, DEVICE_CLASS_UNITS) 210 | else: 211 | return None 212 | 213 | @property 214 | def extra_state_attributes(self) -> dict[str, Any]: 215 | return { 216 | "inAlarm": bool( 217 | self.coordinator.data.devices[self.deviceid] 218 | .objects[self.objectid] 219 | .statusFlags[0] 220 | ), 221 | "fault": bool( 222 | self.coordinator.data.devices[self.deviceid] 223 | .objects[self.objectid] 224 | .statusFlags[1] 225 | ), 226 | "overridden": bool( 227 | self.coordinator.data.devices[self.deviceid] 228 | .objects[self.objectid] 229 | .statusFlags[2] 230 | ), 231 | "outOfService": bool( 232 | self.coordinator.data.devices[self.deviceid] 233 | .objects[self.objectid] 234 | .statusFlags[3] 235 | ), 236 | } 237 | 238 | @property 239 | def device_info(self) -> DeviceInfo: 240 | return DeviceInfo( 241 | identifiers={(DOMAIN, self.deviceid)}, 242 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 243 | manufacturer=self.coordinator.data.devices[self.deviceid] 244 | .objects[self.deviceid] 245 | .vendorName, 246 | model=self.coordinator.data.devices[self.deviceid] 247 | .objects[self.deviceid] 248 | .modelName, 249 | ) 250 | 251 | async def async_set_native_value(self, value: float) -> None: 252 | """Set analogOutput object to active.""" 253 | 254 | propertyid = self.coordinator.config_entry.data.get( 255 | CONF_ANALOG_OUTPUT, "present_value" 256 | ) 257 | 258 | await self.coordinator.interface.write_property_v2( 259 | deviceid=self.deviceid, 260 | objectid=self.objectid, 261 | propertyid=key_to_property(propertyid), 262 | value=value, 263 | array_index=None, 264 | priority=None, 265 | ) 266 | 267 | 268 | class AnalogValueEntity(CoordinatorEntity[EcoPanelDataUpdateCoordinator], NumberEntity): 269 | _attr_has_entity_name = True 270 | 271 | def __init__( 272 | self, 273 | coordinator: EcoPanelDataUpdateCoordinator, 274 | deviceid: str, 275 | objectid: str, 276 | ): 277 | """Initialize a BACnet AnalogValue object as entity.""" 278 | super().__init__(coordinator=coordinator) 279 | self.deviceid = deviceid 280 | self.objectid = objectid 281 | 282 | @property 283 | def unique_id(self) -> str: 284 | return f"{self.deviceid}_{self.objectid}" 285 | 286 | @property 287 | def name(self) -> str: 288 | name = self.coordinator.config_entry.data.get(CONF_NAME, "object_name") 289 | if name == "description": 290 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].description}" 291 | elif name == "object_identifier": 292 | identifier = ( 293 | self.coordinator.data.devices[self.deviceid] 294 | .objects[self.objectid] 295 | .objectIdentifier 296 | ) 297 | return f"{identifier[0]}:{identifier[1]}" 298 | else: 299 | return f"{self.coordinator.data.devices[self.deviceid].objects[self.objectid].objectName}" 300 | 301 | @property 302 | def icon(self): 303 | return "mdi:pencil" 304 | 305 | @property 306 | def entity_registry_enabled_default(self) -> bool: 307 | """Return if the entity should be enabled when first added to the entity registry.""" 308 | return self.coordinator.config_entry.data.get(CONF_ENABLED, False) 309 | 310 | @property 311 | def mode(self) -> str: 312 | return "box" 313 | 314 | @property 315 | def native_step(self): 316 | if ( 317 | self.coordinator.data.devices[self.deviceid] 318 | .objects[self.objectid] 319 | .resolution 320 | ): 321 | return ( 322 | self.coordinator.data.devices[self.deviceid] 323 | .objects[self.objectid] 324 | .resolution 325 | ) 326 | elif ( 327 | self.coordinator.data.devices[self.deviceid] 328 | .objects[self.objectid] 329 | .covIncrement 330 | ): 331 | return float( 332 | self.coordinator.data.devices[self.deviceid] 333 | .objects[self.objectid] 334 | .covIncrement 335 | ) 336 | else: 337 | return float(0.1) 338 | 339 | @property 340 | def native_value(self): 341 | value = self.coordinator.data.devices[self.deviceid].objects[self.objectid].presentValue 342 | 343 | if value is None: 344 | raise InvalidStateError 345 | 346 | value = float(value) 347 | return int(value) if self.native_step >= 1 else value 348 | 349 | @property 350 | def native_max_value(self): 351 | if ( 352 | self.coordinator.data.devices[self.deviceid] 353 | .objects[self.objectid] 354 | .maxPresValue 355 | ): 356 | return ( 357 | self.coordinator.data.devices[self.deviceid] 358 | .objects[self.objectid] 359 | .maxPresValue 360 | ) 361 | else: 362 | return 2147483647 363 | 364 | @property 365 | def native_min_value(self): 366 | if ( 367 | self.coordinator.data.devices[self.deviceid] 368 | .objects[self.objectid] 369 | .minPresValue 370 | ): 371 | return ( 372 | self.coordinator.data.devices[self.deviceid] 373 | .objects[self.objectid] 374 | .minPresValue 375 | ) 376 | else: 377 | return -2147483647 378 | 379 | @property 380 | def native_unit_of_measurement(self) -> str | None: 381 | if ( 382 | units := self.coordinator.data.devices[self.deviceid] 383 | .objects[self.objectid] 384 | .units 385 | ): 386 | return bacnet_to_ha_units(units) 387 | else: 388 | return None 389 | 390 | @property 391 | def device_class(self) -> str | None: 392 | if ( 393 | units := self.coordinator.data.devices[self.deviceid] 394 | .objects[self.objectid] 395 | .units 396 | ): 397 | return bacnet_to_device_class(units, DEVICE_CLASS_UNITS) 398 | else: 399 | return None 400 | 401 | @property 402 | def extra_state_attributes(self) -> dict[str, Any]: 403 | return { 404 | "inAlarm": bool( 405 | self.coordinator.data.devices[self.deviceid] 406 | .objects[self.objectid] 407 | .statusFlags[0] 408 | ), 409 | "fault": bool( 410 | self.coordinator.data.devices[self.deviceid] 411 | .objects[self.objectid] 412 | .statusFlags[1] 413 | ), 414 | "overridden": bool( 415 | self.coordinator.data.devices[self.deviceid] 416 | .objects[self.objectid] 417 | .statusFlags[2] 418 | ), 419 | "outOfService": bool( 420 | self.coordinator.data.devices[self.deviceid] 421 | .objects[self.objectid] 422 | .statusFlags[3] 423 | ), 424 | } 425 | 426 | @property 427 | def device_info(self) -> DeviceInfo: 428 | return DeviceInfo( 429 | identifiers={(DOMAIN, self.deviceid)}, 430 | name=f"{self.coordinator.data.devices[self.deviceid].objects[self.deviceid].objectName}", 431 | manufacturer=self.coordinator.data.devices[self.deviceid] 432 | .objects[self.deviceid] 433 | .vendorName, 434 | model=self.coordinator.data.devices[self.deviceid] 435 | .objects[self.deviceid] 436 | .modelName, 437 | ) 438 | 439 | async def async_set_native_value(self, value: float) -> None: 440 | """Set analogOutput object to active.""" 441 | 442 | propertyid = self.coordinator.config_entry.data.get( 443 | CONF_ANALOG_VALUE, "present_value" 444 | ) 445 | 446 | await self.coordinator.interface.write_property_v2( 447 | deviceid=self.deviceid, 448 | objectid=self.objectid, 449 | propertyid=key_to_property(propertyid), 450 | value=value, 451 | array_index=None, 452 | priority=None, 453 | ) 454 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Hello World integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | from aioecopanel import (DeviceDict, EcoPanelConnectionError, 9 | EcoPanelEmptyResponseError, Interface) 10 | from homeassistant.helpers.service_info.hassio import HassioServiceInfo 11 | from homeassistant.config_entries import (CONN_CLASS_LOCAL_PUSH, ConfigEntry, 12 | ConfigFlow, ConfigFlowResult, 13 | OptionsFlow) 14 | from homeassistant.const import (CONF_CUSTOMIZE, CONF_ENABLED, CONF_HOST, 15 | CONF_NAME, CONF_PORT, CONF_TARGET) 16 | from homeassistant.core import HomeAssistant, callback 17 | from homeassistant.data_entry_flow import FlowResult 18 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 19 | from homeassistant.helpers.selector import selector 20 | from homeassistant.helpers.service_info.hassio import HassioServiceInfo 21 | 22 | from .const import CONF_ANALOG_OUTPUT # pylint:disable=unused-import 23 | from .const import (CONF_ANALOG_VALUE, CONF_BINARY_OUTPUT, CONF_BINARY_VALUE, 24 | CONF_MULTISTATE_OUTPUT, CONF_MULTISTATE_VALUE, DOMAIN, 25 | LOGGER, NAME_OPTIONS, WRITE_OPTIONS) 26 | 27 | _LOGGER = LOGGER 28 | 29 | 30 | class EcoPanelConfigFlow(ConfigFlow, domain=DOMAIN): 31 | """Handle a config flow for the EcoPanel.""" 32 | 33 | VERSION = 1 34 | CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH 35 | 36 | def __init__(self) -> None: 37 | """Initialize options flow.""" 38 | self.options = dict() 39 | 40 | @staticmethod 41 | @callback 42 | def async_get_options_flow(config_entry: ConfigEntry): 43 | """Get the options flow.""" 44 | return OptionsFlowHandler(config_entry) 45 | 46 | async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: 47 | """Set the config entry up from yaml.""" 48 | if self._async_current_entries(): 49 | return self.async_abort(reason="single_instance_allowed") 50 | 51 | return self.async_create_entry(title="BACnet Interface", data=import_info) 52 | 53 | async def async_step_user( 54 | self, user_input: dict[str, Any] | None = None 55 | ) -> FlowResult: 56 | """Handle a flow initiated by the user.""" 57 | 58 | errors = {} 59 | 60 | if self._async_current_entries(): 61 | return self.async_abort(reason="single_instance_allowed") 62 | if self.hass.data.get(DOMAIN): 63 | return self.async_abort(reason="single_instance_allowed") 64 | 65 | return await self.async_step_host() 66 | 67 | async def _async_get_device(self, host: str, port: int) -> DeviceDict: 68 | """Get device information from add-on.""" 69 | session = async_get_clientsession(self.hass) 70 | interface = Interface(host=host, port=port, session=session) 71 | return await interface.update() 72 | 73 | async def async_step_host( 74 | self, user_input: dict[str, Any] | None = None 75 | ) -> FlowResult: 76 | """Get options for naming the entities""" 77 | 78 | errors = {} 79 | 80 | if user_input is not None: 81 | 82 | try: 83 | devicedict = await self._async_get_device( 84 | host=user_input[CONF_HOST], port=user_input[CONF_PORT] 85 | ) 86 | except EcoPanelConnectionError: 87 | errors["base"] = "cannot_connect" 88 | except EcoPanelEmptyResponseError: 89 | errors["base"] = "empty_response" 90 | else: 91 | self.options.update(user_input) 92 | return await self.async_step_naming() 93 | 94 | return self.async_show_form( 95 | step_id="host", 96 | data_schema=vol.Schema( 97 | { 98 | vol.Required( 99 | CONF_HOST, 100 | description={"suggested_value": "127.0.0.1"}, 101 | ): str, 102 | vol.Required( 103 | CONF_PORT, 104 | description={ 105 | "suggested_value": self.options.get(CONF_PORT, 8099) 106 | }, 107 | ): int, 108 | } 109 | ), 110 | errors=errors, 111 | ) 112 | 113 | async def async_step_naming( 114 | self, user_input: dict[str, Any] | None = None 115 | ) -> FlowResult: 116 | """Get options for naming the entities""" 117 | 118 | # Show form for naming and if you want to have advanced config. If so, option step_writing will be presented. 119 | 120 | if user_input is not None: 121 | self.options.update(user_input) 122 | 123 | if user_input[CONF_CUSTOMIZE]: 124 | return await self.async_step_writing() 125 | 126 | return await self._create_options() 127 | 128 | return self.async_show_form( 129 | step_id="naming", 130 | data_schema=vol.Schema( 131 | { 132 | vol.Required( 133 | CONF_NAME, 134 | description={ 135 | "suggested_value": self.options.get( 136 | CONF_NAME, "object_name" 137 | ) 138 | }, 139 | ): selector( 140 | { 141 | "select": { 142 | "options": NAME_OPTIONS, 143 | "multiple": False, 144 | "translation_key": "name_select", 145 | "mode": "dropdown", 146 | } 147 | } 148 | ), 149 | vol.Required( 150 | CONF_ENABLED, description={"suggested_value": True} 151 | ): bool, 152 | vol.Required( 153 | CONF_CUSTOMIZE, 154 | description={ 155 | "suggested_value": self.options.get(CONF_CUSTOMIZE, False) 156 | }, 157 | ): bool, 158 | } 159 | ), 160 | ) 161 | 162 | async def async_step_writing( 163 | self, user_input: dict[str, Any] | None = None 164 | ) -> FlowResult: 165 | """Get options for what properties to write to per objecttype""" 166 | 167 | if user_input is not None: 168 | self.options.update(user_input) 169 | return await self._create_options() 170 | 171 | write_selector = selector( 172 | { 173 | "select": { 174 | "options": WRITE_OPTIONS, 175 | "multiple": False, 176 | "translation_key": "write_options", 177 | "mode": "dropdown", 178 | } 179 | } 180 | ) 181 | 182 | return self.async_show_form( 183 | step_id="writing", 184 | data_schema=vol.Schema( 185 | { 186 | vol.Required( 187 | CONF_ANALOG_OUTPUT, 188 | description={ 189 | "suggested_value": self.options.get( 190 | CONF_ANALOG_OUTPUT, "present_value" 191 | ) 192 | }, 193 | ): write_selector, 194 | vol.Required( 195 | CONF_ANALOG_VALUE, 196 | description={ 197 | "suggested_value": self.options.get( 198 | CONF_ANALOG_VALUE, "present_value" 199 | ) 200 | }, 201 | ): write_selector, 202 | vol.Required( 203 | CONF_BINARY_OUTPUT, 204 | description={ 205 | "suggested_value": self.options.get( 206 | CONF_BINARY_OUTPUT, "present_value" 207 | ) 208 | }, 209 | ): write_selector, 210 | vol.Required( 211 | CONF_BINARY_VALUE, 212 | description={ 213 | "suggested_value": self.options.get( 214 | CONF_BINARY_VALUE, "present_value" 215 | ) 216 | }, 217 | ): write_selector, 218 | vol.Required( 219 | CONF_MULTISTATE_OUTPUT, 220 | description={ 221 | "suggested_value": self.options.get( 222 | CONF_MULTISTATE_OUTPUT, "present_value" 223 | ) 224 | }, 225 | ): write_selector, 226 | vol.Required( 227 | CONF_MULTISTATE_VALUE, 228 | description={ 229 | "suggested_value": self.options.get( 230 | CONF_MULTISTATE_VALUE, "present_value" 231 | ) 232 | }, 233 | ): write_selector, 234 | } 235 | ), 236 | ) 237 | 238 | async def _create_options(self) -> ConfigFlowResult: 239 | """Update config entry options.""" 240 | 241 | # self.hass.config_entries.async_update_entry(self.config_entry, data=self.options) 242 | 243 | return self.async_create_entry(title="BACnet Interface", data=self.options) 244 | 245 | 246 | class OptionsFlowHandler(OptionsFlow): 247 | """Handle Options.""" 248 | 249 | def __init__(self, config_entry: ConfigEntry) -> None: 250 | """Initialize options flow.""" 251 | self.config_entry = config_entry 252 | self.options = dict(config_entry.options) 253 | 254 | async def async_step_init( 255 | self, user_input: dict[str, Any] | None = None 256 | ) -> FlowResult: 257 | """Manage EcoPanel options.""" 258 | 259 | return await self.async_step_host() 260 | 261 | async def _async_get_device(self, host: str, port: int) -> DeviceDict: 262 | """Get device information from add-on.""" 263 | session = async_get_clientsession(self.hass) 264 | interface = Interface(host=host, port=port, session=session) 265 | return await interface.update() 266 | 267 | async def async_step_host( 268 | self, user_input: dict[str, Any] | None = None 269 | ) -> FlowResult: 270 | """Get options for naming the entities""" 271 | 272 | errors = {} 273 | 274 | if user_input is not None: 275 | 276 | try: 277 | devicedict = await self._async_get_device( 278 | host=user_input[CONF_HOST], port=user_input[CONF_PORT] 279 | ) 280 | except EcoPanelConnectionError: 281 | errors["base"] = "cannot_connect" 282 | except EcoPanelEmptyResponseError: 283 | errors["base"] = "empty_response" 284 | else: 285 | self.options.update(user_input) 286 | return await self.async_step_naming() 287 | 288 | return self.async_show_form( 289 | step_id="host", 290 | data_schema=vol.Schema( 291 | { 292 | vol.Required( 293 | CONF_HOST, 294 | description={ 295 | "suggested_value": self.config_entry.data.get(CONF_HOST) 296 | }, 297 | ): str, 298 | vol.Required( 299 | CONF_PORT, 300 | description={ 301 | "suggested_value": self.config_entry.data.get( 302 | CONF_PORT, 8099 303 | ) 304 | }, 305 | ): int, 306 | } 307 | ), 308 | errors=errors, 309 | ) 310 | 311 | async def async_step_naming( 312 | self, user_input: dict[str, Any] | None = None 313 | ) -> FlowResult: 314 | """Get options for naming the entities""" 315 | 316 | # Show form for naming and if you want to have advanced config. If so, option step_writing will be presented. 317 | 318 | if user_input is not None: 319 | self.options.update(user_input) 320 | 321 | if user_input[CONF_CUSTOMIZE]: 322 | return await self.async_step_writing() 323 | 324 | return await self._update_options() 325 | 326 | return self.async_show_form( 327 | step_id="naming", 328 | data_schema=vol.Schema( 329 | { 330 | vol.Required( 331 | CONF_NAME, 332 | description={ 333 | "suggested_value": self.config_entry.data.get( 334 | CONF_NAME, "object_name" 335 | ) 336 | }, 337 | ): selector( 338 | { 339 | "select": { 340 | "options": NAME_OPTIONS, 341 | "multiple": False, 342 | "translation_key": "name_select", 343 | "mode": "dropdown", 344 | } 345 | } 346 | ), 347 | vol.Required( 348 | CONF_CUSTOMIZE, 349 | description={ 350 | "suggested_value": self.config_entry.data.get( 351 | CONF_CUSTOMIZE, False 352 | ) 353 | }, 354 | ): bool, 355 | } 356 | ), 357 | ) 358 | 359 | async def async_step_writing( 360 | self, user_input: dict[str, Any] | None = None 361 | ) -> FlowResult: 362 | """Get options for what properties to write to per objecttype""" 363 | # show form for analogValue, analogOutput, binaryValue, binaryOutput, multiStateValue, multiStateOutput with dropdown choosing either present_value or relinquishDefault 364 | 365 | if user_input is not None: 366 | self.options.update(user_input) 367 | return await self._update_options() 368 | 369 | write_selector = selector( 370 | { 371 | "select": { 372 | "options": WRITE_OPTIONS, 373 | "multiple": False, 374 | "translation_key": "write_options", 375 | "mode": "dropdown", 376 | } 377 | } 378 | ) 379 | 380 | return self.async_show_form( 381 | step_id="writing", 382 | data_schema=vol.Schema( 383 | { 384 | vol.Required( 385 | CONF_ANALOG_OUTPUT, 386 | description={ 387 | "suggested_value": self.config_entry.data.get( 388 | CONF_ANALOG_OUTPUT, "present_value" 389 | ) 390 | }, 391 | ): write_selector, 392 | vol.Required( 393 | CONF_ANALOG_VALUE, 394 | description={ 395 | "suggested_value": self.config_entry.data.get( 396 | CONF_ANALOG_VALUE, "present_value" 397 | ) 398 | }, 399 | ): write_selector, 400 | vol.Required( 401 | CONF_BINARY_OUTPUT, 402 | description={ 403 | "suggested_value": self.config_entry.data.get( 404 | CONF_BINARY_OUTPUT, "present_value" 405 | ) 406 | }, 407 | ): write_selector, 408 | vol.Required( 409 | CONF_BINARY_VALUE, 410 | description={ 411 | "suggested_value": self.config_entry.data.get( 412 | CONF_BINARY_VALUE, "present_value" 413 | ) 414 | }, 415 | ): write_selector, 416 | vol.Required( 417 | CONF_MULTISTATE_OUTPUT, 418 | description={ 419 | "suggested_value": self.config_entry.data.get( 420 | CONF_MULTISTATE_OUTPUT, "present_value" 421 | ) 422 | }, 423 | ): write_selector, 424 | vol.Required( 425 | CONF_MULTISTATE_VALUE, 426 | description={ 427 | "suggested_value": self.config_entry.data.get( 428 | CONF_MULTISTATE_VALUE, "present_value" 429 | ) 430 | }, 431 | ): write_selector, 432 | } 433 | ), 434 | ) 435 | 436 | async def _update_options(self) -> ConfigFlowResult: 437 | """Update config entry options.""" 438 | 439 | self.hass.config_entries.async_update_entry( 440 | self.config_entry, data=self.options 441 | ) 442 | 443 | return self.async_create_entry(title=self.config_entry.title, data=self.options) 444 | -------------------------------------------------------------------------------- /custom_components/bacnet_interface/helper.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the integration""" 2 | 3 | import math 4 | import string 5 | from logging import BASIC_FORMAT 6 | 7 | from homeassistant.components.number import NumberDeviceClass 8 | from homeassistant.components.sensor import SensorDeviceClass 9 | from homeassistant.const import (CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, 10 | CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 11 | CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 12 | CONCENTRATION_PARTS_PER_BILLION, 13 | CONCENTRATION_PARTS_PER_CUBIC_METER, 14 | CONCENTRATION_PARTS_PER_MILLION, 15 | CURRENCY_DOLLAR, CURRENCY_EURO, DEGREE, 16 | LIGHT_LUX, PERCENTAGE, 17 | REVOLUTIONS_PER_MINUTE, UnitOfApparentPower, 18 | UnitOfArea, 19 | UnitOfElectricCurrent, 20 | UnitOfElectricPotential, UnitOfEnergy, 21 | UnitOfFrequency, UnitOfInformation, 22 | UnitOfIrradiance, UnitOfLength, UnitOfMass, 23 | UnitOfPower, UnitOfPrecipitationDepth, 24 | UnitOfPressure, UnitOfReactivePower, 25 | UnitOfSoundPressure, UnitOfSpeed, 26 | UnitOfTemperature, UnitOfTime, 27 | UnitOfVolume, UnitOfVolumeFlowRate, 28 | UnitOfVolumetricFlux) 29 | 30 | from .const import LOGGER 31 | 32 | 33 | def key_to_property(key: str | None) -> str | None: 34 | match key: 35 | case "present_value" | "presentValue": 36 | return "presentValue" 37 | case "relinquish_default" | "relinquishDefault": 38 | return "relinquishDefault" 39 | case _: 40 | return None 41 | 42 | 43 | def bacnet_to_ha_units(unit_in: str | None) -> str | None: 44 | match unit_in: 45 | case "amperes": 46 | return UnitOfElectricCurrent.AMPERE 47 | case "ampereSeconds": 48 | return None 49 | case "amperesPerMeter": 50 | return None 51 | case "amperesPerSquareMeter": 52 | return None 53 | case "ampereSquareHours": 54 | return None 55 | case "ampereSquareMeters": 56 | return None 57 | case "bars": 58 | return None 59 | case "becquerels": 60 | return None 61 | case "btus": 62 | return None 63 | case "btusPerHour": 64 | return UnitOfPower.BTU_PER_HOUR 65 | case "btusPerPound": 66 | return None 67 | case "btusPerPoundDryAir": 68 | return None 69 | case "candelas": 70 | return None 71 | case "candelasPerSquareMeter": 72 | return None 73 | case "centimeters": 74 | return UnitOfLength.CENTIMETERS 75 | case "centimetersOfMercury": 76 | return None 77 | case "centimetersOfWater": 78 | return UnitOfPrecipitationDepth.CENTIMETERS 79 | case "cubicFeet": 80 | return UnitOfVolume.CUBIC_FEET 81 | case "cubicFeetPerDay": 82 | return None 83 | case "cubicFeetPerHour": 84 | return None 85 | case "cubicFeetPerMinute": 86 | return UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE 87 | case "cubicFeetPerSecond": 88 | return None 89 | case "cubicMeters": 90 | return UnitOfVolume.CUBIC_METERS 91 | case "cubicMetersPerDay": 92 | return None 93 | case "cubicMetersPerHour": 94 | return UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR 95 | case "cubicMetersPerMinute": 96 | return None 97 | case "cubicMetersPerSecond": 98 | return None 99 | case "currency10": 100 | return None 101 | case "currency1": 102 | return None 103 | case "currency2": 104 | return None 105 | case "currency3": 106 | return None 107 | case "currency4": 108 | return None 109 | case "currency5": 110 | return None 111 | case "currency6": 112 | return None 113 | case "currency7": 114 | return None 115 | case "currency8": 116 | return None 117 | case "currency9": 118 | return None 119 | case "cyclesPerHour": 120 | return None 121 | case "cyclesPerMinute": 122 | return None 123 | case "days": 124 | return UnitOfTime.DAYS 125 | case "decibels": 126 | return UnitOfSoundPressure.DECIBEL 127 | case "decibelsA": 128 | return UnitOfSoundPressure.WEIGHTED_DECIBEL_A 129 | case "decibelsMillivolt": 130 | return None 131 | case "decibelsVolt": 132 | return None 133 | case "degreeDaysCelsius": 134 | return None 135 | case "degreeDaysFahrenheit": 136 | return None 137 | case "degreesAngular": 138 | return DEGREE 139 | case "degreesCelsius": 140 | return UnitOfTemperature.CELSIUS 141 | case "degreesCelsiusPerHour": 142 | return None 143 | case "degreesCelsiusPerMinute": 144 | return None 145 | case "degreesFahrenheit": 146 | return UnitOfTemperature.FAHRENHEIT 147 | case "degreesFahrenheitPerHour": 148 | return None 149 | case "degreesFahrenheitPerMinute": 150 | return None 151 | case "degreesKelvin": 152 | return UnitOfTemperature.KELVIN 153 | case "degreesKelvinPerHour": 154 | return None 155 | case "degreesKelvinPerMinute": 156 | return None 157 | case "degreesPhase": 158 | return DEGREE 159 | case "deltaDegreesFahrenheit": 160 | return None 161 | case "deltaDegreesKelvin": 162 | return None 163 | case "farads": 164 | return None 165 | case "feet": 166 | return UnitOfLength.FEET 167 | case "feetPerMinute": 168 | return None 169 | case "feetPerSecond": 170 | return UnitOfSpeed.FEET_PER_SECOND 171 | case "footCandles": 172 | return None 173 | case "grams": 174 | return UnitOfMass.GRAMS 175 | case "gramsOfWaterPerKilogramDryAir": 176 | return None 177 | case "gramsPerCubicCentimeter": 178 | return None 179 | case "gramsPerCubicMeter": 180 | return None 181 | case "gramsPerGram": 182 | return None 183 | case "gramsPerKilogram": 184 | return None 185 | case "gramsPerLiter": 186 | return None 187 | case "gramsPerMilliliter": 188 | return None 189 | case "gramsPerMinute": 190 | return None 191 | case "gramsPerSecond": 192 | return None 193 | case "gramsPerSquareMeter": 194 | return None 195 | case "gray": 196 | return None 197 | case "hectopascals": 198 | return UnitOfPressure.HPA 199 | case "henrys": 200 | return None 201 | case "hertz": 202 | return UnitOfFrequency.HERTZ 203 | case "horsepower": 204 | return None 205 | case "hours": 206 | return UnitOfTime.HOURS 207 | case "hundredthsSeconds": 208 | return None 209 | case "imperialGallons": 210 | return None 211 | case "imperialGallonsPerMinute": 212 | return None 213 | case "inches": 214 | return UnitOfLength.INCHES 215 | case "inchesOfMercury": 216 | return None 217 | case "inchesOfWater": 218 | return UnitOfPrecipitationDepth.INCHES 219 | case "joules": 220 | return None 221 | case "jouleSeconds": 222 | return None 223 | case "joulesPerCubicMeter": 224 | return None 225 | case "joulesPerDegreeKelvin": 226 | return None 227 | case "joulesPerHours": 228 | return None 229 | case "joulesPerKilogramDegreeKelvin": 230 | return None 231 | case "joulesPerKilogramDryAir": 232 | return None 233 | case "kilobecquerels": 234 | return None 235 | case "kiloBtus": 236 | return None 237 | case "kiloBtusPerHour": 238 | return None 239 | case "kilograms": 240 | return UnitOfMass.KILOGRAMS 241 | case "kilogramsPerCubicMeter": 242 | return None 243 | case "kilogramsPerHour": 244 | return None 245 | case "kilogramsPerKilogram": 246 | return None 247 | case "kilogramsPerMinute": 248 | return None 249 | case "kilogramsPerSecond": 250 | return None 251 | case "kilohertz": 252 | return UnitOfFrequency.KILOHERTZ 253 | case "kilohms": 254 | return None 255 | case "kilojoules": 256 | return None 257 | case "kilojoulesPerDegreeKelvin": 258 | return None 259 | case "kilojoulesPerKilogram": 260 | return None 261 | case "kilojoulesPerKilogramDryAir": 262 | return None 263 | case "kilometers": 264 | return UnitOfLength.KILOMETERS 265 | case "kilometersPerHour": 266 | return UnitOfSpeed.KILOMETERS_PER_HOUR 267 | case "kilopascals": 268 | return UnitOfPressure.KPA 269 | case "kilovoltAmpereHours": 270 | return None 271 | case "kilovoltAmpereHoursReactive": 272 | return None 273 | case "kilovoltAmperes": 274 | return UnitOfApparentPower.VOLT_AMPERE 275 | case "kilovoltAmperesReactive": 276 | return None 277 | case "kilovolts": 278 | return None 279 | case "kilowattHours": 280 | return UnitOfEnergy.KILO_WATT_HOUR 281 | case "kilowattHoursPerSquareFoot": 282 | return None 283 | case "kilowattHoursPerSquareMeter": 284 | return None 285 | case "kilowattHoursReactive": 286 | return None 287 | case "kilowatts": 288 | return UnitOfPower.KILO_WATT 289 | case "liters": 290 | return UnitOfVolume.LITERS 291 | case "litersPerHour": 292 | return None 293 | case "litersPerMinute": 294 | return UnitOfVolumeFlowRate.LITERS_PER_MINUTE 295 | case "litersPerSecond": 296 | return None 297 | case "lumens": 298 | return None 299 | case "luxes": 300 | return LIGHT_LUX 301 | case "megabecquerels": 302 | return None 303 | case "megaBtus": 304 | return None 305 | case "megahertz": 306 | return UnitOfFrequency.MEGAHERTZ 307 | case "megajoules": 308 | return UnitOfEnergy.MEGA_JOULE 309 | case "megajoulesPerDegreeKelvin": 310 | return None 311 | case "megajoulesPerKilogramDryAir": 312 | return None 313 | case "megajoulesPerSquareFoot": 314 | return None 315 | case "megajoulesPerSquareMeter": 316 | return None 317 | case "megavoltAmpereHours": 318 | return None 319 | case "megavoltAmpereHoursReactive": 320 | return None 321 | case "megavoltAmperes": 322 | return None 323 | case "megavoltAmperesReactive": 324 | return None 325 | case "megavolts": 326 | return None 327 | case "megawattHours": 328 | return UnitOfEnergy.MEGA_WATT_HOUR 329 | case "megawattHoursReactive": 330 | return None 331 | case "megawatts": 332 | return None 333 | case "megohms": 334 | return None 335 | case "meters": 336 | return UnitOfLength.METERS 337 | case "metersPerHour": 338 | return None 339 | case "metersPerMinute": 340 | return None 341 | case "metersPerSecond": 342 | return UnitOfSpeed.METERS_PER_SECOND 343 | case "metersPerSecondPerSecond": 344 | return None 345 | case "microgramsPerCubicMeter": 346 | return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER 347 | case "microgramsPerLiter": 348 | return None 349 | case "microgray": 350 | return None 351 | case "micrometers": 352 | return None 353 | case "microSiemens": 354 | return None 355 | case "microsieverts": 356 | return None 357 | case "microsievertsPerHour": 358 | return None 359 | case "milesPerHour": 360 | return UnitOfSpeed.MILES_PER_HOUR 361 | case "milliamperes": 362 | return UnitOfElectricCurrent.MILLIAMPERE 363 | case "millibars": 364 | return UnitOfPressure.MBAR 365 | case "milligrams": 366 | return UnitOfMass.MILLIGRAMS 367 | case "milligramsPerCubicMeter": 368 | return CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER 369 | case "milligramsPerGram": 370 | return None 371 | case "milligramsPerKilogram": 372 | return None 373 | case "milligramsPerLiter": 374 | return None 375 | case "milligray": 376 | return None 377 | case "milliliters": 378 | return UnitOfVolume.MILLILITERS 379 | case "millilitersPerSecond": 380 | return None 381 | case "millimeters": 382 | return UnitOfLength.MILLIMETERS 383 | case "millimetersOfMercury": 384 | return None 385 | case "millimetersOfWater": 386 | return UnitOfPrecipitationDepth.MILLIMETERS 387 | case "millimetersPerMinute": 388 | return None 389 | case "millimetersPerSecond": 390 | return None 391 | case "milliohms": 392 | return None 393 | case "milliseconds": 394 | return UnitOfTime.MILLISECONDS 395 | case "millisiemens": 396 | return None 397 | case "millisieverts": 398 | return None 399 | case "millivolts": 400 | return UnitOfElectricPotential.MILLIVOLT 401 | case "milliwatts": 402 | return None 403 | case "minutes": 404 | return UnitOfTime.MINUTES 405 | case "minutesPerDegreeKelvin": 406 | return None 407 | case "months": 408 | return UnitOfTime.MONTHS 409 | case "nanogramsPerCubicMeter": 410 | return None 411 | case "nephelometricTurbidityUnit": 412 | return None 413 | case "newton": 414 | return None 415 | case "newtonMeters": 416 | return None 417 | case "newtonSeconds": 418 | return None 419 | case "newtonsPerMeter": 420 | return None 421 | case "noUnits": 422 | return None 423 | case "ohmMeterPerSquareMeter": 424 | return None 425 | case "ohmMeters": 426 | return None 427 | case "ohms": 428 | return None 429 | case "partsPerBillion": 430 | return CONCENTRATION_PARTS_PER_BILLION 431 | case "partsPerMillion": 432 | return CONCENTRATION_PARTS_PER_MILLION 433 | case "pascals": 434 | return UnitOfPressure.PA 435 | case "pascalSeconds": 436 | return None 437 | case "percent": 438 | return PERCENTAGE 439 | case "percentObscurationPerFoot": 440 | return None 441 | case "percentObscurationPerMeter": 442 | return None 443 | case "percentPerSecond": 444 | return None 445 | case "percentRelativeHumidity": 446 | return PERCENTAGE 447 | case "perHour": 448 | return None 449 | case "perMille": 450 | return None 451 | case "perMinute": 452 | return None 453 | case "perSecond": 454 | return None 455 | case "pH": 456 | return None 457 | case "poundsForcePerSquareInch": 458 | return UnitOfPressure.PSI 459 | case "poundsMass": 460 | return UnitOfMass.POUNDS 461 | case "poundsMassPerHour": 462 | return None 463 | case "poundsMassPerMinute": 464 | return None 465 | case "poundsMassPerSecond": 466 | return None 467 | case "powerFactor": 468 | return None 469 | case "psiPerDegreeFahrenheit": 470 | return None 471 | case "radians": 472 | return None 473 | case "radiansPerSecond": 474 | return None 475 | case "revolutionsPerMinute": 476 | return REVOLUTIONS_PER_MINUTE 477 | case "seconds": 478 | return UnitOfTime.SECONDS 479 | case "siemens": 480 | return None 481 | case "siemensPerMeter": 482 | return None 483 | case "sieverts": 484 | return None 485 | case "squareCentimeters": 486 | return None 487 | case "squareFeet": 488 | return None 489 | case "squareInches": 490 | return None 491 | case "squareMeters": 492 | return UnitOfArea.SQUARE_METERS 493 | case "squareMetersPerNewton": 494 | return None 495 | case "teslas": 496 | return None 497 | case "therms": 498 | return None 499 | case "tonHours": 500 | return None 501 | case "tons": 502 | return None 503 | case "tonsPerHour": 504 | return None 505 | case "tonsRefrigeration": 506 | return None 507 | case "usGallons": 508 | return UnitOfVolume.GALLONS 509 | case "usGallonsPerHour": 510 | return None 511 | case "usGallonsPerMinute": 512 | return UnitOfVolumeFlowRate.GALLONS_PER_MINUTE 513 | case "voltAmpereHours": 514 | return None 515 | case "voltAmpereHoursReactive": 516 | return UnitOfReactivePower.VOLT_AMPERE_REACTIVE 517 | case "voltAmperes": 518 | return None 519 | case "voltAmperesReactive": 520 | return None 521 | case "volts": 522 | return UnitOfElectricPotential.VOLT 523 | case "voltsPerDegreeKelvin": 524 | return None 525 | case "voltsPerMeter": 526 | return None 527 | case "voltsSquareHours": 528 | return None 529 | case "wattHours": 530 | return UnitOfEnergy.WATT_HOUR 531 | case "wattHoursPerCubicMeter": 532 | return None 533 | case "wattHoursReactive": 534 | return None 535 | case "watts": 536 | return UnitOfPower.WATT 537 | case "wattsPerMeterPerDegreeKelvin": 538 | return None 539 | case "wattsPerSquareFoot": 540 | return None 541 | case "wattsPerSquareMeter": 542 | return UnitOfIrradiance.WATTS_PER_SQUARE_METER 543 | case "wattsPerSquareMeterDegreeKelvin": 544 | return None 545 | case "webers": 546 | return None 547 | case "weeks": 548 | return UnitOfTime.WEEKS 549 | case "years": 550 | return UnitOfTime.YEARS 551 | case _: 552 | return None 553 | 554 | 555 | def bacnet_to_device_class(unit_in: str | None, device_class_units: dict) -> str | None: 556 | """BACnet engineering unit to device class""" 557 | if unit := bacnet_to_ha_units(unit_in): 558 | for classes, values in device_class_units.items(): 559 | if unit in values: 560 | return classes 561 | else: 562 | return None 563 | 564 | 565 | def decimal_places_needed(resolution: float) -> int: 566 | if resolution <= 0: 567 | raise ValueError("Resolution must be greater than 0") 568 | 569 | # Take the base-10 logarithm of the resolution 570 | log10_value = -math.log10(resolution) 571 | 572 | # Take the ceiling of the logarithm value 573 | decimal_places = math.ceil(log10_value) 574 | 575 | return decimal_places 576 | --------------------------------------------------------------------------------