Free tool
bacnet-sweep — read-only BACnet/IP discovery from a laptop
bacnet-sweep is a free, read-only BACnet/IP discovery tool: it broadcasts a Who-Is, tables every device that answers, and dumps a named device's object list — object name, present value, units — to a terminal table or to CSV. One Python file, standard library only, and no code path in it that writes anything to anything.
- BACnet/IP
- Who-Is / I-Am
- Object list
- CSV
- Read-only
- Python 3
Install and run
There is no install. One file, standard library only — no pip, no
bacpypes, no virtual environment, nothing to install beyond the Python
already on the machine, 3.8 or newer. It was written and run here on
Python 3.12, which is the only version it has actually been executed on; the
3.8 floor comes from reading the file rather than from running it.
# download it next to wherever you are working
curl -O https://plantroomlabs.com/tools/bacnet-sweep.py
# who is out there? broadcasts a Who-Is and listens for I-Am replies
python3 bacnet-sweep.py discover
# what does device 1201 at 10.20.30.41 have on it?
python3 bacnet-sweep.py points 10.20.30.41 1201
# the same thing, as a file you can send to somebody
python3 bacnet-sweep.py points 10.20.30.41 1201 --csv > ahu-01.csv
It binds UDP 47808, which needs no special privilege — the port is above
1024, so there is nothing to elevate. What actually stops a sweep on a laptop is
something else on the same machine already holding that port: Workbench, a running
station, another BACnet stack, or a second copy of this. Pass
--local-port 47809 and it works, because devices reply to whatever port
the request came from. The tool diagnoses exactly that case and names the flag:
Real output, from the tool's own test fixture on 127.0.0.1 — not from a site:
$ python3 bacnet-sweep.py discover --broadcast 127.0.0.1 --port 48570 # port held by the fixture
bacnet-sweep: UDP port 48570 on 127.0.0.1 is already in use.
Something else on this machine holds the BACnet port — Workbench, a
station, a BACnet stack, or another copy of this tool. Devices reply to
whatever source port we send from, so `--local-port 48571` normally just
works; only broadcast I-Am replies aimed at 48570 would be missed.
[exit 2]
The machine's own firewall still has to allow UDP back in. A sweep that reports nothing at all on a network you know has devices on it is nearly always the firewall, the port, or the wrong broadcast address rather than an empty network — and a silent sweep prints those causes in order of likelihood instead of an empty table.
What you get
From the sweep: one row per device that answered, keyed by device instance number, with the address it answered from, the vendor identifier as a number, the largest message the device will accept and whether it supports segmentation. Those last two are a prediction rather than trivia: they are how the sweep can tell you in advance that a device's point list will have to be read the slow way.
Two devices claiming the same instance number are both listed and both flagged, rather than one of them quietly winning. A device answering with instance 4194303 is named as one that was never given an instance number. A device whose I-Am arrived through a BACnet router is listed and marked as not readable from here, which is honest about the thing most likely to waste your afternoon.
The vendor column is a number, not a name: the ASHRAE vendor list is not shipped with the file, and the output says so rather than guessing.
Real output, from the fake BACnet device that ships with the tool's test suite, running on 127.0.0.1. It is not a site and there is no real device anywhere in it:
$ python3 bacnet-sweep.py discover --broadcast 127.0.0.1
Who-Is from 127.0.0.1:51655 -> 127.0.0.1:48570, listening 1.0s
device address vendor max-apdu segmentation object-name model-name note
------ --------------- ------ -------- --------------- --------------- ---------- ----
260001 127.0.0.1:48570 999 1476 no-segmentation PL-Fixture-AHU1 FX-STUB-1
1 device(s) answered. Vendor ids are numbers assigned by ASHRAE; this tool does not ship the vendor-id list.
[exit 0]
From the dump: the device's object list, one row per object — object type and instance, object name, present value, engineering units, and a note column carrying whatever the device refused to answer. Five columns, to a terminal table or to CSV on standard output. The CSV is the format that matters, because it is the thing you paste into a point schedule, diff against last year's, or hand to whoever asked whether that meter is on the system.
Where an object list will not fit in one message, it is read element by element instead of being given up on, and the output says that it was. The footer says how many properties were read, how far apart, and that every value is a snapshot at its own instant rather than a synchronised sample — which matters the moment anyone starts reasoning about two numbers together.
Real output, same fixture, same loopback address:
$ python3 bacnet-sweep.py points 127.0.0.1 260001
Reading object-list of device:260001 at 127.0.0.1:48570
done in 1.1s
object object-name present-value units note
-------------------- ------------------- ------------- ------------------ --------------------------------------------------------------------------------------------
device:260001 PL-Fixture-AHU1 present-value: Error: property / unknown-property
analog-input:1 AHU1_SaTemp 18.6 degrees-celsius
analog-input:2 AHU1_SaStaticPress 245 pascals
analog-input:3 AHU1_RmCo2 612 parts-per-million
analog-input:4 AHU1_RaTemp_F 65.3 degrees-fahrenheit
analog-value:1 AHU1_FanSpeedCmd 62.5 percent
analog-value:2 AHU1_ChwFlow 1.85 liters-per-second
analog-value:3 AHU1_HxPressDrop 34.2 kilopascals
analog-value:4 AHU1_FanPower 2.4 kilowatts
analog-value:5 AHU1_PidOutRaw 0.62 no-units
binary-value:1 AHU1_FanRun active (binary)
multi-state-value:1 AHU1_OccMode 2 units: Error: property / unknown-property
integer-value:1 AHU1_FrostStatDelay -17 seconds
large-analog-value:1 AHU1_ElecMeterTotal 1234567.89 kilowatt-hours
octet-string-value:1 AHU1_LastRawFrame 0102deadbeef units: Error: property / unknown-property
schedule:1 AHU1_OccSchedule 1 units: Error: property / unknown-property
notification-class:1 AHU1_Alarms present-value: Error: property / unknown-property; units: Error: property / unknown-property
trend-log:1 AHU1_SaTemp_Log present-value: Error: property / unknown-property; units: Error: property / unknown-property
18 object(s) on device:260001 at 127.0.0.1. 6 row(s) carry a note: a note is the device's own answer, not a failure of the sweep — objects legitimately lack present-value or units.
53 properties were read one at a time, 0.02s apart; every value is a snapshot at its own instant, not a synchronised sample.
[exit 0]
How far it has actually been tested
It has never spoken to a real BACnet device. There is no BACnet hardware here to point it at, so everything on this page was proved against a fake device written from the standard and run on loopback — 194 assertions, including that the tool prints a diagnosis rather than a traceback on a malformed or random frame. A real controller from any vendor will differ, most likely in which optional properties it refuses and in how it answers an unindexed object-list read. That is the biggest gap in the tool and no amount of test writing here closes it.
What the fixture did cover: the Who-Is broadcast and the I-Am decode, including max APDU and segmentation support; reading real, unsigned, signed, double, enumerated, character-string, octet-string, bit-string, date, time and object-identifier values; array reads whole, by element, and element 0 as a count; the error, reject and abort replies decoded into words rather than numbers; a device that segments a reply it was told not to segment; a timeout; and a port already in use.
The enumeration tables — object types, properties, units, error and abort codes — were cross-checked against the equivalent tables inside a Niagara installation on this machine: 582 entries agree, none disagrees, and the check found four errors in ours before release. Reading a jar as a cross-check is a cross-check and nothing else; it is not an endorsement, an approval or a relationship of any kind.
Not tested, and therefore not claimed: any make or model of real device; a real broadcast domain, because loopback has none; MS/TP; BBMD or foreign-device registration; change-of-value subscription; reassembling a segmented reply; Windows; macOS; any Python other than the 3.12 it was run on; and anything at scale — the largest sweep tested had two devices and the largest dump eighteen objects.
Limits
These are the reasons a sweep can be wrong, and they are on the page rather than in a footnote because a tool that hides them wastes your afternoon.
| Limit | What it means on site |
|---|---|
| It sees one broadcast domain | A Who-Is is a broadcast, and a broadcast stops at the subnet. Devices on other IP subnets answer only if a BBMD forwards for them and the laptop is registered with it as a foreign device — which this tool does not do. There is no BBMD registration in it, so a routed estate means running the sweep from a laptop on each subnet. It does decode an I-Am that a BBMD forwarded to it, and reads at the address that forwarding reveals. |
| No MS/TP of its own | It speaks BACnet/IP over the laptop's network interface. Devices on an RS-485 MS/TP trunk appear only through a BACnet router, and this tool lists them and then tells you it cannot read them: there is no network-layer routing in it. There is no RS-485 on your laptop either, so nothing here reaches a trunk directly. |
| Silence is not absence | A device can be missed by a blocked inbound port, a firewall on this machine, an address on a different subnet, a Who-Is range that excluded it, or a device that answered while the listener had already stopped. Absence of an I-Am is a question, not a finding. |
| A present value is a single sample | One read, at one moment. No trend, no history, no rate of change, and no opinion about whether the number is plausible. A value that has been frozen for a year reads exactly like a good one. |
| Five columns, not eight | The dump carries object, object name, present value, units and a note. It does
not carry out-of-service, status-flags or reliability as columns, and
those three are exactly the properties that decide whether a present value is a
measurement at all. They are readable one at a time with the read
subcommand; reading the note below on what a present value does not tell you is
the cheaper version. |
| A segmented reply is refused, not reassembled | When a reply will not fit in one unsegmented message the device aborts, and this tool says which abort it was and what to do about it rather than reporting an empty object list. It does not reassemble segments; it reads the array element by element instead, which always works and is slow. |
| It is narrower than the tools you have | YABE, Workbench's own BACnet discovery and the commercial explorers all do a great deal this does not: writes, subscriptions, trend objects, alarm and event configuration, MS/TP over a dongle, a GUI. This is a sweep and a dump. Where you want the explorer, use the explorer. |
| Read-only is not zero-impact | A Who-Is asks every device on the subnet to reply at once, and dumping a large
controller means hundreds of requests to a device with a building to run. It
paces itself: one request at a time, never in parallel, 50 ms apart by
default and configurable with --delay, and never to an address you
did not name. On a fragile trunk, sweep first, dump later, and dump one device at a
time. |
The abort case is worth seeing, because a tool that reports it as "no objects" is wrong in the most confusing possible way. Real output, fixture, loopback:
$ python3 bacnet-sweep.py read 127.0.0.1 trend-log:1 log-buffer
127.0.0.1 trend-log:1 log-buffer
Abort: segmentation-not-supported — the answer does not fit one unsegmented APDU. Read it element by element with --index.
[exit 4]
What it will never do. It can encode two BACnet services, Who-Is and ReadProperty, and there is no code path in the file that encodes any other. No WriteProperty, no WritePropertyMultiple, no ReinitializeDevice, no DeviceCommunicationControl, no time synchronisation, no change-of-value subscription, no object creation or deletion. That is enforced in code rather than documented: a gate function raises before any packet is sent if the service is not one of the two, and the test suite asserts it for every write service there is. If you need to prove that to somebody before it goes on their network, the proof is one file long.
If it found something you now have to fix
A sweep tends to find one of four things. Devices nobody knew were there, or that are
missing from the head end. Two devices claiming the same device instance number. Point
names that no convention survives — AI_17 forty times across four
vendors. Or values that are plainly stale, which is the reading that matters most and
the one this tool can only hint at.
The first two are station engineering: discovery, a device and point model that matches the site, poll rates set deliberately rather than left at default, and the naming and tagging done as the points are created rather than retro-fitted. That is station engineering, and poll rates and tuning policies is the note on why the default is usually wrong.
The third is a bulk operation rather than a week of clicking: bulk point renaming and tagging. The fourth, when the equipment turns out to speak something with no Niagara driver behind it, is a custom module or driver.
If the CSV tells you something and you are not sure what, send it. Reading one is half an hour and costs nothing.
Reading further
What a BACnet discovery sweep actually tells you is the protocol behind this page: why the sweep stops at the subnet, how to read an object list from a device that cannot segment, and what a present value does not tell you. BACnet MS/TP on a JACE is the one to read if the devices you are missing are on a trunk. The other program is mqtt-tap, and both of them are listed here.
The file you are downloading
Published here so the download is checkable rather than trusted. Both figures
are read off the file served at
/tools/bacnet-sweep.py when this page is built, so they cannot
disagree with it.
| Property | Value |
|---|---|
| File | bacnet-sweep.py |
| Size | 70,520 bytes |
| SHA-256 | d10af9654644ec45f908290f92256ad6e7404e7fe17eae84a53b553751a17dca |
To check it, on Linux sha256sum bacnet-sweep.py, on macOS
shasum -a 256 bacnet-sweep.py, on Windows
certutil -hashfile bacnet-sweep.py SHA256. A different digest means a
different file — not necessarily a hostile one, but not this one.
mqtt-tap
Subscribes to a broker and prints what is actually on it: the topic tree with a count, a rate, a payload-type guess and the last value per topic, plus the retained topics that stopped updating. It sends five packet types and none of them is PUBLISH.
- MQTT
- Topic tree
- Retained
Next step
Send the CSV and the question it raised.
A point list and a sentence about what is wrong with it is usually enough to say whether this is a setting, a day of bulk editing, or a driver. That answer costs nothing either way.