diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json index 7e76f4e0..29513d6d 100644 --- a/.vs/VSWorkspaceState.json +++ b/.vs/VSWorkspaceState.json @@ -3,14 +3,9 @@ "", "\\bacnetinterface_dev", "\\bacnetinterface_dev\\rootfs", - "\\bacnetinterface_dev\\rootfs\\etc", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d\\init-nginx", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d\\interface", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d\\nginx", - "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d\\user" + "\\bacnetinterface_dev\\rootfs\\usr", + "\\bacnetinterface_dev\\rootfs\\usr\\bin" ], - "SelectedNode": "\\bacnetinterface_dev\\rootfs\\etc\\s6-overlay\\s6-rc.d", + "SelectedNode": "\\bacnetinterface_dev\\rootfs\\usr\\bin\\BACnetIOHandler.py", "PreviewInSolutionExplorer": false } \ No newline at end of file diff --git a/.vs/bepacom-HA-Addons/FileContentIndex/fd72ff88-925e-437b-88bb-e0be79f75a32.vsidx b/.vs/bepacom-HA-Addons/FileContentIndex/119e3ac3-3a9f-4b26-916f-bb470830fc81.vsidx similarity index 100% rename from .vs/bepacom-HA-Addons/FileContentIndex/fd72ff88-925e-437b-88bb-e0be79f75a32.vsidx rename to .vs/bepacom-HA-Addons/FileContentIndex/119e3ac3-3a9f-4b26-916f-bb470830fc81.vsidx diff --git a/.vs/bepacom-HA-Addons/FileContentIndex/3898a8f5-4bbb-4c7a-aae9-16773d119136.vsidx b/.vs/bepacom-HA-Addons/FileContentIndex/3898a8f5-4bbb-4c7a-aae9-16773d119136.vsidx new file mode 100644 index 00000000..7daa293e Binary files /dev/null and b/.vs/bepacom-HA-Addons/FileContentIndex/3898a8f5-4bbb-4c7a-aae9-16773d119136.vsidx differ diff --git a/.vs/bepacom-HA-Addons/FileContentIndex/4fc24fcc-7951-4e31-a832-5d82a5fffc44.vsidx b/.vs/bepacom-HA-Addons/FileContentIndex/4fc24fcc-7951-4e31-a832-5d82a5fffc44.vsidx deleted file mode 100644 index 871a039e..00000000 Binary files a/.vs/bepacom-HA-Addons/FileContentIndex/4fc24fcc-7951-4e31-a832-5d82a5fffc44.vsidx and /dev/null differ diff --git a/.vs/bepacom-HA-Addons/FileContentIndex/5497d121-7d70-436d-ba54-d012e8efced2.vsidx b/.vs/bepacom-HA-Addons/FileContentIndex/5497d121-7d70-436d-ba54-d012e8efced2.vsidx new file mode 100644 index 00000000..a7410a5e Binary files /dev/null and b/.vs/bepacom-HA-Addons/FileContentIndex/5497d121-7d70-436d-ba54-d012e8efced2.vsidx differ diff --git a/.vs/bepacom-HA-Addons/FileContentIndex/59eada58-5d1f-49e3-83d2-4130cd34a737.vsidx b/.vs/bepacom-HA-Addons/FileContentIndex/59eada58-5d1f-49e3-83d2-4130cd34a737.vsidx new file mode 100644 index 00000000..36248cdc Binary files /dev/null and b/.vs/bepacom-HA-Addons/FileContentIndex/59eada58-5d1f-49e3-83d2-4130cd34a737.vsidx differ diff --git a/.vs/bepacom-HA-Addons/v17/.wsuo b/.vs/bepacom-HA-Addons/v17/.wsuo index c8d705ca..6a419b6c 100644 Binary files a/.vs/bepacom-HA-Addons/v17/.wsuo and b/.vs/bepacom-HA-Addons/v17/.wsuo differ diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite index 90bc883f..5f14af2d 100644 Binary files a/.vs/slnx.sqlite and b/.vs/slnx.sqlite differ diff --git a/bacnetinterface_dev/.vs/ProjectSettings.json b/bacnetinterface_dev/.vs/ProjectSettings.json new file mode 100644 index 00000000..f8b48885 --- /dev/null +++ b/bacnetinterface_dev/.vs/ProjectSettings.json @@ -0,0 +1,3 @@ +{ + "CurrentProjectSetting": null +} \ No newline at end of file diff --git a/bacnetinterface_dev/.vs/VSWorkspaceState.json b/bacnetinterface_dev/.vs/VSWorkspaceState.json new file mode 100644 index 00000000..b8c58108 --- /dev/null +++ b/bacnetinterface_dev/.vs/VSWorkspaceState.json @@ -0,0 +1,13 @@ +{ + "ExpandedNodes": [ + "", + "\\rootfs", + "\\rootfs\\etc", + "\\rootfs\\etc\\nginx", + "\\rootfs\\usr", + "\\rootfs\\usr\\bin", + "\\translations" + ], + "SelectedNode": "\\rootfs\\usr\\bin\\webAPI.py", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/bacnetinterface_dev/.vs/bacnetinterface/FileContentIndex/3bd273a1-1e97-47ff-9c1b-dd386dda58e1.vsidx b/bacnetinterface_dev/.vs/bacnetinterface/FileContentIndex/3bd273a1-1e97-47ff-9c1b-dd386dda58e1.vsidx new file mode 100644 index 00000000..8a6cc617 Binary files /dev/null and b/bacnetinterface_dev/.vs/bacnetinterface/FileContentIndex/3bd273a1-1e97-47ff-9c1b-dd386dda58e1.vsidx differ diff --git a/bacnetinterface_dev/.vs/bacnetinterface/FileContentIndex/read.lock b/bacnetinterface_dev/.vs/bacnetinterface/FileContentIndex/read.lock new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/.vs/bacnetinterface/v17/.wsuo b/bacnetinterface_dev/.vs/bacnetinterface/v17/.wsuo new file mode 100644 index 00000000..3709256e Binary files /dev/null and b/bacnetinterface_dev/.vs/bacnetinterface/v17/.wsuo differ diff --git a/bacnetinterface_dev/.vs/slnx.sqlite b/bacnetinterface_dev/.vs/slnx.sqlite new file mode 100644 index 00000000..c3c85763 Binary files /dev/null and b/bacnetinterface_dev/.vs/slnx.sqlite differ diff --git a/bacnetinterface_dev/CHANGELOG.md b/bacnetinterface_dev/CHANGELOG.md new file mode 100644 index 00000000..6d39d6bb --- /dev/null +++ b/bacnetinterface_dev/CHANGELOG.md @@ -0,0 +1,246 @@ + + + +# 1.1.3 +15/9/2023 + +- Fixed Float value not getting handled correctly when parsing JSON. +- Removed legacy Home Assistant discovery. +- ⬆️ Bumped base-python image to version v11.0.5. +- ⬆️ Bumped BACpypes3 to version 0.0.79. + + +# 1.1.2 +9/8/2023 + +- Fixed getting rejection PDU stopping subscribing process. +- ⬆️ Bumped base-python image to version v11.0.4. +- ⬆️ Bumped FastAPI to version 0.101.0. +- ⬆️ Bumped Uvicorn to version 0.23.2. + + +# 1.1.1 +7/8/2023 +- Subscriptions no longer indefinite as some devices don't support it. +- Subscriptions can be deleted through the UI. +- Subscriptions get renewed automatically. + + +# 1.1.0 + +7/8/2023 +- NGINX now waits until the API is available before starting. +- Copied CoV method of legacy version. This will stop the system from being overwhelmed and hanging. +- NGINX using templates to dynamically add own IP. + + +# 1.0.6 + +10/7/2023 +- Configuration options are now dropdown menu's, reducing configuration page clutter. +- Fixed segmentation issue, segmentation now gets passed down to requests. +- Removed the "fall back" functionality where the add-on reads every object of the object list seperately. +- Punched a hole in NGINX to allow the device's own IP to communicate with the API. + + +# 1.0.5 + +10/7/2023 +- segmentation-not-supported error while reading objectlist solved. +- Allowing more internal Home Assistant IP's. + + +# 1.0.4 + +10/7/2023 +- Base image to hassio-addons/addon-base-python image. +- Library versions are more closely guarded now. +- Added segmentation option back in. +- Trying to log abort PDU's. Issue with Priva devices not supporting segmentation. +- Reading objectList last to get most info from the device without getting an error. + + +# 1.0.3 + +29/6/2023 +- EDE files give out correct data through API now. +- Increased sleeping time during startup to give the add-on enough chance to catch all BACnet data. + + +# 1.0.2 + +28/6/2023 +- Fixed an issue where empty websockets would remain in memory. +- CoV is now indefinite, because of lack of lifetime. Siemens PXC4 doesn't work with lifetime = 0. +- Removed excessive logging. + + +# 1.0.1 + +20/6/2023 +- Updated web UI main page to make navigation a little easier. +- Unsubscribing now gets done when the add-on closes. +- All errors now really should be caught instead of dumping tracelogs. +- Updated translations. +- EDE files show up on the web UI now once uploaded. + + +# 1.0.0 + +16/06/2023 +- Rewrote the backbones of the program. Now using BACpypes3 instead of BACpypes. +- Removed certain configuration options that have no effect. +- New API points! +- Subscription page on the web UI now functions like the EDE page where you can add or remove subscriptions easily. +- CIDR notation gets discovered when using "auto" as ip address setting. +- Can configure the rate at which all objects get updated. +- Catching more errors so the logs don't get spammed with trace logs. +- This update _may_ break a thing or two. Please make an Issue on GitHub to get it solved. + + +## 0.2.1 + +01/06/2023 +- Rounding of long float values. It's now rounding at the first decimal. +- LAN IP detection still works automatically, and only shuts down if it's automatic in config. If it's not auto, you can manually write an IP and not shut down. +- Add-on uses image from GitHub now, decreasing install time. +- fixed presentvalue not doing anything for EDE files + + +## 0.2.0 + +01/06/2023 +- Added commissioning API points! +- Now it's possible to load EDE files in the add-on. +- This allows the integration generate placeholder entities in Home Assistant until the real device gets connected to the network. +- EDE files that have been loaded can be deleted. +- A restart of the add-on will remove the EDE files from the add-on. +- Web UI pages allow easy adding and removing. + + +## 0.1.6 + +30/05/2023 +- Bumped Alpine base image to 3.18. +- Python version is now 3.11. +- Packages on GitHub renamed to bacnet-interface instead of null. + + +## 0.1.5 + +11/05/2023 +- Updated web UI page to include Redoc API documentation. +- Viewing API documentation works now. +- API documentation now uses ingress. +- Made loglevel a mandatory configuration. + + +## 0.1.4 + +09/05/2023 +- Updated configuration to include Write Request Priority. +- Updated DOCS to reflect the change. +- We appreciate the feedback!! + + +## 0.1.3 + +12/4/2023 +- Readme updated to include integration. +- NGINX now blocks everything from outside. +- Trying to automatically get the ethernet adapter from the host device. +- FastAPI to include lifetime now instead of on_startup(). + + +## 0.1.2 + +16/02/2023 +- Dutch translations added. +- Log level can be adjusted. Defaults to WARNING now. +- Read request every 60 seconds asking for values that can change, instead of asking for static values. This reduces network traffic. +- WebUI has tooltips now. Buttons or fields should explain what they do. + + +## 0.1.1 + +15/02/2023 +- Bumped up FastAPI to version 0.92.0 for security reasons. +- Restructured S6-overlay processes. One shots for initialisations, longruns for actual processes. +- Discovery enabled for if it's possible to discovery integration in the future. +- Added automatic ethernet adapter detection. Won't work in every case, but it'll help a lot of people out. +- BACnet subscriptions now have a lifetime instead of none. Increases compatability. +- BACnet subscriptions last maximum time and get resubscribed every 60 seconds along with a read request. +- Websockets can handle multiple clients now. +- Configuration of the add-on has been simplified. +- Added some API tests internally. +- API has an endpoint that let's you subscribe now. +- Flask no longer included, FastAPI handles everything now, along with uvicorn. +- WebUI gets updated over websocket. This means values are the same as in the API. +- WebUI can write now as well. +- WebUI has gotten a makeover in general. +- WebUI Subscription page can actually subscribe now. +- This add-on runs on Raspberry Pi 3 as it would on an Intel NUC. This means Raspberry Pi is supported. + + +# 0.1.0 + +10/1/2022 +- API now has more datatypes +- objectIdentifier, statusFlags became list. +- notificationClass added as object as well as a property to access through API. +- Multi State stateText and numberOfStates have been added. +- Attempting to get min and max presentValue's from objects now. +- Altered webUI page to be a little functional. It has 3 buttons you can press to call a service like I Am or Who Is. +- Formatting of code now uses Black and Isort. +- Removed unused packages from Dockerfile: 'nmap' and 'iproute2'. +- Added a read all command, so all devices can be read again on command. +- Bumped version to '0.1.0' as the add-on ready to be used. + + +## 0.0.5 + +02/01/2023 +- Added write functionality through API +- Added multiple API points. You can find them through going to /docs +- Who Is and I Am can be received and sent through host network. This is possible through Home Assistant Supervised +- Add-on is now matching requirements for this project. Error handling etc. will be improved later, as will code optimisations. + + +## 0.0.4 + +21/12/2022 +- Zeroconf removed, not functional for add-on. If this was a core add-on, would be using DHCP. +- FastAPI running on a separate thread +- Created BACnetIOHandler class to serve as BACnet application +- BACnet devices will automatically be subscribed to +- FastAPI program converts the BACnet dictionary it gets from BACnetIOHandler to a bite sized dictionary containing only the essentials for Home Assistant +- API can do get request on /apiv1/json +- Can connect to websocket on ws://ip:port/ws +- Websocket will automatically push updates on Change Of Value +- Changed icon to Bepacom logo + + +## 0.0.3 + +07/11/2022 +- Added FastAPI to function as API in the future +- Changed webserver to Uvicorn +- Changed program to split into multiple files +- BACnet device can be detected +- WebUI can be loaded, it's just an example page now +- API and web UI are split into different paths, /apiv1/ and /webapp/ +- Zeroconf added, unsure if functioning + + +## 0.0.2 + +31/10/2022 +- WhoIsIAmProgram Running +- Nginx set up to work correctly with Flask webserver +- Flask and BACpypes happily working together <3 + + +## 0.0.1 + + 21/10/2022 +- Getting started... diff --git a/bacnetinterface_dev/DOCS.md b/bacnetinterface_dev/DOCS.md new file mode 100644 index 00000000..92d38a60 --- /dev/null +++ b/bacnetinterface_dev/DOCS.md @@ -0,0 +1,112 @@ +# Bepacom EcoPanel BACnet/IP interface + +This add-on is created by Bepacom B.V. for their EcoPanel. + +The goal is to add BACnet functionality to Home Assistant so these devices can be displayed on the dashboard. + +This add-on works on Home Assistant OS as well as Home Assistant Supervised. + + +## Installation + +1. Click the Home Assistant My button below to open the add-on on your Home + Assistant instance. + + [![Open this add-on in your Home Assistant instance.][addon-badge]][addon] + +1. Click the "Install" button to install the add-on. +1. Start the "Bepacom EcoPanel BACnet/IP Interface" add-on. +1. Check the logs of the "Bepacom EcoPanel BACnet/IP Interface" add-on to see if everything went + well. +1. Now your Home Assistant host is a virtual BACnet/IP device! + + +## API Points + +You'll be able to find all API points in the Web UI. All outside access to the API and Web UI is blocked. +Only through Home Assistant the API can be accessed. +This means the integration is allowed to communicate while the rest is not. + +### API V1 + +**Device Identifiers** get written as "device:number", so if a device has an identifier of 100, the notation for API will be "device:100". + +**Object Identifiers** apply the same notation. The object name will be camelCase. An example notation for an AnalogInput 1 would be "analogInput:1". + +**Property Identifiers** also apply camelCase logic. An object identifier will be written as "objectIdentifier". +Fortunately, you only need to write the value for writing properties. + +#### GET + +- /apiv1/json - Return a full list of all device data. +- /apiv1/command/whois - Make the add-on do a Who Is request. +- /apiv1/command/iam - Make the add-on do an I Am request. +- /apiv1/command/readall - Make the add-on read everything. +- /apiv1/commission/ede - Read uploaded EDE files. +- /apiv1/{deviceid} - Retrieve all data from a specific device. +- /apiv1/{deviceid}/{objectid} - Retrieve all data from an object from a specific device. +- /apiv1/{deviceid}/{objectid}/{propertyid} - Retrieve a property value from an object in a specific device. + +#### POST + +- /apiv1/commission/ede - Post EDE files +- /apiv1/{deviceid}/{objectid} - Write data to be written to a BACnet object +- /apiv1/subscribe/{deviceid}/{objectid} - Upload an EDE file + +#### DELETE + +- /apiv1/commission/ede - Remove an EDE file with the corresponding device identifier +- /apiv1/subscribe/{deviceid}/{objectid} - Remove a CoV subscription + + +## Configuration + +**Note**: _Remember to restart the add-on when the configuration is changed._ + +Example add-on configuration: + +```yaml +objectName: EcoPanel +address: 0.0.0.0/24 +objectIdentifier: 420 +defaultPriority: 15 +loglevel: INFO +vendorID: 15 +updateInterval: 60 +``` + +### Option: `objectName` +The Object Name that this device will get. This will be seen by other devices on the BACnet network. + +### Option: `address` +The address of the BACnet/IP interface. +Best is to write the IP of the Ethernet port connected to the BACnet network. Include /24 as not all BACnet devices can be detected without. + +### Option: `objectIdentifier` +The Object Identifier that this device will get. This will be seen by other devices on the BACnet network. **Make sure it's unique in your network!** + +### Option: `defaultPriority` +The priority your write requests get. Low number means high priority. High number means low priority. Recommended to keep at 15 or 16 unless you know what a higher priority can do to your BACnet devices. + +### Option: `loglevel` +The verbosity of the logs in the add-on. Usually WARNING is sufficient. + +### Option: `vendorID` +Identifier of the vendor of the interface. As we don't have an official identifier, put anything you want in here. + +### Option: `updateInterval` +The time after which the interface will try to read all object properties of each detected device again. + + +## Credits + +**Bepacom B.V. Raalte** + + +[![Open this add-on in your Home Assistant instance.][bepacom-badge]][bepacom] + + +[addon-badge]: https://my.home-assistant.io/badges/supervisor_addon.svg +[addon]: https://my.home-assistant.io/redirect/supervisor_addon/?addon=13b6b180_bacnetinterface&repository_url=https%3A%2F%2Fgithub.com%2FGravySeal%2Fbepacom-repo +[bepacom-badge]: https://www.bepacom.nl/wp-content/uploads/2018/09/logo-bepacom-besturingstechniek.jpg +[bepacom]: https://www.bepacom.nl/ diff --git a/bacnetinterface_dev/Dockerfile b/bacnetinterface_dev/Dockerfile new file mode 100644 index 00000000..aa6a4c7a --- /dev/null +++ b/bacnetinterface_dev/Dockerfile @@ -0,0 +1,25 @@ +ARG BUILD_FROM +FROM ${BUILD_FROM} + +COPY requirements.txt /usr/src/requirements.txt + +# Install requirements for add-on +WORKDIR /usr/src +RUN \ + apk add --no-cache \ + nginx \ + python3 \ + py3-pip \ + py3-setuptools \ + py3-wheel \ + && pip3 install --no-cache-dir --upgrade pip \ + 'bacpypes3<=0.0.79' \ + 'fastapi<=0.103.1' \ + 'jinja2<=3.1.2' \ + 'uvicorn<=0.23.2' \ + 'websockets<=11.0.3' \ + 'python-multipart<=0.0.6' + +WORKDIR / + +COPY rootfs / diff --git a/bacnetinterface_dev/README.md b/bacnetinterface_dev/README.md new file mode 100644 index 00000000..d0baa880 --- /dev/null +++ b/bacnetinterface_dev/README.md @@ -0,0 +1,21 @@ +# Bepacom BACnet/IP interface + +_Bepacom BACnet/IP interface. It'll discover BACnet devices on your network. On the WebUI you'll be able to browse detected devices and interact with them!_ + +The accompanying integration can be found on Bepacom's Custom Component Components Repository! + +## [Bepacom Custom Integration Repository](https://github.com/Bepacom-Raalte/bepacom-custom_components) + +If you have any issues, please check the logs and contact the add-on developer. + +![Supports aarch64 Architecture][aarch64-shield] +![Supports amd64 Architecture][amd64-shield] +![Supports armhf Architecture][armhf-shield] +![Supports armv7 Architecture][armv7-shield] +![Supports i386 Architecture][i386-shield] + +[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg +[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg +[armhf-shield]: https://img.shields.io/badge/armhf-yes-green.svg +[armv7-shield]: https://img.shields.io/badge/armv7-yes-green.svg +[i386-shield]: https://img.shields.io/badge/i386-yes-green.svg diff --git a/bacnetinterface_dev/build.yaml b/bacnetinterface_dev/build.yaml new file mode 100644 index 00000000..d2973c3a --- /dev/null +++ b/bacnetinterface_dev/build.yaml @@ -0,0 +1,12 @@ +--- +build_from: + aarch64: ghcr.io/hassio-addons/base-python:11.0.6 + armhf: ghcr.io/hassio-addons/base-python:11.0.6 + armv7: ghcr.io/hassio-addons/base-python:11.0.6 + amd64: ghcr.io/hassio-addons/base-python:11.0.6 + i386: ghcr.io/hassio-addons/base-python:11.0.6 +labels: + org.opencontainers.image.title: "Home Assistant Add-on: Bepacom BACnet/IP interface" + org.opencontainers.image.description: "Bepacom BACnet/IP add-on for Home Assistant." + org.opencontainers.image.source: "https://github.com/Bepacom-Raalte/bepacom-HA-Addons" + org.opencontainers.image.licenses: "Apache License 2.0" diff --git a/bacnetinterface_dev/config.yaml b/bacnetinterface_dev/config.yaml new file mode 100644 index 00000000..6f1c7122 --- /dev/null +++ b/bacnetinterface_dev/config.yaml @@ -0,0 +1,44 @@ +# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config +name: Bepacom EcoPanel BACnet/IP Interface Development Version +version: "1.1.4" +slug: bacnetinterface_dev +description: Bepacom BACnet/IP interface for the Bepacom EcoPanel. Allows BACnet devices to be available to Home Assistant through an API +url: "https://github.com/Bepacom-Raalte/bepacom-HA-Addons/tree/main/bacnetinterface" +arch: + - armhf + - armv7 + - aarch64 + - amd64 + - i386 +init: false +ingress: true +# Mind to use webapp instead of webapp/... This causes ingress to misbehave. +ingress_entry: webapp +host_network: true +panel_icon: mdi:router-wireless-settings +startup: services +map: + - config:rw +ports: + 80/tcp: 80 + 47808/udp: 47808 +ports_description: + 80/tcp: Default webserver port + 47808/udp: BACnet port +options: + objectName: EcoPanel + address: auto + objectIdentifier: 420 + defaultPriority: 15 + updateInterval: 60 + loglevel: WARNING + segmentation: segmentedBoth +schema: + objectName: str + address: str + objectIdentifier: int + defaultPriority: "int(1,16)" + loglevel: list(DEBUG|INFO|WARNING|ERROR|CRITICAL|) + updateInterval: int + vendorID: int? + segmentation: list(segmentedBoth|segmentedTransmit|segmentedReceive|noSegmentation||)? \ No newline at end of file diff --git a/bacnetinterface_dev/icon.png b/bacnetinterface_dev/icon.png new file mode 100644 index 00000000..1853839c Binary files /dev/null and b/bacnetinterface_dev/icon.png differ diff --git a/bacnetinterface_dev/logo.png b/bacnetinterface_dev/logo.png new file mode 100644 index 00000000..113229c8 Binary files /dev/null and b/bacnetinterface_dev/logo.png differ diff --git a/bacnetinterface_dev/requirements.txt b/bacnetinterface_dev/requirements.txt new file mode 100644 index 00000000..2b192c53 --- /dev/null +++ b/bacnetinterface_dev/requirements.txt @@ -0,0 +1,6 @@ +bacpypes3<=0.0.78 +fastapi<=0.100.0 +jinja2<=3.1.2 +uvicorn<=0.22.0 +websockets<=11.0.3 +python-multipart<=0.0.6 \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/apk/repositories b/bacnetinterface_dev/rootfs/etc/apk/repositories new file mode 100644 index 00000000..4a0de5d7 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/apk/repositories @@ -0,0 +1,13 @@ +https://dl-cdn.alpinelinux.org/alpine/v3.15/community +https://dl-cdn.alpinelinux.org/alpine/v3.15/main +https://dl-cdn.alpinelinux.org/alpine/v3.15/releases +https://dl-cdn.alpinelinux.org/alpine/v3.16/community +https://dl-cdn.alpinelinux.org/alpine/v3.16/main +https://dl-cdn.alpinelinux.org/alpine/v3.16/releases +#https://dl-cdn.alpinelinux.org/alpine/latest-stable/community/ +#https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/ +#https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/ +#https://dl-cdn.alpinelinux.org/alpine/edge/community/ +#https://dl-cdn.alpinelinux.org/alpine/edge/main/ +#https://dl-cdn.alpinelinux.org/alpine/edge/releases/ +#https://wheels.home-assistant.io/musllinux/ \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/nginx/nginx.conf b/bacnetinterface_dev/rootfs/etc/nginx/nginx.conf new file mode 100644 index 00000000..f46a4433 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/nginx/nginx.conf @@ -0,0 +1,33 @@ +# Run nginx in foreground. +daemon off; +# This is run inside Docker. +user root; +# Pid storage location. +pid /var/run/nginx.pid; +# Set number of worker processes. +worker_processes 1; +# Write error log to the add-on log +error_log /proc/1/fd/1 debug; + + +events { + worker_connections 10240; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + proxy_read_timeout 1200; + server_tokens off; + + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + include /etc/nginx/servers/*.conf; +} + diff --git a/bacnetinterface_dev/rootfs/etc/nginx/servers/.gitkeep b/bacnetinterface_dev/rootfs/etc/nginx/servers/.gitkeep new file mode 100644 index 00000000..85ad51be --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/nginx/servers/.gitkeep @@ -0,0 +1 @@ +Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/bacnetinterface_dev/rootfs/etc/nginx/templates/ingress.gtpl b/bacnetinterface_dev/rootfs/etc/nginx/templates/ingress.gtpl new file mode 100644 index 00000000..30b13137 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/nginx/templates/ingress.gtpl @@ -0,0 +1,31 @@ + server { + # listen on port + listen 8099; + listen 80; + + allow 172.30.32.0/24; + allow 127.0.0.0/24; + allow {{ .interface }}; + # deny all; + + # forward request to backend + location / { + # send it to upstream + + # Replace header to true origin + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header x-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:7813; + } + + location /ws { + proxy_pass http://127.0.0.1:7813/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header x-Forwarded-For $proxy_add_x_forwarded_for; + } + } \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/dependencies.d/base b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/dependencies.d/base new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/run b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/run new file mode 100644 index 00000000..396dd9d2 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/run @@ -0,0 +1,103 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash + +# ============================================================================== +# Initialize the Interface service +# s6-overlay docs: https://github.com/just-containers/s6-overlay +# ============================================================================== + +declare header +declare objectname +declare address +declare objectIdentifier +declare maxApduLenghtAccepted +declare segmentation +declare vendorID +declare foreignBBMD +declare foreignTTL +declare maxSegmentsAccepted +declare defaultPriority +declare updateInterval + +# Functions + +IPprefix_by_netmask() { + #function returns prefix for given netmask in arg1 + bits=0 + for octet in $(echo $1| sed 's/\./ /g'); do + binbits=$(echo "obase=2; ibase=10; ${octet}"| bc | sed 's/0//g') + let bits+=${#binbits} + done + echo "/${bits}" +} + +# Setting variables + +bashio::log.info "Generating BACpypes.ini" + +readarray -t eth_adapters < <(ifconfig -a | grep -oE '^(enp|eth|eno)[a-z0-9]+') + +{ # Try + for adapter in "${eth_adapters[@]}" + do + ipaddr=$(ifconfig "$adapter" | grep 'inet addr' | cut -d: -f2 | awk '{print $1}') + if [ -n "$ipaddr" ]; then + netmask=$(ifconfig $adapter | grep 'Mask' | cut -d: -f4) + cidr=$(IPprefix_by_netmask $netmask) + break + fi + done +} || { + echo "No suitable ethernet adapters found. You probably won't detect anything now." + ipaddr=$(hostname -i) +} + +header='[BACpypes]' +objectname="objectName: $(bashio::config 'objectName')" + +if [[ $(bashio::config 'address') == "auto" ]]; then + if [ -z "$ipaddr" ]; then + bashio::log.error "All adapters checked but found no suitable choice. Check whether you are connected through ethernet with a BACnet network. If this problem persists, contact the developer." + exit 1 + fi + address="address: $ipaddr$cidr" + echo "Using $adapter as $address" +elif [[ -z "$(bashio::config 'objectName')" ]]; then + echo "Address is empty and didn't detect any suitable devices!" + exit 1 +elif [[ $(bashio::config 'address') == *"/"* ]]; then + address="address: $(bashio::config 'address')" +else + address="address: $(bashio::config 'address')/24" +fi + +objectIdentifier="objectIdentifier: $(bashio::config 'objectIdentifier')" +maxApduLenghtAccepted="maxApduLengthAccepted: $(bashio::config 'maxApduLenghtAccepted' '1476')" +segmentation="segmentation: $(bashio::config 'segmentation' 'segmentedBoth')" + +if [[ -z $(bashio::config 'segmentation' 'segmentedBoth') ]]; then + segmentation="segmentation: segmentedBoth" +else + segmentation="segmentation: $(bashio::config 'segmentation' 'segmentedBoth')" +fi + +vendorID="vendorIdentifier: $(bashio::config 'vendorID' '15')" +foreignBBMD="foreignBBMD: $(bashio::config 'foreignBBMD' '-')" +foreignTTL="foreignTTL: $(bashio::config 'foreignTTL' '255')" +maxSegmentsAccepted="maxSegmentsAccepted: $(bashio::config 'maxSegmentsAccepted' '64')" + +if [[ -z $(bashio::config 'loglevel' 'INFO') ]]; then + loglevel="loglevel: INFO" +else + loglevel="loglevel: $(bashio::config 'loglevel')" +fi + +defaultPriority="defaultPriority: $(bashio::config 'defaultPriority')" +updateInterval="updateInterval: $(bashio::config 'updateInterval')" + +# Generate INI file + +printf '%s\n' "$header" "$objectname" "$address" "$objectIdentifier" "$maxApduLenghtAccepted" "$segmentation" "$vendorID" "$foreignBBMD" "$foreignTTL" "$maxSegmentsAccepted" "$loglevel" "$defaultPriority" "$updateInterval" > /usr/bin/BACpypes.ini +cat /usr/bin/BACpypes.ini + +printf "$(bashio::addon.ingress_url)" > /usr/bin/ingress.ini diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/type b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/type new file mode 100644 index 00000000..3d92b15f --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/type @@ -0,0 +1 @@ +oneshot \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/up b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/up new file mode 100644 index 00000000..7129c830 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-interface/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/init-interface/run \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run new file mode 100644 index 00000000..69369bdc --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run @@ -0,0 +1,31 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash + +# ============================================================================== +# Initialize the NGINX service +# s6-overlay docs: https://github.com/just-containers/s6-overlay +# ============================================================================== + +bashio::log.info "Initialising NGINX" + +readarray -t eth_adapters < <(ifconfig -a | grep -oE '^(enp|eth|eno)[a-z0-9]+') + +bashio::log.info "Initialising NGINX - array read..." + +for adapter in "${eth_adapters[@]}" +do + ipaddr=$(ifconfig "$adapter" | grep 'inet addr' | cut -d: -f2 | awk '{print $1}') + bashio::log.info "Initialising NGINX - cycling through adapters... $ipaddr" + if [ -n "$ipaddr" ]; then + break + fi +done + +# Generate Ingress configuration +bashio::var.json \ + interface "$ipaddr" \ + | tempio \ + -template /etc/nginx/templates/ingress.gtpl \ + -out /etc/nginx/servers/ingress.conf + + diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type new file mode 100644 index 00000000..3d92b15f --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type @@ -0,0 +1 @@ +oneshot \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up new file mode 100644 index 00000000..60ba159c --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/init-nginx/run \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/dependencies.d/init-interface b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/dependencies.d/init-interface new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/finish b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/finish new file mode 100644 index 00000000..3f329bed --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/finish @@ -0,0 +1,28 @@ +#!/command/with-contenv bashio + +# ============================================================================== +# Exit Interface Service +# Take down the S6 supervision tree when Nginx fails +# ============================================================================== + +declare exit_code +readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode + fi + [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt +elif [[ "${exit_code_service}" -ne 0 ]]; then + if [[ "${exit_code_container}" -eq 0 ]]; then + echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode + fi + exec /run/s6/basedir/bin/halt +fi diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/run b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/run new file mode 100644 index 00000000..7e6d37fe --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/run @@ -0,0 +1,12 @@ +#!/command/with-contenv bashio + +# ============================================================================== +# Start the Interface service +# s6-overlay docs: https://github.com/just-containers/s6-overlay +# ============================================================================== + +cp /usr/bin/BACpypes.ini /usr/bin/ingress.ini . + +bashio::log.info "Running interface" + +exec python3 /usr/bin/main.py diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/type b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/type new file mode 100644 index 00000000..1780f9f4 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/interface/type @@ -0,0 +1 @@ +longrun \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/interface b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/interface new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/finish b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/finish new file mode 100644 index 00000000..6f004a1e --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/finish @@ -0,0 +1,26 @@ +#!/command/with-contenv bashio +# ============================================================================== +# Exit NGINX Service +# Take down the S6 supervision tree when Nginx fails +# ============================================================================== +declare exit_code +readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode + fi + [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt +elif [[ "${exit_code_service}" -ne 0 ]]; then + if [[ "${exit_code_container}" -eq 0 ]]; then + echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode + fi + exec /run/s6/basedir/bin/halt +fi diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/run new file mode 100644 index 00000000..c3e2159a --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -0,0 +1,14 @@ +#!/command/with-contenv bashio + +# ============================================================================== +# Start the NGINX service +# s6-overlay docs: https://github.com/just-containers/s6-overlay +# ============================================================================== + +# Wait for Node-RED to become available +bashio::net.wait_for 7813 localhost 300 + +bashio::log.info "Starting NGINX" + +exec nginx + diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/type new file mode 100644 index 00000000..1780f9f4 --- /dev/null +++ b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/nginx/type @@ -0,0 +1 @@ +longrun \ No newline at end of file diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-interface b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-interface new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/interface b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/interface new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/bacnetinterface_dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx new file mode 100644 index 00000000..e69de29b diff --git a/bacnetinterface_dev/rootfs/usr/bin/BACnetIOHandler.py b/bacnetinterface_dev/rootfs/usr/bin/BACnetIOHandler.py new file mode 100644 index 00000000..9d2322aa --- /dev/null +++ b/bacnetinterface_dev/rootfs/usr/bin/BACnetIOHandler.py @@ -0,0 +1,484 @@ +import asyncio +import logging +import traceback +from typing import Any, Dict, TypeVar +from math import isnan, isinf + +from bacpypes3.apdu import (AbortPDU, ConfirmedCOVNotificationRequest, + ErrorPDU, ErrorRejectAbortNack, + ReadPropertyRequest, RejectPDU, SimpleAckPDU, + SubscribeCOVRequest) +from bacpypes3.basetypes import (BinaryPV, DeviceStatus, EngineeringUnits, + ErrorType, EventState, PropertyIdentifier, + Reliability) +from bacpypes3.constructeddata import AnyAtomic +from bacpypes3.errors import * +from bacpypes3.ipv4.app import NormalApplication +from bacpypes3.object import get_vendor_info +from bacpypes3.pdu import Address +from bacpypes3.primitivedata import BitString, ObjectIdentifier, ObjectType +from const import (device_properties_to_read, object_properties_to_read_once, + object_properties_to_read_periodically, + subscribable_objects) + +KeyType = TypeVar("KeyType") + + +class BACnetIOHandler(NormalApplication): + bacnet_device_dict: dict = {} + subscription_tasks: list = [] + update_event: asyncio.Event = asyncio.Event() + startup_complete: asyncio.Event = asyncio.Event() + id_to_object = {} + object_to_id = {} + available_ids = set() + next_id = 1 + default_subscription_lifetime = 28800 + + def __init__(self, *args) -> None: + NormalApplication.__init__(self, *args) + super().i_am() + super().who_is() + self.vendor_info = get_vendor_info(0) + asyncio.get_event_loop().create_task(self.refresh_subscriptions()) + self.startup_complete.set() + logging.debug("Application initialised") + + def deep_update( + self, mapping: Dict[KeyType, Any], *updating_mappings: Dict[KeyType, Any] + ) -> Dict[KeyType, Any]: + for updating_mapping in updating_mappings: + for k, v in updating_mapping.items(): + if ( + k in mapping + and isinstance(mapping[k], dict) + and isinstance(v, dict) + ): + mapping[k] = self.deep_update(mapping[k], v) + else: + mapping[k] = v + self.update_event.set() + logging.debug(f"Updating {updating_mapping}") + return mapping + + def dev_to_addr(self, dev: ObjectIdentifier) -> Address | None: + for instance in self.device_info_cache.instance_cache: + if instance == dev[1]: + return self.device_info_cache.instance_cache[instance].address + return None + + def addr_to_dev(self, addr: Address) -> ObjectIdentifier | None: + for address in self.device_info_cache.address_cache: + if addr == address: + return ObjectIdentifier( + f"device:{self.device_info_cache.address_cache[address].device_instance}" + ) + return None + + def assign_id(self, obj: ObjectIdentifier, dev: ObjectIdentifier) -> int: + """Assign an ID to the given object and return it.""" + if (obj, dev) in self.object_to_id: + # The object already has an ID, return it + return self.object_to_id[(obj, dev)] + + # Assign a new ID to the object + if self.available_ids: + # Use an available ID if there is one + new_id = self.available_ids.pop() + else: + # Assign a new ID if there are no available IDs + new_id = self.next_id + self.next_id += 1 + + self.id_to_object[new_id] = (obj, dev) + self.object_to_id[(obj, dev)] = new_id + return new_id + + def unassign_id(self, obj: ObjectIdentifier, dev: ObjectIdentifier) -> None: + """Remove the ID assignment for the given object.""" + if (obj, dev) not in self.object_to_id: + return + + # Remove the ID assignment for the object and add the ID to the available IDs set + obj_id = self.object_to_id[(obj, dev)] + del self.id_to_object[obj_id] + del self.object_to_id[(obj, dev)] + self.available_ids.add(obj_id) + + async def refresh_subscriptions(self): + while True: + logging.info("Refreshing subscriptions...") + await asyncio.sleep(self.default_subscription_lifetime) + for task in self.subscription_tasks: + await self.create_subscription_task( + device_identifier=task[4], + object_identifier=task[1], + confirmed_notifications=task[2], + lifetime=task[3], + ) + + async def do_WhoIsRequest(self, apdu) -> None: + logging.info(f"Received Who Is Request from {apdu.pduSource}") + await super().do_WhoIsRequest(apdu) + + async def do_IAmRequest(self, apdu) -> None: + logging.info(f"I Am from {apdu.iAmDeviceIdentifier}") + + await super().do_IAmRequest(apdu) + + if apdu.iAmDeviceIdentifier[1] in self.device_info_cache.instance_cache: + logging.warning(f"Device {apdu.iAmDeviceIdentifier} already in cache!") + else: + await self.read_device_props(apdu=apdu) + + await self.read_object_list(device_identifier=apdu.iAmDeviceIdentifier) + + await self.subscribe_object_list(device_identifier=apdu.iAmDeviceIdentifier) + + def dict_updater( + self, + device_identifier: ObjectIdentifier, + object_identifier: ObjectIdentifier, + property_identifier: PropertyIdentifier, + property_value, + ): + if isinstance(property_value, ErrorType): + return + elif isinstance(property_value, float): + if isnan(property_value): + logging.warning(f"Replacing with 0: {device_identifier}, {object_identifier}, {property_identifier}... NaN value: {property_value}") + property_value = 0 + if isinf(property_value): + logging.warning(f"Replacing with 0: {device_identifier}, {object_identifier}, {property_identifier}... Inf value: {property_value}") + property_value = 0 + property_value = round(property_value, 4) + elif isinstance(property_value, AnyAtomic): + return + + if isinstance(property_value, list): + prop_list: list = [] + for val in property_value: + if isinstance(val, ObjectIdentifier): + prop_list.append( + [ + val[0].attr, + val[1], + ] + ) + pass + + if isinstance(property_value, ObjectIdentifier): + self.deep_update( + self.bacnet_device_dict, + { + f"{device_identifier[0]}:{device_identifier[1]}": { + f"{object_identifier[0].attr}:{object_identifier[1]}": { + property_identifier.attr: ( + property_value[0].attr, + property_value[1], + ) + } + } + }, + ) + elif isinstance( + property_value, + EventState | DeviceStatus | EngineeringUnits | Reliability | BinaryPV, + ): + self.deep_update( + self.bacnet_device_dict, + { + f"{device_identifier[0]}:{device_identifier[1]}": { + f"{object_identifier[0].attr}:{object_identifier[1]}": { + property_identifier.attr: property_value.attr, + } + } + }, + ) + else: + self.deep_update( + self.bacnet_device_dict, + { + f"{device_identifier[0]}:{device_identifier[1]}": { + f"{object_identifier[0].attr}:{object_identifier[1]}": { + property_identifier.attr: property_value + } + } + }, + ) + + async def read_device_props(self, apdu) -> bool: + try: # Send readPropertyMultiple and get response + device_identifier = ObjectIdentifier(apdu.iAmDeviceIdentifier) + parameter_list = [device_identifier] + device_properties_to_read + + logging.debug(f"Exploring Device info of {device_identifier}") + + response = await self.read_property_multiple( + address=apdu.pduSource, parameter_list=parameter_list + ) + + except AbortPDU as err: + logging.error( + f"Abort PDU error while reading device properties: {device_identifier}: {err}" + ) + + except ErrorRejectAbortNack as err: + logging.error(f"Nack error: {device_identifier}: {err}") + except AttributeError as err: + logging.error(f"Attribute error: {err}") + else: + for ( + object_identifier, + property_identifier, + property_array_index, + property_value, + ) in response: + self.dict_updater( + device_identifier=device_identifier, + object_identifier=object_identifier, + property_identifier=property_identifier, + property_value=property_value, + ) + + async def read_object_list(self, device_identifier): + """Read Object List of a device.""" + for obj_id in self.bacnet_device_dict[f"device:{device_identifier[1]}"][ + f"device:{device_identifier[1]}" + ]["objectList"]: + if not isinstance(obj_id, ObjectIdentifier): + obj_id = ObjectIdentifier(obj_id) + + if ( + ObjectType(obj_id[0]) == ObjectType("device") + or ObjectType(obj_id[0]) + not in self.vendor_info.registered_object_classes + ): + continue + + parameter_list = [obj_id] + parameter_list.extend(object_properties_to_read_once) + + try: # Send readPropertyMultiple and get response + logging.debug( + f"Reading object {obj_id} of {device_identifier} during read_object_list" + ) + + response = await self.read_property_multiple( + address=self.dev_to_addr(device_identifier), + parameter_list=parameter_list, + ) + except AbortPDU as err: + logging.error( + f"Abort PDU Error while reading object list: {obj_id}: {err}" + ) + return False + + except ErrorRejectAbortNack as err: + logging.error(f"Nack error while reading object list: {obj_id}: {err}") + return False + + except AssertionError as err: + logging.error(f"Assertion error for: {device_identifier}: {obj_id}") + return False + + except AttributeError as err: + logging.error( + f"Attribute error while reading object list: {obj_id}: {err}" + ) + return False + else: + for ( + object_identifier, + property_identifier, + property_array_index, + property_value, + ) in response: + self.dict_updater( + device_identifier=device_identifier, + object_identifier=object_identifier, + property_identifier=property_identifier, + property_value=property_value, + ) + return True + + async def read_objects_periodically(self): + for dev_id in self.bacnet_device_dict: + for obj_id in self.bacnet_device_dict[dev_id]: + if not isinstance(obj_id, ObjectIdentifier): + obj_id = ObjectIdentifier(obj_id) + device_identifier = ObjectIdentifier(dev_id) + + if ( + ObjectType(obj_id[0]) == ObjectType("device") + or ObjectType(obj_id[0]) + not in self.vendor_info.registered_object_classes + ): + continue + + parameter_list = [obj_id] + parameter_list.extend(object_properties_to_read_periodically) + + try: # Send readPropertyMultiple and get response + logging.debug( + f"Reading object {obj_id} of {device_identifier} during read_objects_periodically" + ) + + response = await self.read_property_multiple( + address=self.dev_to_addr(ObjectIdentifier(dev_id)), + parameter_list=parameter_list, + ) + except AbortPDU as err: + logging.error(f"Abort PDU Error: {obj_id}: {err}") + + except ErrorRejectAbortNack as err: + logging.error(f"Nack error: {obj_id}: {err}") + + except AttributeError as err: + logging.error(f"Attribute error: {obj_id}: {err}") + + else: + for ( + object_identifier, + property_identifier, + property_array_index, + property_value, + ) in response: + self.dict_updater( + device_identifier=device_identifier, + object_identifier=object_identifier, + property_identifier=property_identifier, + property_value=property_value, + ) + + async def subscribe_object_list(self, device_identifier): + for object_id in self.bacnet_device_dict[f"device:{device_identifier[1]}"]: + if ObjectIdentifier(object_id)[0] in subscribable_objects: + await self.create_subscription_task( + device_identifier=device_identifier, + object_identifier=ObjectIdentifier(object_id), + confirmed_notifications=True, + lifetime=self.default_subscription_lifetime, + ) + + async def create_subscription_task( + self, + device_identifier: ObjectIdentifier, + object_identifier: ObjectIdentifier, + confirmed_notifications: bool, + lifetime: int | None = None, + ): + if isinstance(object_identifier, str): + object_identifier = ObjectIdentifier(object_identifier) + + if isinstance(device_identifier, str): + device_identifier = ObjectIdentifier(device_identifier) + + subscriber_process_identifier = self.assign_id( + dev=device_identifier, obj=object_identifier + ) + + subscribe_req = SubscribeCOVRequest( + subscriberProcessIdentifier=subscriber_process_identifier, + monitoredObjectIdentifier=object_identifier, + issueConfirmedNotifications=confirmed_notifications, + lifetime=lifetime, + destination=self.dev_to_addr(ObjectIdentifier(device_identifier)), + ) + + try: + response = await self.request(subscribe_req) + logging.debug(response) + + except (ErrorRejectAbortNack, RejectException, AbortException) as error: + logging.error( + f"Error while subscribing to {device_identifier}, {object_identifier}: {error}" + ) + return + + if ( + not [ + subscriber_process_identifier, + object_identifier, + confirmed_notifications, + lifetime, + ObjectIdentifier(device_identifier), + ] + in self.subscription_tasks + ): + self.subscription_tasks.append( + [ + subscriber_process_identifier, + object_identifier, + confirmed_notifications, + lifetime, + ObjectIdentifier(device_identifier), + ] + ) + + async def unsubscribe_COV( + self, subscriber_process_identifier, device_identifier, object_identifier + ): + unsubscribe_cov_request = SubscribeCOVRequest( + subscriberProcessIdentifier=subscriber_process_identifier, + monitoredObjectIdentifier=object_identifier, + ) + unsubscribe_cov_request.pduDestination = self.dev_to_addr(device_identifier) + # send the request, wait for the response + response = await self.request(unsubscribe_cov_request) + + if not isinstance(response, SimpleAckPDU): + return False + + for subscription in self.subscription_tasks: + if ( + subscription[0] == subscriber_process_identifier + and subscription[1] == object_identifier + and subscription[4] == device_identifier + ): + self.unassign_id(obj=subscription[1], dev=subscription[4]) + del self.subscription_tasks[self.subscription_tasks.index(subscription)] + return + + async def end_subscription_tasks(self): + while self.subscription_tasks: + for subscription in self.subscription_tasks: + await self.unsubscribe_COV( + subscriber_process_identifier=subscription[0], + device_identifier=subscription[4], + object_identifier=subscription[1], + ) + + async def do_ConfirmedCOVNotificationRequest( + self, apdu: ConfirmedCOVNotificationRequest + ) -> None: + # await super().do_ConfirmedCOVNotificationRequest(apdu) + + for value in apdu.listOfValues: + vendor_info = get_vendor_info(0) + object_class = vendor_info.get_object_class( + apdu.monitoredObjectIdentifier[0] + ) + property_type = object_class.get_property_type(value.propertyIdentifier) + property_value = value.value.cast_out(property_type) + + self.dict_updater( + device_identifier=apdu.initiatingDeviceIdentifier, + object_identifier=apdu.monitoredObjectIdentifier, + property_identifier=value.propertyIdentifier, + property_value=property_value, + ) + + # success + resp = SimpleAckPDU(context=apdu) + + # return the result + await self.response(resp) + + async def do_ReadPropertyRequest(self, apdu: ReadPropertyRequest) -> None: + try: + await super().do_ReadPropertyRequest(apdu) + except (Exception, AttributeError) as err: + logging.error( + f"{self.addr_to_dev(apdu.pduSource)} tried to read {apdu.objectIdentifier} {apdu.propertyIdentifier}: {err}" + ) diff --git a/bacnetinterface_dev/rootfs/usr/bin/const.py b/bacnetinterface_dev/rootfs/usr/bin/const.py new file mode 100644 index 00000000..1c46e77a --- /dev/null +++ b/bacnetinterface_dev/rootfs/usr/bin/const.py @@ -0,0 +1,80 @@ +from bacpypes3.basetypes import ObjectType, PropertyIdentifier + +device_properties_to_read: list = [ + PropertyIdentifier("objectIdentifier"), + PropertyIdentifier("objectType"), + PropertyIdentifier("objectName"), + PropertyIdentifier("systemStatus"), + PropertyIdentifier("vendorName"), + PropertyIdentifier("vendorIdentifier"), + PropertyIdentifier("description"), + PropertyIdentifier("modelName"), + PropertyIdentifier("firmwareRevision"), + PropertyIdentifier("applicationSoftwareVersion"), + PropertyIdentifier("protocolVersion"), + PropertyIdentifier("protocolRevision"), + PropertyIdentifier("protocolServicesSupported"), + PropertyIdentifier("protocolObjectTypesSupported"), + PropertyIdentifier("segmentationSupported"), + PropertyIdentifier("apduTimeout"), + PropertyIdentifier("numberOfApduRetries"), + PropertyIdentifier("databaseRevision"), + PropertyIdentifier("segmentationSupported"), + PropertyIdentifier("maxApduLengthAccepted"), + PropertyIdentifier("maxSegmentsAccepted"), + PropertyIdentifier("objectList"), +] + +object_properties_to_read_once: list = [ + PropertyIdentifier("objectIdentifier"), + PropertyIdentifier("objectType"), + PropertyIdentifier("objectName"), + PropertyIdentifier("description"), + PropertyIdentifier("presentValue"), + PropertyIdentifier("statusFlags"), + PropertyIdentifier("outOfService"), + PropertyIdentifier("units"), + PropertyIdentifier("eventState"), + PropertyIdentifier("reliability"), + PropertyIdentifier("covIncrement"), + PropertyIdentifier("stateText"), + PropertyIdentifier("numberOfStates"), + PropertyIdentifier("notificationClass"), + PropertyIdentifier("minPresValue"), + PropertyIdentifier("maxPresValue"), + PropertyIdentifier("activeText"), + PropertyIdentifier("inactiveText"), + PropertyIdentifier("polarity"), +] + +object_properties_to_read_periodically: list = [ + PropertyIdentifier("presentValue"), + PropertyIdentifier("statusFlags"), + PropertyIdentifier("outOfService"), + PropertyIdentifier("eventState"), + PropertyIdentifier("reliability"), + PropertyIdentifier("covIncrement"), + PropertyIdentifier("notificationClass"), +] + +subscribable_objects: list = [ + ObjectType("accumulator"), + ObjectType("analogValue"), + ObjectType("analogInput"), + ObjectType("analogOutput"), + ObjectType("binaryValue"), + ObjectType("binaryInput"), + ObjectType("binaryOutput"), + ObjectType("multiStateValue"), + ObjectType("multiStateInput"), + ObjectType("multiStateOutput"), + ObjectType("alertEnrollment"), + ObjectType("eventEnrollment"), + ObjectType("integerValue"), + ObjectType("calendar"), + ObjectType("pulseConverter"), + ObjectType("program"), + ObjectType("largeAnalogValue"), + ObjectType("positiveIntegerValue"), + ObjectType("lightingOutput"), +] diff --git a/bacnetinterface_dev/rootfs/usr/bin/main.py b/bacnetinterface_dev/rootfs/usr/bin/main.py new file mode 100644 index 00000000..bd39cf6c --- /dev/null +++ b/bacnetinterface_dev/rootfs/usr/bin/main.py @@ -0,0 +1,239 @@ +"""Main script for EcoPanel BACnet add-on.""" + +import asyncio +import configparser +import json +import logging +from typing import TypeVar + +import uvicorn +import webAPI +from BACnetIOHandler import BACnetIOHandler +from bacpypes3.argparse import INIArgumentParser +from bacpypes3.basetypes import Segmentation +from bacpypes3.ipv4.app import Application +from bacpypes3.local.device import DeviceObject +from bacpypes3.pdu import Address, IPv4Address +from bacpypes3.primitivedata import ObjectIdentifier +from webAPI import app as fastapi_app + +KeyType = TypeVar("KeyType") + + +def exception_handler(loop, context): + """Handle uncaught exceptions""" + try: + logging.error(f'An error occurred: {context["exception"]}') + except: + logging.error("Tried to log error, but something went horribly wrong!!!") + + +async def updater_task(app: Application, interval: int, event: asyncio.Event) -> None: + """Task to handle periodic updates to the BACnet dictionary""" + try: + while True: + try: + await asyncio.wait_for(event.wait(), timeout=interval) + event.clear() + except asyncio.TimeoutError: + await app.read_objects_periodically() + + except asyncio.CancelledError as err: + logging.warning(f"Updater task cancelled: {err}") + + +async def writer_task(app: Application, write_queue: asyncio.Queue) -> None: + """Task to handle the write queue""" + try: + global default_write_prio + while True: + queue_result = await write_queue.get() + device_id = queue_result[0] + object_id = queue_result[1] + property_id = queue_result[2] + property_val = queue_result[3] + array_index = queue_result[4] + priority = queue_result[5] + + if queue_result[5] is None: + queue_result[5] = default_write_prio + await app.write_property( + address=app.dev_to_addr(device_id), + objid=object_id, + prop=property_id, + value=property_val, + array_index=array_index, + priority=priority, + ) + read = await app.read_property( + address=app.dev_to_addr(device_id), + objid=object_id, + prop=property_id, + array_index=array_index, + ) + logging.info(f"Write result: {read}") + + app.dict_updater( + device_identifier=device_id, + object_identifier=object_id, + property_identifier=property_id, + property_value=property_val, + ) + + except Exception as err: + logging.error(f" Writer task error: {err}") + except asyncio.CancelledError as err: + logging.warning(f"Writer task cancelled: {err}") + + +async def subscribe_handler_task(app: Application, sub_queue: asyncio.Queue) -> None: + """Task to handle the subscribe queue""" + try: + while True: + queue_result = await sub_queue.get() + device_identifier = queue_result[0] + object_identifier = queue_result[1] + notifications = queue_result[2] + lifetime = queue_result[3] + + for task in app.subscription_tasks: + if task[1] == object_identifier and task[4] == device_identifier: + logging.error( + f"Subscription for {device_identifier}, {object_identifier} already exists" + ) + break + else: + await app.create_subscription_task( + device_identifier=device_identifier, + object_identifier=object_identifier, + confirmed_notifications=notifications, + lifetime=lifetime, + ) + + except asyncio.CancelledError as err: + logging.warning(f"Subscribe task cancelled: {err}") + + +async def unsubscribe_handler_task( + app: Application, unsub_queue: asyncio.Queue +) -> None: + """Task to handle the unsubscribe queue""" + try: + while True: + queue_result = await unsub_queue.get() + device_identifier = queue_result[0] + object_identifier = queue_result[1] + + for task in app.subscription_tasks: + if task[1] == object_identifier and task[4] == device_identifier: + await app.unsubscribe_COV( + subscriber_process_identifier=task[0], + device_identifier=task[4], + object_identifier=task[1], + ) + break + else: + logging.error( + f"Subscription task '{device_identifier}, {object_identifier}' does not exist" + ) + + except asyncio.CancelledError as err: + logging.warning(f"Unsubscribe task cancelled: {err}") + + +async def main(): + """Main function of the application.""" + + loop = asyncio.get_event_loop() + + loop.set_exception_handler(exception_handler) + + config = configparser.ConfigParser() + + config.read("/usr/bin/BACpypes.ini") + + global default_write_prio + + default_write_prio = config.get("BACpypes", "defaultPriority") + + loglevel = config.get("BACpypes", "loglevel") + + logging.basicConfig(format="%(levelname)s: %(message)s", level=loglevel) + + ipv4_address = IPv4Address(config.get("BACpypes", "address")) + + this_device = DeviceObject( + objectIdentifier=ObjectIdentifier( + f"device,{config.get('BACpypes', 'objectIdentifier')}" + ), + objectName=config.get("BACpypes", "objectName"), + description="BACnet Add-on for Home Assistant", + vendorIdentifier=int(config.get("BACpypes", "vendorIdentifier")), + segmentationSupported=Segmentation(config.get("BACpypes", "segmentation")), + maxApduLengthAccepted=int(config.get("BACpypes", "maxApduLengthAccepted")), + maxSegmentsAccepted=int(config.get("BACpypes", "maxSegmentsAccepted")), + ) + + app = BACnetIOHandler(this_device, ipv4_address) + + app.asap.maxApduLengthAccepted = int( + config.get("BACpypes", "maxApduLengthAccepted") + ) + + app.asap.segmentationSupported = Segmentation( + config.get("BACpypes", "segmentation") + ) + + app.asap.maxSegmentsAccepted = int(config.get("BACpypes", "maxSegmentsAccepted")) + + update_task = asyncio.create_task( + updater_task( + app=app, + interval=int(config.get("BACpypes", "updateInterval")), + event=webAPI.events.read_event, + ) + ) + + write_task = asyncio.create_task( + writer_task(app=app, write_queue=webAPI.events.write_queue) + ) + + sub_task = asyncio.create_task( + subscribe_handler_task(app=app, sub_queue=webAPI.events.sub_queue) + ) + + unsub_task = asyncio.create_task( + unsubscribe_handler_task(app=app, unsub_queue=webAPI.events.unsub_queue) + ) + + webAPI.sub_list = app.subscription_tasks + webAPI.bacnet_device_dict = app.bacnet_device_dict + webAPI.who_is_func = app.who_is + webAPI.i_am_func = app.i_am + webAPI.events.val_updated_event = app.update_event + webAPI.events.startup_complete_event = app.startup_complete + + if loglevel == "DEBUG": + uvilog = "info" + else: + uvilog = loglevel.lower() + + config = uvicorn.Config( + app=fastapi_app, host="127.0.0.1", port=7813, log_level=uvilog + ) + + server = uvicorn.Server(config) + + await server.serve() + + if app: + update_task.cancel() + write_task.cancel() + sub_task.cancel() + unsub_task.cancel() + await app.end_subscription_tasks() + app.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bacnetinterface_dev/rootfs/usr/bin/static/css/styles.css b/bacnetinterface_dev/rootfs/usr/bin/static/css/styles.css new file mode 100644 index 00000000..cd2944da --- /dev/null +++ b/bacnetinterface_dev/rootfs/usr/bin/static/css/styles.css @@ -0,0 +1,230 @@ +body { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +.parent { + display: grid; + grid-template-columns: 0.8fr repeat(4, 1fr); + grid-template-rows: 0.8fr 0.2fr repeat(3, 1fr); + grid-column-gap: 0px; + grid-row-gap: 0px; + width: 100vw; + height: 100vh; + background-color: white; + text-align: center; +} + +.div1 { + grid-area: 1 / 1 / 2 / 6; + border: 2px solid #f60000; +} + + .div1 h1 { + } + +.div2 { + border: 2px solid #f60000; + grid-area: 2 / 1 / 3 / 6; + background-color: #f60000; + border-bottom-color: white; +} + + .div2 a { + float: left; + display: block; + color: white; + margin-top: 0; + padding-left: 10px; + padding-right: 10px; + height: 100%; + line-height: 300%; + width: 12%; + } + + .div2 a:hover { + background-color: white; + color: #f60000; + } + +.div3 { + border: 2px solid #f60000; + grid-area: 3 / 1 / 6 / 2; +} + +.div4 { + border: 2px solid #f60000; + grid-area: 3 / 2 / 6 / 5; + align-items: center; + text-align: center; + overflow-y: scroll; + scrollbar-width: none; +} + + .div4 label { + padding: 10px; + width: 100%; + font-size: 24px; + display: block; + } + + .div4 span { + padding: 10px; + width: 100%; + font-size: 16px; + display: block; + } + +.div5 { + border: 2px solid #f60000; + grid-area: 3 / 5 / 6 / 6; +} + + .div5 form { + border: 0px; + border-radius: 2px; + } + + .div5 select { + padding: 20px; + width: 100%; + background-color: white; + color: #f60000; + font-size: 16px; + } + + .div5 select:hover { + color: black; + } + + .div5 label { + padding: 10px; + width: 100%; + background-color: #f60000; + color: white; + font-size: 16px; + display: block; + } + + .div5 input[type=text] { + padding: 20px; + width: 100%; + background-color: white; + color: #f60000; + font-size: 16px; + } + + .div5 input[type=file] { + padding: 20px; + width: 100%; + background-color: #f60000; + color: white; + font-size: 16px; + } + + .div5 input[type=button] { + padding: 20px; + width: 100%; + border: none; + background-color: #f60000; + color: white; + font-size: 16px; + cursor: pointer; + } + + .div5 input[type=button]:hover { + background-color: white; + color: #f60000 + } + +table, th, td { + border: 1px solid black; + border-collapse: collapse; +} + +th, td { + padding: 5px; +} + +th { + text-align: left; +} + +.button { + padding: 20px; + width: 100%; + border: none; + background-color: #f60000; + color: white; + font-size: 16px; + cursor: pointer; +} + + .button:hover { + background-color: white; + color: #f60000 + } + +.selectable-label { + display: inline-block; + cursor: pointer; +} + + .selectable-label input[type="checkbox"] { + display: none; + padding: 10px; + } + + .selectable-label a { + display: inline-block; + width: 32%; + } + + .selectable-label input[type="checkbox"]:checked + span { + background-color: #f60000; + color: white; + padding: 10px; + border-radius: 25px; + } + +.object-tree, .properties { + /* CSS styles for the elements with class 'object-tree' */ + display: none; + padding: 10px; +} + + .object-tree label { + padding: 10px; + font-size: 20px; + } + +.device-tree input[type="checkbox"]:checked + label { + background-color: #f60000; + color: white; + border-radius: 25px; +} + +.device-tree input[type="checkbox"] { + display: none; + padding: 10px; +} + +.device-tree label { + cursor: pointer; + width: 98%; + margin-left: 1%; + margin-top: 10px; +} + +.properties label { + padding: 10px; + font-size: 16px; + margin: 0; +} diff --git a/bacnetinterface_dev/rootfs/usr/bin/templates/ede.html b/bacnetinterface_dev/rootfs/usr/bin/templates/ede.html new file mode 100644 index 00000000..3d1bbe11 --- /dev/null +++ b/bacnetinterface_dev/rootfs/usr/bin/templates/ede.html @@ -0,0 +1,142 @@ + + +
+ + +