Skip to content

Instantly share code, notes, and snippets.

@dechamps
Last active April 19, 2024 02:36
Show Gist options
  • Save dechamps/f1e5b181501f811ec7b679ccfd63a754 to your computer and use it in GitHub Desktop.
Save dechamps/f1e5b181501f811ec7b679ccfd63a754 to your computer and use it in GitHub Desktop.
A Prometheus exporter for BACnet device properties using the BAC0 library.
#!/usr/bin/python3 -u
import argparse
argument_parser = argparse.ArgumentParser('Run the HVAC BAC0 server')
argument_parser.add_argument('--local-address', help='local IP address to bind to', required=True)
argument_parser.add_argument('--local-port', help='UDP port to bind to')
argument_parser.add_argument('--bbmd-address', help='IP:port of the BACnet BBMD to register against, if any')
argument_parser.add_argument('--bbmd-ttl', help='TTL of the BACnet foreign device registration, in seconds', type=int)
argument_parser.add_argument('--bacnet-network', help='BACnet network to discover devices on; can be specified multiple times', type=int, action='append', required=True)
argument_parser.add_argument('--prometheus-port', help='Serve Prometheus metrics on this port', type=int)
#argument_parser.add_argument('--trend', help='Name of a point to trend in the form "DeviceName/PropertyName"; can be specified multiple times', action='append')
args = argument_parser.parse_args()
import BAC0
import select
import sys
#import bacpypes.consolelogging
#import logging
# Log low-level BACnet messages
#for loggerName in logging.Logger.manager.loggerDict.keys():
# bacpypes.consolelogging.ConsoleLogHandler(loggerName)
# Log BAC0 debug messages
#BAC0.log_level(log_file='critical', stderr='debug', stdout='critical')
# Log error information for HTTP 500s
#bacpypes.consolelogging.ConsoleLogHandler('tornado.application')
# Debug the plot renderer
#bacpypes.consolelogging.ConsoleLogHandler('BAC0_Root.BAC0.web.BokehRenderer.DynamicPlotHandler')
flatten = lambda l: [item for sublist in l for item in sublist]
# We use the lite version because there is no point in spinning up the HTTP server - the web app (Flask/Bokeh) is a giant mess that has localhost restrictions hardcoded and is virtually impossible to proxy in a reasonable way.
bacnet = BAC0.lite(ip=args.local_address, port=args.local_port, bbmdAddress=args.bbmd_address, bbmdTTL=args.bbmd_ttl)
# We provide a history_size otherwise BAC0 will keep history forever, leading to unbounded memory usage and performance degradation.
devices = [BAC0.device(device[0], device[1], bacnet, history_size=10, poll=30) for device in bacnet.discover(networks=args.bacnet_network)]
if not devices:
raise Exception('Could not find any devices')
#trend_points = filter(lambda point: '%s/%s' % (point.properties.device.properties.name, point.properties.name) in args.trend, flatten([device.points for device in devices]))
#for trend_point in trend_points:
# print('Trending "%s" on device "%s"' % (trend_point.properties.name, trend_point.properties.device.properties.name), file=sys.stderr)
# bacnet.add_trend(trend_point)
if args.prometheus_port is not None:
import prometheus_client
class PrometheusCollector:
def collect(self):
metrics = {}
for point in flatten([device.points for device in devices]):
def add_to_metrics(unit, value, documentation):
# Sometimes BAC0 sets a property value to ''. No idea why.
try:
value = float(value)
except ValueError:
return
if unit is None:
unit = 'noUnit'
if unit not in metrics:
metrics[unit] = prometheus_client.metrics_core.GaugeMetricFamily('bacnet_' + unit, documentation, labels=['device', 'name'], unit=unit)
metrics[unit].add_metric([point.properties.device.properties.name, point.properties.name], value)
if isinstance(point, BAC0.core.devices.Points.NumericPoint):
add_to_metrics(point.properties.units_state, point.lastValue, 'Present Value of BACnet objects with a %s unit' % point.properties.units_state)
if isinstance(point, BAC0.core.devices.Points.BooleanPoint):
add_to_metrics('binary', 1 if point.lastValue == 'active' else 0, 'Present Value of BACnet binary objects')
if isinstance(point, BAC0.core.devices.Points.EnumPoint):
add_to_metrics('multistate', str(point.lastValue).split(':', 1)[0], 'Present Value of BACnet multi-state objects')
return metrics.values()
prometheus_client.REGISTRY.register(PrometheusCollector())
prometheus_client.start_http_server(args.prometheus_port)
print("BAC0 exporter ready", file=sys.stderr)
while True:
select.select([], [], [])
@dechamps
Copy link
Author

It's 2 years old but I'm still using it every day :)

Should this just be set to any known unoccupied port and the target set to match?

Yes.

it has not yet finished the discovery so i am just unsure if this port is not open until after the discovery or if i simply misunderstood what the intent was there

The port will open after the discovery is finished.

Here's some sample output of a successful startup if that helps:

2021-07-04 11:10:25,127 - INFO    | Starting BAC0 version 20.11.21 (Lite)
2021-07-04 11:10:25,127 - INFO    | Use BAC0.log_level to adjust verbosity of the app.
2021-07-04 11:10:25,127 - INFO    | Ex. BAC0.log_level('silence') or BAC0.log_level('error')
2021-07-04 11:10:25,127 - INFO    | Starting TaskManager
2021-07-04 11:10:25,128 - INFO    | Using ip : 10.173.7.2:47809
2021-07-04 11:10:25,128 - INFO    | Starting app...
2021-07-04 11:10:25,129 - INFO    | BAC0 started
2021-07-04 11:10:25,129 - INFO    | Registered as Foreign Device
2021-07-04 11:10:25,130 - INFO    | Update Local COV Task started
2021-07-04 11:10:27,134 - INFO    | 10.173.6.2 router to [2006]
2021-07-04 11:10:27,136 - INFO    | 10.173.8.2 router to [1738]
2021-07-04 11:10:29,133 - INFO    | Addr : 10.173.8.2
2021-07-04 11:10:31,134 - INFO    | Found those networks : {0, 1738, 2006}
2021-07-04 11:10:31,134 - INFO    | Discovering network 2006
2021-07-04 11:10:34,136 - INFO    | Changing device state to DeviceDisconnected'>
2021-07-04 11:10:34,145 - INFO    | Changing device state to RPMDeviceConnected'>
2021-07-04 11:10:34,159 - INFO    | Device 2006:[20-06] found... building points list
2021-07-04 11:10:34,218 - INFO    | Ready!
2021-07-04 11:10:34,218 - INFO    | Device defined for normal polling with a delay of 30sec
2021-07-04 11:10:34,218 - INFO    | Polling started, values read every 30 seconds
2021-07-04 11:10:34,219 - INFO    | Changing device state to DeviceDisconnected'>
2021-07-04 11:10:34,233 - INFO    | Changing device state to RPMDeviceConnected'>
2021-07-04 11:10:34,254 - INFO    | Device 40284:[UFH-20.06] found... building points list
2021-07-04 11:10:35,911 - INFO    | Ready!
2021-07-04 11:10:35,911 - INFO    | Device defined for normal polling with a delay of 30sec
2021-07-04 11:10:35,911 - INFO    | Polling started, values read every 30 seconds
2021-07-04 11:10:35,911 - INFO    | Changing device state to DeviceDisconnected'>
2021-07-04 11:10:35,933 - INFO    | Changing device state to RPMDeviceConnected'>
2021-07-04 11:10:35,954 - INFO    | Device 300280:[MULTIFCU-20.06-280] found... building points list
2021-07-04 11:10:38,141 - INFO    | Ready!
2021-07-04 11:10:38,141 - INFO    | Device defined for normal polling with a delay of 30sec
2021-07-04 11:10:38,141 - INFO    | Polling started, values read every 30 seconds
2021-07-04 11:10:38,141 - INFO    | Changing device state to DeviceDisconnected'>
2021-07-04 11:10:38,178 - INFO    | Changing device state to RPMDeviceConnected'>
2021-07-04 11:10:38,199 - INFO    | Device 300279:[UNIFCU-20.06-279] found... building points list
2021-07-04 11:10:39,929 - INFO    | Ready!
2021-07-04 11:10:39,929 - INFO    | Device defined for normal polling with a delay of 30sec
2021-07-04 11:10:39,929 - INFO    | Polling started, values read every 30 seconds
BAC0 exporter ready

The port should be open once the BAC0 exporter ready message appears.

@rusirius84
Copy link

Fantastic. I will admit i did not expect a response that fast! I was just being impatient i suppose, everything pointed at it would be open once complete but i just kept stopping it second guessing myself. I will let you know my findings if you are interested. I am very curious to see how this goes. Would be curious to know what it is you use this for / why you developed it in the first place. I am working with some older building automation systems that just have a TON of BACNET data locked up in proprietary systems and i am looking for a way to free that all up to be able to make more use of it. Thanks again.

@dechamps
Copy link
Author

I live in a brand new flat with a fairly sophisticated AC/heating system that happens to be running over BACnet. I developed this tool to monitor and graph the hell out of it, because why not! I've been using it continuously since then (with Grafana dashboards) and it works quite well - perhaps the only real limitation is that it will only discover devices once at startup, it will not spontaneously monitor new devices that come online as the service is running. I guess that could be fixed though, if one is motivated enough.

@timax4life
Copy link

Hello Etienne,

My boilercontrol is BACnet IP and I would like to use Grafana to monitor some temperature values. I know my Bacnet, but not Grafana. Can you give me a starting point? Is there a way to use a raspberry pi?

Regards,

Frank

@dechamps
Copy link
Author

dechamps commented Apr 5, 2022

The basic idea is to set up Prometheus to scrape and store metrics from the bac0 exporter, and then set up Grafana and point it to the Prometheus instance. You can set up Prometheus first, use its basic query & graphing features (it has a rudimentary web interface) to verify it works, and then set up Grafana to get nice user-facing graphs and dashboards. Prometheus and Grafana are well-documented, but they are not trivial pieces of software; they require some time investment to set up and to understand how they work and what they offer.

@b169d127
Copy link

Does this exporter work on Windows?
Please let me know if it works correctly.

OS: Win11 Pro
Python: 3.10.5
BAC0:21.12.3

but
OSError:[WinError 10022] comes back

2022-07-29_170830
2022-07-29_171629
.

@dechamps
Copy link
Author

@b169d127 I use it on Linux. I have no idea if it can work on Windows. Looking at the specific error you have, I get the impression Windows doesn't like select() calls with no FDs. You might be able to get past the problem by changing the select.select([], [], []) call on the last line with anything else that sleeps forever, for example:

while True:
  time.sleep(86400)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment