├── 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 | [](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 |
--------------------------------------------------------------------------------