Build a BACnet Building Automation Simulator

Simulate a real HVAC controller using Python and the BACnet protocol.

Introduction

30 Second Summary

Every modern office building automatically adjusts its temperature, airflow, and lighting without anyone flipping a switch. Behind the scenes, controllers communicate using a protocol called BACnet to coordinate heating, cooling, and ventilation equipment across entire floors.

In this project, you will build a virtual Air Handling Unit and a supervisor client in Python that communicate over BACnet/IP on your local machine. You will discover devices, read sensor values, write setpoint commands, detect alarm conditions, and log trend data to CSV.

What You'll Build

A complete BACnet building automation simulation that models a real-world Air Handling Unit controller, with two Python programs communicating over BACnet/IP on your local machine.

By the end of this project, you'll have:

  • A working virtual BACnet device (Air Handling Unit) with temperature sensors, fan status, damper positions, and HVAC operating modes.
  • A supervisor client that discovers BACnet devices, reads all their points, writes setpoint commands, and monitors for alarm conditions.
  • A trend data log recording time-series sensor readings and exporting to CSV for commissioning-style analysis.
  • Secret Mission: An occupancy schedule that automatically adjusts HVAC setpoints between occupied and unoccupied modes based on time of day.

Are there any prerequisites?

You should have basic Python knowledge (variables, functions, loops) and be comfortable running commands in Windows CMD. You do not need any prior experience with BACnet or building automation systems.

Before We Start

Before We Start...

Set Up Your Environment

Before you can simulate a BACnet building automation system, you need a workspace with the right tools installed.

In this step, you will create a project folder, set up an isolated Python environment, and install the BAC0 library that handles all BACnet/IP communication.

In this step, get ready to:
  • Create a project directory and Python virtual environment.
  • Install the BAC0 library and verify it works.
  • Confirm Windows Firewall allows BACnet/IP traffic on UDP port 47808.
Create a project directory and virtual environment

A virtual environment keeps your project's Python packages isolated from the rest of your system. This prevents version conflicts between different projects.

  • Open CMD by pressing the Windows key, typing cmd, and pressing Enter.
  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Create your project folder and move into it by running these commands:
mkdir bacnet-project
cd bacnet-project
  • Create a Python virtual environment by running:
python -m venv venv

What is a virtual environment?

A virtual environment is a self-contained Python installation inside your project folder. Packages you install here stay isolated from other projects on your machine.

The venv folder holds a copy of the Python interpreter and a private site-packages directory. You activate it to tell your terminal to use this private copy instead of the system-wide one.

  • Activate the virtual environment by running:
venv\Scripts\activate
  • Confirm the files exist by running:
dir
  • You should see a venv folder listed in the output. Your command prompt should also now show (venv) at the beginning of each line, confirming the virtual environment is active.

Not seeing (venv) in your prompt?

Make sure you typed venv\Scripts\activate with a backslash, not a forward slash. Windows CMD uses backslashes for file paths.

If you see a permissions error, try running CMD as Administrator by right-clicking the CMD icon and selecting Run as administrator.

help me activate my Python virtual environment on Windows

Install BAC0 and verify it works

BAC0 is a Python library that handles all the low-level BACnet/IP protocol communication for you. It also uses pandas for convenient trend history handling.

  • Install BAC0 and pandas by running:
pip install BAC0 pandas

What does this install?

BAC0 is the main library that lets you create virtual BACnet devices and communicate over BACnet/IP. It depends on BACpypes3 for the protocol layer.

pandas is a data analysis library. You will use it later to log trend data from your virtual HVAC device into a CSV file.

  • Verify the installation by checking the package details:
pip show BAC0
  • You should see output listing the package name, version (2025.9.15 or newer), and other metadata. This confirms BAC0 installed correctly.
  • Now verify that BAC0 actually imports and initializes. Open the asyncio REPL by running:
python -m asyncio
  • In the Python prompt that appears, type the following two lines, pressing Enter after each:
import BAC0
async with BAC0.start() as bacnet:
    pass
  • You should see output that prints the installed BAC0 version. This confirms the library loads and can initialize a BACnet stack.
  • Type exit() to close the Python REPL and return to your CMD prompt.

Seeing an import error?

Make sure your virtual environment is still active. You should see (venv) at the start of your CMD prompt. If not, run venv\Scripts\activate again.

If you see a dependency error, try reinstalling with:

pip uninstall BAC0 -y
pip install BAC0

help me fix a BAC0 import error in my Python virtual environment

Verify Windows Firewall and test BACnet/IP initialization

BACnet/IP communicates over UDP port 47808 (hex 0xBAC0). Windows Firewall needs to allow traffic on this port for your virtual devices to talk to each other.

  • Add a firewall rule to allow inbound UDP traffic on port 47808 by running this command (you may need to run CMD as Administrator):
netsh advfirewall firewall add rule name="BACnet" protocol=UDP dir=in localport=47808 action=allow

Why port 47808?

Port 47808 is the standard BACnet/IP port defined by the ASHRAE BACnet protocol. In hexadecimal, 47808 is written as 0xBAC0. That is where the BAC0 library gets its name.

Since both your virtual device and supervisor will run on 127.0.0.1 (localhost), most Windows configurations allow loopback traffic without a rule. Adding this rule ensures it works regardless of your firewall profile.

  • You should see Ok. printed in the terminal, confirming the rule was added.
  • Now create a test script to confirm BAC0 can fully initialize a BACnet/IP device. Create the file by running:
echo. > test_bacnet.py
  • Open test_bacnet.py in your text editor (VS Code or Cursor). Press Cmd+Space (macOS) or the Windows key (Windows) and type VS Code or Cursor to open your editor. Then open the bacnet-project folder from the Desktop.
  • Replace the contents of test_bacnet.py with the following code:
"""Test script to verify BAC0 can initialize a BACnet/IP application."""
import asyncio
import BAC0


async def main():
    # Create a BAC0 lite instance on the standard BACnet port
    # This confirms the BACnet/IP stack can bind to UDP 47808
    async with BAC0.lite(deviceId=9999, port=47808, localObjName="Test") as device:
        print("SUCCESS: BAC0 lite instance initialized.")
        print(f"Device ID: 9999 | Port: 47808")
        print("BACnet/IP stack is working correctly.")


if __name__ == "__main__":
    asyncio.run(main())

What does this script do?

This script creates a temporary BAC0 lite instance. The async with pattern initializes the BACnet/IP stack (binding to UDP port 47808) and automatically cleans it up when the block exits.

If the script prints the success message, it proves that your Python environment, BAC0 installation, and firewall configuration are all working together.

  • Save test_bacnet.py.
  • Run the test script by going back to your CMD window and entering:
python test_bacnet.py
  • You should see output that includes a line confirming BACnet/IP registration. Look for text similar to SUCCESS: BAC0 lite instance initialized in the output.

Script not printing the success message?

If you see a permission or binding error, another application may already be using port 47808. Close any other BACnet software and try again.

If CMD says the file was not found, make sure you are in the bacnet-project directory and your virtual environment is active.

help me troubleshoot my BAC0 test script that fails to initialize on port 47808

Your development environment is ready. Next up, you will build a virtual Air Handling Unit device that simulates a real HVAC controller.

Create a Virtual AHU Device

In the real world, an Air Handling Unit (AHU) is one of the most common pieces of HVAC equipment you will work with. It controls temperature, airflow, and air quality in a building zone.

Creating a virtual AHU teaches you the BACnet object model, which is how every real controller exposes its data to supervisory systems. In this step, you will build a Python script that simulates a real AHU controller with temperature sensors, a fan, a damper, and multiple operating modes.

In this step, get ready to:
  • Create a virtual BACnet device script with sensor and actuator objects.
  • Add a simulation loop that mimics real HVAC temperature behavior.
  • Run the device and confirm it registers on the BACnet/IP network.
Create the virtual device script

Every BACnet device exposes its data through objects. An Analog Input represents a sensor reading, a Binary Output represents equipment that is ON or OFF, an Analog Output represents a percentage-controlled actuator, and a Multistate Value represents named operating modes.

  • Create a new file called ahu_device.py inside your bacnet-project folder by running this command:
type nul > ahu_device.py
  • Confirm the file exists by running:
dir ahu_device.py

You should see ahu_device.py listed in the output.

  • Open ahu_device.py in your code editor and paste the following code to set up the device and declare all BACnet objects:
import asyncio
import random
import BAC0
from BAC0.core.devices.local.factory import (
    analog_input,
    analog_output,
    binary_output,
    multistate_value,
    make_state_text,
)


async def main():
    # Create a virtual BACnet device on the standard BACnet/IP port
    # deviceId is the unique address, localObjName is what other devices see
    async with BAC0.lite(deviceId=1001, port=47808, localObjName="AHU-1") as device:
        # Analog Inputs: sensors that report measured values
        analog_input(
            name="Zone_Temp",
            description="Zone Temperature Sensor",
            properties={"units": "degreesFahrenheit"},
        )
        analog_input(
            name="Supply_Air_Temp",
            description="Supply Air Temperature Sensor",
            properties={"units": "degreesFahrenheit"},
        )
        analog_input(
            name="Temp_Setpoint",
            description="Active Temperature Setpoint",
            properties={"units": "degreesFahrenheit"},
        )

        # Binary Output: equipment that is either ON or OFF
        binary_output(
            name="Fan_Status",
            description="Supply Fan Run Status",
            properties={"inactiveText": "OFF", "activeText": "ON"},
        )

        # Analog Output: equipment controlled to a percentage
        analog_output(
            name="Damper_Position",
            description="Outside Air Damper Position",
            properties={"units": "percent"},
        )

        # Multistate Value: equipment with named operating modes
        mode_states = make_state_text(["Off", "Heating", "Cooling", "Auto"])
        multistate_value(
            name="HVAC_Mode",
            description="Current HVAC Operating Mode",
            presentValue=4,
            properties={"stateText": mode_states},
        )

        # Register all declared objects with the running device
        multistate_value.add_objects_to_application(device)

What does this code do?

This code creates a virtual BACnet device named AHU-1 with device ID 1001 on port 47808 (the standard BACnet/IP port).

  • analog_input creates sensor points. You have three: zone temperature, supply air temperature, and the active setpoint.
  • binary_output creates an ON/OFF point for the supply fan.
  • analog_output creates a percentage-controlled point for the outside air damper.
  • multistate_value creates a named-state point for the HVAC operating mode (Off, Heating, Cooling, Auto).
  • add_objects_to_application(device) registers all accumulated objects with the running device in one call.

Now add the initial values and startup banner directly below the add_objects_to_application line, still inside the async with block:

        # Set initial values for all points
        device["Zone_Temp"].presentValue = 72.0
        device["Supply_Air_Temp"].presentValue = 55.0
        device["Temp_Setpoint"].presentValue = 72.0
        device["Fan_Status"].presentValue = True
        device["Damper_Position"].presentValue = 25.0

        # Print startup banner so you know the device is running
        print("=" * 60)
        print("AHU-1 Virtual BACnet Device Running")
        print(f"Device ID: 1001 | Port: 47808")
        print(f"Objects: Zone_Temp, Supply_Air_Temp, Temp_Setpoint,")
        print(f"         Fan_Status, Damper_Position, HVAC_Mode")
        print("=" * 60)
        print("Press Ctrl+C to stop.\n")

What does this code do?

This sets starting values for each point so the device has realistic data from the moment it boots. Zone temperature starts at 72.0F, the fan is ON, and the damper is at 25%.

The print statements create a status banner confirming the device is running and listing all its objects.

Add a simulation loop

A real AHU controller continuously adjusts temperatures based on whether it is heating or cooling. Your simulation loop will drift the zone temperature based on the active HVAC mode, making the device behave realistically when queried by a supervisor.

  • Add the following simulation loop directly below the print statements you just added, still inside the async with block. Then close the file with the if __name__ guard at the bottom:
        # Simulation loop: update sensor values to mimic real HVAC behavior
        while True:
            zone_temp = device["Zone_Temp"].presentValue
            setpoint = device["Temp_Setpoint"].presentValue
            fan_on = device["Fan_Status"].presentValue
            mode = device["HVAC_Mode"].presentValue

            # Simulate temperature drift based on HVAC mode
            if fan_on:
                if mode == 2:  # Heating
                    zone_temp += random.uniform(0.1, 0.3)
                elif mode == 3:  # Cooling
                    zone_temp -= random.uniform(0.1, 0.3)
                elif mode == 4:  # Auto
                    if zone_temp < setpoint - 1:
                        zone_temp += random.uniform(0.1, 0.3)
                    elif zone_temp > setpoint + 1:
                        zone_temp -= random.uniform(0.1, 0.3)
                    else:
                        zone_temp += random.uniform(-0.1, 0.1)
            else:
                # No conditioning: temperature drifts upward
                zone_temp += random.uniform(0.05, 0.15)

            # Supply air temperature depends on active mode
            if fan_on and (mode == 3 or (mode == 4 and zone_temp > setpoint)):
                supply_temp = 55.0 + random.uniform(-1, 1)
            elif fan_on and (mode == 2 or (mode == 4 and zone_temp < setpoint)):
                supply_temp = 95.0 + random.uniform(-1, 1)
            else:
                supply_temp = zone_temp + random.uniform(-2, 2)

            # Update device object values
            device["Zone_Temp"].presentValue = round(zone_temp, 1)
            device["Supply_Air_Temp"].presentValue = round(supply_temp, 1)

            # Adjust damper based on fan status
            if fan_on:
                device["Damper_Position"].presentValue = 25.0 + random.uniform(-5, 5)
            else:
                device["Damper_Position"].presentValue = 0.0

            # Print status every cycle
            print(
                f"  Zone: {device['Zone_Temp'].presentValue}F | "
                f"Supply: {device['Supply_Air_Temp'].presentValue}F | "
                f"Setpoint: {setpoint}F | "
                f"Fan: {'ON' if fan_on else 'OFF'} | "
                f"Damper: {device['Damper_Position'].presentValue}%"
            )

            await asyncio.sleep(5)


if __name__ == "__main__":
    asyncio.run(main())

What does this code do?

The while True loop runs every 5 seconds and adjusts temperatures based on the current HVAC mode.

  • In Auto mode (mode == 4), the system heats when below setpoint and cools when above it.
  • When the fan is OFF, temperature drifts upward (simulating no conditioning on a warm day).
  • Supply air temperature is 55F during cooling and 95F during heating, mimicking real coil discharge temperatures.
  • asyncio.sleep(5) pauses 5 seconds between updates, giving you time to read the output.
  • Save ahu_device.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved ahu_device.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete ahu_device.py file. Make sure your code matches this exactly:

import asyncio
import random
import BAC0
from BAC0.core.devices.local.factory import (
    analog_input,
    analog_output,
    binary_output,
    multistate_value,
    make_state_text,
)


async def main():
    async with BAC0.lite(deviceId=1001, port=47808, localObjName="AHU-1") as device:
        analog_input(
            name="Zone_Temp",
            description="Zone Temperature Sensor",
            properties={"units": "degreesFahrenheit"},
        )
        analog_input(
            name="Supply_Air_Temp",
            description="Supply Air Temperature Sensor",
            properties={"units": "degreesFahrenheit"},
        )
        analog_input(
            name="Temp_Setpoint",
            description="Active Temperature Setpoint",
            properties={"units": "degreesFahrenheit"},
        )

        binary_output(
            name="Fan_Status",
            description="Supply Fan Run Status",
            properties={"inactiveText": "OFF", "activeText": "ON"},
        )

        analog_output(
            name="Damper_Position",
            description="Outside Air Damper Position",
            properties={"units": "percent"},
        )

        mode_states = make_state_text(["Off", "Heating", "Cooling", "Auto"])
        multistate_value(
            name="HVAC_Mode",
            description="Current HVAC Operating Mode",
            presentValue=4,
            properties={"stateText": mode_states},
        )

        multistate_value.add_objects_to_application(device)

        device["Zone_Temp"].presentValue = 72.0
        device["Supply_Air_Temp"].presentValue = 55.0
        device["Temp_Setpoint"].presentValue = 72.0
        device["Fan_Status"].presentValue = True
        device["Damper_Position"].presentValue = 25.0

        print("=" * 60)
        print("AHU-1 Virtual BACnet Device Running")
        print(f"Device ID: 1001 | Port: 47808")
        print(f"Objects: Zone_Temp, Supply_Air_Temp, Temp_Setpoint,")
        print(f"         Fan_Status, Damper_Position, HVAC_Mode")
        print("=" * 60)
        print("Press Ctrl+C to stop.\n")

        while True:
            zone_temp = device["Zone_Temp"].presentValue
            setpoint = device["Temp_Setpoint"].presentValue
            fan_on = device["Fan_Status"].presentValue
            mode = device["HVAC_Mode"].presentValue

            if fan_on:
                if mode == 2:
                    zone_temp += random.uniform(0.1, 0.3)
                elif mode == 3:
                    zone_temp -= random.uniform(0.1, 0.3)
                elif mode == 4:
                    if zone_temp < setpoint - 1:
                        zone_temp += random.uniform(0.1, 0.3)
                    elif zone_temp > setpoint + 1:
                        zone_temp -= random.uniform(0.1, 0.3)
                    else:
                        zone_temp += random.uniform(-0.1, 0.1)
            else:
                zone_temp += random.uniform(0.05, 0.15)

            if fan_on and (mode == 3 or (mode == 4 and zone_temp > setpoint)):
                supply_temp = 55.0 + random.uniform(-1, 1)
            elif fan_on and (mode == 2 or (mode == 4 and zone_temp < setpoint)):
                supply_temp = 95.0 + random.uniform(-1, 1)
            else:
                supply_temp = zone_temp + random.uniform(-2, 2)

            device["Zone_Temp"].presentValue = round(zone_temp, 1)
            device["Supply_Air_Temp"].presentValue = round(supply_temp, 1)

            if fan_on:
                device["Damper_Position"].presentValue = 25.0 + random.uniform(-5, 5)
            else:
                device["Damper_Position"].presentValue = 0.0

            print(
                f"  Zone: {device['Zone_Temp'].presentValue}F | "
                f"Supply: {device['Supply_Air_Temp'].presentValue}F | "
                f"Setpoint: {setpoint}F | "
                f"Fan: {'ON' if fan_on else 'OFF'} | "
                f"Damper: {device['Damper_Position'].presentValue}%"
            )

            await asyncio.sleep(5)


if __name__ == "__main__":
    asyncio.run(main())
Run the device and confirm it registers
  • Run the virtual AHU device by executing this command in your CMD window (with the virtual environment still activated):
python ahu_device.py

You should see the startup banner followed by sensor readings updating every 5 seconds. The output will look similar to this:

============================================================
AHU-1 Virtual BACnet Device Running
Device ID: 1001 | Port: 47808
Objects: Zone_Temp, Supply_Air_Temp, Temp_Setpoint,
         Fan_Status, Damper_Position, HVAC_Mode
============================================================
Press Ctrl+C to stop.

  Zone: 72.1F | Supply: 55.3F | Setpoint: 72.0F | Fan: ON | Damper: 23.8%
  Zone: 72.0F | Supply: 54.7F | Setpoint: 72.0F | Fan: ON | Damper: 26.2%

What should I see?

The zone temperature will hover around 72F because Auto mode maintains the setpoint. Supply air temperature stays near 55F (cooling) or 95F (heating) depending on whether the zone is above or below setpoint.

Leave this CMD window running. The device needs to stay active for the next step when you build the supervisor client.

Not seeing any output?

Make sure your virtual environment is activated. You should see (venv) at the start of your CMD prompt.

If you see an error about port 47808 already being in use, close any other Python scripts or BACnet tools that might be running on that port.

If you see a ModuleNotFoundError for BAC0, confirm you installed it by running pip list and checking that BAC0 appears in the output.

Help me troubleshoot my AHU device script not running

Your virtual AHU is now live on the BACnet/IP network. Next up, you will build a supervisor client that discovers this device, reads all its points, and writes new commands to it.

Build the Supervisor Client

Your virtual AHU device is running and broadcasting its data over BACnet/IP. Now you need something to talk to it.

In Building Automation Systems, a "supervisor" or "front-end" is the software that connects to all your field controllers, discovers what points they expose, and lets building operators monitor and command the system. At Convergint, you would use tools like Niagara Workbench for this. Here, you are building one from scratch in Python to deeply understand what happens under the hood when the supervisor communicates with a controller.

In this step, get ready to:
  • Create a supervisor client that discovers the virtual AHU device over BACnet.
  • Read all six BACnet points and display them in a formatted dashboard.
  • Write a new temperature setpoint using BACnet's priority array system.
Create the supervisor script and discover the AHU device

The supervisor needs its own BACnet identity so it can participate on the network as a separate device. You will start a second BAC0 instance on a different UDP port so it does not conflict with the AHU device already running on port 47808.

  • Open a second CMD window (keep the AHU device running in the first one).
  • Activate your virtual environment in the new window by running:
cd %USERPROFILE%\Desktop\bacnet-project
venv\Scripts\activate
  • Create the supervisor script file by running:
type nul > supervisor.py
  • Open supervisor.py in your editor and paste the following code:
"""BACnet Supervisor Client
Discovers devices, reads points, writes setpoints, detects alarms, and logs trends.
"""
import asyncio
from datetime import datetime
import pandas as pd
import BAC0


# Configuration
AHU_IP = "127.0.0.1"
AHU_PORT = 47808
SUPERVISOR_PORT = 47809
DEVICE_ID = 1001
MONITOR_DURATION_SECONDS = 60
POLL_INTERVAL_SECONDS = 5

# Alarm thresholds
HIGH_TEMP_ALARM = 78.0
LOW_TEMP_ALARM = 65.0


async def main():
    print("=" * 60)
    print("BACnet Supervisor Client Starting")
    print("=" * 60)

    # Start a second BAC0 instance on a different port (47809)
    async with BAC0.lite(deviceId=9999, port=SUPERVISOR_PORT, localObjName="Supervisor") as supervisor:
        target = f"{AHU_IP}:{AHU_PORT}"

        # --- Step 1: Device Discovery ---
        print("\n--- Device Discovery (Who-Is / I-Am) ---")
        print(f"Querying device at {target}...")

        # Read the device object's name and object list
        device_name = await supervisor.read(f"{target} device {DEVICE_ID} objectName")
        object_list = await supervisor.read(f"{target} device {DEVICE_ID} objectList")

        print(f"Found device: {device_name} (ID: {DEVICE_ID})")
        print(f"Object list: {object_list}")

What does this code do?

The configuration block defines the AHU device's IP address and port so the supervisor knows where to send BACnet requests. The supervisor runs on port 47809 to avoid conflicts with the AHU on port 47808.

The BAC0.lite() call creates a lightweight BACnet device with ID 9999. The read() method queries the AHU's device object for its objectName and objectList properties. This mimics BACnet's Who-Is/I-Am discovery process where a supervisor learns what objects a controller exposes.

What is Who-Is / I-Am?

In real BACnet networks, Who-Is is a broadcast message that asks "who's out there?" and I-Am is the response from each device saying "I'm here, my device ID is X." Reading the objectList is the next step: once you know a device exists, you ask it what BACnet objects (sensors, outputs, values) it contains.

Read all point values from the AHU device

Now that the supervisor has discovered the AHU and knows what objects it has, the next job is reading the current value of every point. In BAC0, the read syntax is a space-delimited string: address, object type, instance number, and property name.

  • Add the following code directly below the discovery section in supervisor.py:
        # --- Step 2: Read All Points ---
        print("\n--- Reading All Points ---")
        print(f"{'Point':<20} {'Type':<16} {'Value':<12} {'Units'}")
        print("-" * 60)

        # Read each BACnet object's presentValue by type and instance number
        zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
        supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
        setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
        fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
        damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
        hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

        mode_names = {1: "Off", 2: "Heating", 3: "Cooling", 4: "Auto"}

        print(f"{'Zone_Temp':<20} {'analogInput':<16} {zone_temp:<12} {'degF'}")
        print(f"{'Supply_Air_Temp':<20} {'analogInput':<16} {supply_temp:<12} {'degF'}")
        print(f"{'Temp_Setpoint':<20} {'analogInput':<16} {setpoint:<12} {'degF'}")
        print(f"{'Fan_Status':<20} {'binaryOutput':<16} {'ON' if fan_status else 'OFF':<12} {''}")
        print(f"{'Damper_Position':<20} {'analogOutput':<16} {damper_pos:<12} {'%'}")
        print(f"{'HVAC_Mode':<20} {'multistateValue':<16} {mode_names.get(hvac_mode, '?'):<12} {''}")

What does the read syntax mean?

Each read() call uses the format "address objectType instanceNumber property". For example, "127.0.0.1:47808 analogInput 1 presentValue" reads the current value of the first Analog Input object on the device at that address.

Instance numbers are assigned in order of declaration. Since Zone_Temp was the first analog_input declared in ahu_device.py, it is instance 1. Supply_Air_Temp is instance 2, and Temp_Setpoint is instance 3.

Write a temperature setpoint with a priority level

Reading data is only half the job. A real supervisor also sends commands to controllers. In BACnet, every writable property has a 16-level priority array that determines which command wins when multiple systems try to control the same point. Higher priority (lower number) always wins.

  • Add the following code below the reading section in supervisor.py:
        # --- Step 3: Write a Setpoint ---
        print("\n--- Writing New Setpoint ---")
        new_setpoint = 70.0
        print(f"Writing setpoint to {new_setpoint}F (priority 8 = Manual Operator)...")
        # Write syntax: "address objectType instance property value - priority"
        await supervisor.write(
            f"{target} analogInput 3 presentValue {new_setpoint} - 8"
        )
        # Read the value back to confirm the write succeeded
        verify = await supervisor.read(f"{target} analogInput 3 presentValue")
        print(f"Verified: Setpoint is now {verify}F")

        # --- Step 4: Trend Logging and Alarm Detection ---
        print(f"\n--- Monitoring for {MONITOR_DURATION_SECONDS} seconds ---")
        print(f"Alarm thresholds: High={HIGH_TEMP_ALARM}F, Low={LOW_TEMP_ALARM}F")

        trend_data = []
        alarms = []
        readings_count = 0
        elapsed = 0

        while elapsed < MONITOR_DURATION_SECONDS:
            timestamp = datetime.now()

            zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
            supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
            setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
            fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
            damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
            hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

            # Record trend data
            trend_data.append({
                "timestamp": timestamp,
                "zone_temp": zone_temp,
                "supply_air_temp": supply_temp,
                "setpoint": setpoint,
                "fan_status": fan_status,
                "damper_position": damper_pos,
                "hvac_mode": hvac_mode,
            })
            readings_count += 1

            # Check alarms
            if zone_temp > HIGH_TEMP_ALARM:
                alarm_msg = f"HIGH TEMP ALARM: {zone_temp}F > {HIGH_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            elif zone_temp < LOW_TEMP_ALARM:
                alarm_msg = f"LOW TEMP ALARM: {zone_temp}F < {LOW_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            else:
                print(
                    f"  [{timestamp.strftime('%H:%M:%S')}] "
                    f"Zone: {zone_temp}F | Supply: {supply_temp}F | "
                    f"Fan: {'ON' if fan_status else 'OFF'}"
                )

            await asyncio.sleep(POLL_INTERVAL_SECONDS)
            elapsed += POLL_INTERVAL_SECONDS

        # --- Step 5: Export and Summary ---
        print("\n--- Trend Data Export ---")
        df = pd.DataFrame(trend_data)
        csv_filename = f"ahu_trend_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        df.to_csv(csv_filename, index=False)
        print(f"Saved {readings_count} readings to: {csv_filename}")

        print("\n--- Monitoring Summary ---")
        print(f"Total readings: {readings_count}")
        print(f"Total alarms: {len(alarms)}")
        if len(trend_data) > 0:
            temps = [r["zone_temp"] for r in trend_data]
            print(f"Zone Temp - Min: {min(temps)}F | Max: {max(temps)}F | "
                  f"Avg: {sum(temps)/len(temps):.1f}F")

        if alarms:
            print("\nAlarm Log:")
            for alarm in alarms:
                print(f"  {alarm['timestamp'].strftime('%H:%M:%S')} - {alarm['message']}")

        print("\n" + "=" * 60)
        print("Monitoring complete. Review CSV for detailed trend analysis.")
        print("=" * 60)


if __name__ == "__main__":
    asyncio.run(main())

What is BACnet's priority array?

Every writable BACnet property has 16 priority levels. Priority 1 (Manual-Life Safety) is the highest and always wins. Priority 8 is the "Manual Operator" level, which is what a technician uses during commissioning or troubleshooting. Priority 10 is typical for supervisor-driven control, and priority 15 is for schedules.

This matters because in a real building, a fire alarm (priority 1) will override your thermostat setting (priority 8). The priority system prevents dangerous conflicts between automated controls.

What about the monitoring loop at the bottom?

The code below the setpoint write adds a monitoring loop that polls the AHU every 5 seconds, checks for alarm conditions, and logs all readings. You will explore this section in detail in the next step when you focus on alarm detection and trend logging.

  • Save supervisor.py.
  • In your second CMD window (with the virtual environment active), run the supervisor by entering:
python supervisor.py

You should see the supervisor discover device AHU-1, print a table of all six point values, write the new setpoint of 70.0F, and then begin monitoring. Let it run for a few seconds to confirm readings appear, then press Ctrl+C to stop early if you like.

Seeing a connection error?

Make sure your AHU device is still running in the first CMD window. If it stopped, restart it with python ahu_device.py before running the supervisor.

If you see a port conflict error, confirm the AHU device is using port 47808 and the supervisor is using port 47809. Two BAC0 instances cannot share the same port.

Help me troubleshoot my BACnet supervisor connection

✔️ Awesome, I've got everything!

Great. Double check you have saved supervisor.py and confirmed it can discover and read from your AHU device.

ⓧ I'd like to double check the full code

Here is the complete supervisor.py file:

"""BACnet Supervisor Client
Discovers devices, reads points, writes setpoints, detects alarms, and logs trends.
"""
import asyncio
from datetime import datetime
import pandas as pd
import BAC0


# Configuration
AHU_IP = "127.0.0.1"
AHU_PORT = 47808
SUPERVISOR_PORT = 47809
DEVICE_ID = 1001
MONITOR_DURATION_SECONDS = 60
POLL_INTERVAL_SECONDS = 5

# Alarm thresholds
HIGH_TEMP_ALARM = 78.0
LOW_TEMP_ALARM = 65.0


async def main():
    print("=" * 60)
    print("BACnet Supervisor Client Starting")
    print("=" * 60)

    async with BAC0.lite(deviceId=9999, port=SUPERVISOR_PORT, localObjName="Supervisor") as supervisor:
        target = f"{AHU_IP}:{AHU_PORT}"

        # --- Step 1: Device Discovery ---
        print("\n--- Device Discovery (Who-Is / I-Am) ---")
        print(f"Querying device at {target}...")

        device_name = await supervisor.read(f"{target} device {DEVICE_ID} objectName")
        object_list = await supervisor.read(f"{target} device {DEVICE_ID} objectList")

        print(f"Found device: {device_name} (ID: {DEVICE_ID})")
        print(f"Object list: {object_list}")

        # --- Step 2: Read All Points ---
        print("\n--- Reading All Points ---")
        print(f"{'Point':<20} {'Type':<16} {'Value':<12} {'Units'}")
        print("-" * 60)

        zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
        supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
        setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
        fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
        damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
        hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

        mode_names = {1: "Off", 2: "Heating", 3: "Cooling", 4: "Auto"}

        print(f"{'Zone_Temp':<20} {'analogInput':<16} {zone_temp:<12} {'degF'}")
        print(f"{'Supply_Air_Temp':<20} {'analogInput':<16} {supply_temp:<12} {'degF'}")
        print(f"{'Temp_Setpoint':<20} {'analogInput':<16} {setpoint:<12} {'degF'}")
        print(f"{'Fan_Status':<20} {'binaryOutput':<16} {'ON' if fan_status else 'OFF':<12} {''}")
        print(f"{'Damper_Position':<20} {'analogOutput':<16} {damper_pos:<12} {'%'}")
        print(f"{'HVAC_Mode':<20} {'multistateValue':<16} {mode_names.get(hvac_mode, '?'):<12} {''}")

        # --- Step 3: Write a Setpoint ---
        print("\n--- Writing New Setpoint ---")
        new_setpoint = 70.0
        print(f"Writing setpoint to {new_setpoint}F (priority 8 = Manual Operator)...")
        await supervisor.write(
            f"{target} analogInput 3 presentValue {new_setpoint} - 8"
        )
        verify = await supervisor.read(f"{target} analogInput 3 presentValue")
        print(f"Verified: Setpoint is now {verify}F")

        # --- Step 4: Trend Logging and Alarm Detection ---
        print(f"\n--- Monitoring for {MONITOR_DURATION_SECONDS} seconds ---")
        print(f"Alarm thresholds: High={HIGH_TEMP_ALARM}F, Low={LOW_TEMP_ALARM}F")

        trend_data = []
        alarms = []
        readings_count = 0
        elapsed = 0

        while elapsed < MONITOR_DURATION_SECONDS:
            timestamp = datetime.now()

            zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
            supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
            setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
            fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
            damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
            hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

            trend_data.append({
                "timestamp": timestamp,
                "zone_temp": zone_temp,
                "supply_air_temp": supply_temp,
                "setpoint": setpoint,
                "fan_status": fan_status,
                "damper_position": damper_pos,
                "hvac_mode": hvac_mode,
            })
            readings_count += 1

            if zone_temp > HIGH_TEMP_ALARM:
                alarm_msg = f"HIGH TEMP ALARM: {zone_temp}F > {HIGH_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            elif zone_temp < LOW_TEMP_ALARM:
                alarm_msg = f"LOW TEMP ALARM: {zone_temp}F < {LOW_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            else:
                print(
                    f"  [{timestamp.strftime('%H:%M:%S')}] "
                    f"Zone: {zone_temp}F | Supply: {supply_temp}F | "
                    f"Fan: {'ON' if fan_status else 'OFF'}"
                )

            await asyncio.sleep(POLL_INTERVAL_SECONDS)
            elapsed += POLL_INTERVAL_SECONDS

        # --- Step 5: Export and Summary ---
        print("\n--- Trend Data Export ---")
        df = pd.DataFrame(trend_data)
        csv_filename = f"ahu_trend_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        df.to_csv(csv_filename, index=False)
        print(f"Saved {readings_count} readings to: {csv_filename}")

        print("\n--- Monitoring Summary ---")
        print(f"Total readings: {readings_count}")
        print(f"Total alarms: {len(alarms)}")
        if len(trend_data) > 0:
            temps = [r["zone_temp"] for r in trend_data]
            print(f"Zone Temp - Min: {min(temps)}F | Max: {max(temps)}F | "
                  f"Avg: {sum(temps)/len(temps):.1f}F")

        if alarms:
            print("\nAlarm Log:")
            for alarm in alarms:
                print(f"  {alarm['timestamp'].strftime('%H:%M:%S')} - {alarm['message']}")

        print("\n" + "=" * 60)
        print("Monitoring complete. Review CSV for detailed trend analysis.")
        print("=" * 60)


if __name__ == "__main__":
    asyncio.run(main())

Your supervisor can now discover devices, read every point on the AHU, and write commands back to it. Next up, you will dive deeper into the monitoring loop to understand how alarm detection and trend logging work for commissioning-style analysis.

Add Alarm Detection and Trend Logging

Your supervisor can now discover the AHU, read all its points, and write setpoints over BACnet. That covers basic communication. But a real commissioning workflow requires more.

Commissioning a Building Automation System means verifying that sensors read correctly, alarms trigger at the right thresholds, and trend data proves the system performs over time. In this step, you will walk through the alarm detection, trend logging, and summary report sections of your supervisor script, then run the full monitoring cycle to see them in action.

In this step, get ready to:
  • Understand the alarm detection logic that flags out-of-range temperatures.
  • Run the trend logging system that records timestamped sensor data to CSV.
  • Review the summary report with min/max/average statistics and an alarm log.
Understand the alarm detection logic

In BAS work, alarm thresholds protect building occupants and equipment. If a temperature sensor reads outside an acceptable range, the system must flag it immediately so an operator can respond.

  • In your code editor, open supervisor.py and scroll down to the section commented # --- Step 4: Trend Logging and Alarm Detection ---.
  • Locate the alarm check logic inside the while loop:
            # Check alarms
            if zone_temp > HIGH_TEMP_ALARM:
                alarm_msg = f"HIGH TEMP ALARM: {zone_temp}F > {HIGH_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            elif zone_temp < LOW_TEMP_ALARM:
                alarm_msg = f"LOW TEMP ALARM: {zone_temp}F < {LOW_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            else:
                print(
                    f"  [{timestamp.strftime('%H:%M:%S')}] "
                    f"Zone: {zone_temp}F | Supply: {supply_temp}F | "
                    f"Fan: {'ON' if fan_status else 'OFF'}"
                )

What does this alarm logic do?

Each polling cycle, the zone temperature is compared against two thresholds you defined at the top of the file: HIGH_TEMP_ALARM = 78.0 and LOW_TEMP_ALARM = 65.0.

When a reading crosses either threshold, the code builds a descriptive alarm message, appends it to the alarms list with a timestamp, and prints it to the console with an [ALARM] prefix. Normal readings print the current values without the alarm flag.

Why two separate thresholds?

Real BAS systems define both high and low limits for every critical sensor. A zone that is too hot indicates a cooling failure. A zone that is too cold indicates a heating failure or a stuck damper letting in outside air. Each failure mode requires a different troubleshooting path, so alarms must distinguish between them.

Understand the trend logging and CSV export

Trend data is how BAS technicians prove a system is performing correctly over time. At Convergint, you would review trend logs to troubleshoot comfort complaints and verify sequences of operation.

  • In the same while loop, find the code that appends to trend_data:
            # Record trend data
            trend_data.append({
                "timestamp": timestamp,
                "zone_temp": zone_temp,
                "supply_air_temp": supply_temp,
                "setpoint": setpoint,
                "fan_status": fan_status,
                "damper_position": damper_pos,
                "hvac_mode": hvac_mode,
            })
            readings_count += 1
  • Now scroll down past the while loop to find the export section commented # --- Step 5: Export and Summary ---:
        # --- Step 5: Export and Summary ---
        print("\n--- Trend Data Export ---")
        df = pd.DataFrame(trend_data)
        csv_filename = f"ahu_trend_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        df.to_csv(csv_filename, index=False)
        print(f"Saved {readings_count} readings to: {csv_filename}")

What does the trend logging do?

Every 5 seconds (set by POLL_INTERVAL_SECONDS), the loop reads all six points and appends a dictionary to the trend_data list. Each dictionary records the timestamp and all point values at that moment.

After the 60-second monitoring period completes, pandas converts the list into a DataFrame and exports it as a CSV file. The filename includes a timestamp so each run produces a unique file. This mimics how real BAS systems store historical data for commissioning reports.

Run the supervisor and review the summary report

The final section of your supervisor prints a summary report with statistics that a technician would use to verify system performance.

  • Find the summary report code below the CSV export:
        print("\n--- Monitoring Summary ---")
        print(f"Total readings: {readings_count}")
        print(f"Total alarms: {len(alarms)}")
        if len(trend_data) > 0:
            temps = [r["zone_temp"] for r in trend_data]
            print(f"Zone Temp - Min: {min(temps)}F | Max: {max(temps)}F | "
                  f"Avg: {sum(temps)/len(temps):.1f}F")

        if alarms:
            print("\nAlarm Log:")
            for alarm in alarms:
                print(f"  {alarm['timestamp'].strftime('%H:%M:%S')} - {alarm['message']}")

What does the summary report show?

  • Total readings: how many polling cycles completed (60s / 5s = 12 readings).
  • Total alarms: how many times the zone temperature crossed a threshold.
  • Min/Max/Avg: statistical summary of zone temperature across all readings.
  • Alarm Log: a timestamped list of every alarm event, showing exactly when each threshold was crossed.
  • Make sure your AHU device is still running in the first CMD window. You should see it printing temperature updates every 5 seconds.
  • Switch to your second CMD window (with the virtual environment activated).
  • Run the supervisor script by running this command:
python supervisor.py

What should I see?

The script runs for about 60 seconds. You will see the device discovery output, the point readings dashboard, the setpoint write confirmation, and then the monitoring loop printing a line every 5 seconds. Lines prefixed with [ALARM] indicate threshold violations.

After 60 seconds, the script exports the CSV file and prints the monitoring summary with total readings, alarms, and temperature statistics.

  • Wait for the script to complete (about 60 seconds). You should see the --- Monitoring Summary --- output followed by statistics.
  • Confirm the CSV file was created by running:
dir ahu_trend_*.csv

You should see a file named something like ahu_trend_20260624_143022.csv (with your current date and time). This file contains all the timestamped readings your supervisor collected.

Supervisor not connecting to the AHU?

Make sure ahu_device.py is still running in your first CMD window. If it stopped, restart it with python ahu_device.py before running the supervisor.

If you see a timeout error, check that both scripts are using the correct ports. The AHU must be on port 47808 and the supervisor on port 47809.

Help me troubleshoot why my supervisor can't read from the AHU device on localhost.

✔️ Awesome, I've got everything!

Great. Double check you have saved supervisor.py and confirmed the CSV file was exported.

ⓧ I'd like to double check the full code

Here is the complete supervisor.py for comparison:

"""
BACnet Supervisor Client
Discovers devices, reads points, writes setpoints, detects alarms, and logs trends.
"""
import asyncio
from datetime import datetime
import pandas as pd
import BAC0


# Configuration
AHU_IP = "127.0.0.1"
AHU_PORT = 47808
SUPERVISOR_PORT = 47809
DEVICE_ID = 1001
MONITOR_DURATION_SECONDS = 60
POLL_INTERVAL_SECONDS = 5

# Alarm thresholds
HIGH_TEMP_ALARM = 78.0
LOW_TEMP_ALARM = 65.0


async def main():
    print("=" * 60)
    print("BACnet Supervisor Client Starting")
    print("=" * 60)

    async with BAC0.lite(deviceId=9999, port=SUPERVISOR_PORT, localObjName="Supervisor") as supervisor:
        target = f"{AHU_IP}:{AHU_PORT}"

        # --- Step 1: Device Discovery ---
        print("\n--- Device Discovery (Who-Is / I-Am) ---")
        print(f"Querying device at {target}...")

        device_name = await supervisor.read(f"{target} device {DEVICE_ID} objectName")
        object_list = await supervisor.read(f"{target} device {DEVICE_ID} objectList")

        print(f"Found device: {device_name} (ID: {DEVICE_ID})")
        print(f"Object list: {object_list}")

        # --- Step 2: Read All Points ---
        print("\n--- Reading All Points ---")
        print(f"{'Point':<20} {'Type':<16} {'Value':<12} {'Units'}")
        print("-" * 60)

        zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
        supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
        setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
        fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
        damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
        hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

        mode_names = {1: "Off", 2: "Heating", 3: "Cooling", 4: "Auto"}

        print(f"{'Zone_Temp':<20} {'analogInput':<16} {zone_temp:<12} {'degF'}")
        print(f"{'Supply_Air_Temp':<20} {'analogInput':<16} {supply_temp:<12} {'degF'}")
        print(f"{'Temp_Setpoint':<20} {'analogInput':<16} {setpoint:<12} {'degF'}")
        print(f"{'Fan_Status':<20} {'binaryOutput':<16} {'ON' if fan_status else 'OFF':<12} {''}")
        print(f"{'Damper_Position':<20} {'analogOutput':<16} {damper_pos:<12} {'%'}")
        print(f"{'HVAC_Mode':<20} {'multistateValue':<16} {mode_names.get(hvac_mode, '?'):<12} {''}")

        # --- Step 3: Write a Setpoint ---
        print("\n--- Writing New Setpoint ---")
        new_setpoint = 70.0
        print(f"Writing setpoint to {new_setpoint}F (priority 8 = Manual Operator)...")
        await supervisor.write(
            f"{target} analogInput 3 presentValue {new_setpoint} - 8"
        )
        verify = await supervisor.read(f"{target} analogInput 3 presentValue")
        print(f"Verified: Setpoint is now {verify}F")

        # --- Step 4: Trend Logging and Alarm Detection ---
        print(f"\n--- Monitoring for {MONITOR_DURATION_SECONDS} seconds ---")
        print(f"Alarm thresholds: High={HIGH_TEMP_ALARM}F, Low={LOW_TEMP_ALARM}F")

        trend_data = []
        alarms = []
        readings_count = 0
        elapsed = 0

        while elapsed < MONITOR_DURATION_SECONDS:
            timestamp = datetime.now()

            zone_temp = await supervisor.read(f"{target} analogInput 1 presentValue")
            supply_temp = await supervisor.read(f"{target} analogInput 2 presentValue")
            setpoint = await supervisor.read(f"{target} analogInput 3 presentValue")
            fan_status = await supervisor.read(f"{target} binaryOutput 1 presentValue")
            damper_pos = await supervisor.read(f"{target} analogOutput 1 presentValue")
            hvac_mode = await supervisor.read(f"{target} multistateValue 1 presentValue")

            trend_data.append({
                "timestamp": timestamp,
                "zone_temp": zone_temp,
                "supply_air_temp": supply_temp,
                "setpoint": setpoint,
                "fan_status": fan_status,
                "damper_position": damper_pos,
                "hvac_mode": hvac_mode,
            })
            readings_count += 1

            if zone_temp > HIGH_TEMP_ALARM:
                alarm_msg = f"HIGH TEMP ALARM: {zone_temp}F > {HIGH_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            elif zone_temp < LOW_TEMP_ALARM:
                alarm_msg = f"LOW TEMP ALARM: {zone_temp}F < {LOW_TEMP_ALARM}F"
                alarms.append({"timestamp": timestamp, "message": alarm_msg})
                print(f"  [ALARM] {alarm_msg}")
            else:
                print(
                    f"  [{timestamp.strftime('%H:%M:%S')}] "
                    f"Zone: {zone_temp}F | Supply: {supply_temp}F | "
                    f"Fan: {'ON' if fan_status else 'OFF'}"
                )

            await asyncio.sleep(POLL_INTERVAL_SECONDS)
            elapsed += POLL_INTERVAL_SECONDS

        # --- Step 5: Export and Summary ---
        print("\n--- Trend Data Export ---")
        df = pd.DataFrame(trend_data)
        csv_filename = f"ahu_trend_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        df.to_csv(csv_filename, index=False)
        print(f"Saved {readings_count} readings to: {csv_filename}")

        print("\n--- Monitoring Summary ---")
        print(f"Total readings: {readings_count}")
        print(f"Total alarms: {len(alarms)}")
        if len(trend_data) > 0:
            temps = [r["zone_temp"] for r in trend_data]
            print(f"Zone Temp - Min: {min(temps)}F | Max: {max(temps)}F | "
                  f"Avg: {sum(temps)/len(temps):.1f}F")

        if alarms:
            print("\nAlarm Log:")
            for alarm in alarms:
                print(f"  {alarm['timestamp'].strftime('%H:%M:%S')} - {alarm['message']}")

        print("\n" + "=" * 60)
        print("Monitoring complete. Review CSV for detailed trend analysis.")
        print("=" * 60)


if __name__ == "__main__":
    asyncio.run(main())

You now have a fully operational BACnet monitoring system that discovers devices, reads and writes points, detects alarms, logs trend data, and produces commissioning-style reports. Next up, you will extend this with an occupancy schedule in the Secret Mission.

Secret mission

Implement an Occupancy Schedule

Real buildings do not run at full comfort 24/7. They follow occupancy schedules that automatically setback temperatures during nights and weekends to save energy. Can you add time-based logic that switches your AHU between occupied and unoccupied modes automatically?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Python virtual environment with BAC0 library (inside your bacnet-project folder).
  • CSV trend data files (inside your bacnet-project folder).
  • Two CMD windows running the AHU device and supervisor scripts.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your BACnet simulator.

  • Your bacnet-project folder stays as-is on your Desktop. The virtual environment takes around 200MB of disk space.
  • Your AHU device script continues running in its CMD window until you close it or press Ctrl+C.
  • CSV trend data files remain available for analysis.

Pause - I'll come back to this later

Shut down the running scripts and deactivate the virtual environment to free up system resources. Your files stay on disk so you can pick up where you left off.

  • In the CMD window running ahu_device.py, press Ctrl+C to stop the device.
  • In any CMD window with the virtual environment active, run:
deactivate
  • Close any extra CMD windows you opened for this project.
  • When you return, reactivate the virtual environment by navigating to bacnet-project and running venv\Scripts\activate.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and trend data to reclaim disk space.

  • In the CMD window running ahu_device.py, press Ctrl+C to stop the device.
  • In any CMD window with the virtual environment active, run:
deactivate
  • Navigate to the parent directory (the folder that contains bacnet-project) and run:
cd Desktop
rmdir /s /q bacnet-project
  • Confirm by typing Y if prompted.
  • Close all CMD windows used for this project.

Nice Work!

Nice Work!

Nice work! You just built a complete BACnet building automation simulator from scratch using Python and the BAC0 library.

You've learned how to:

  • Build a virtual BACnet device that simulates a real Air Handling Unit with temperature sensors, fan control, damper positioning, and HVAC operating modes.
  • Build a supervisor client that discovers BACnet devices on the network, reads all point values, writes setpoint commands with priority levels, and detects alarm conditions.
  • Log trend data to CSV for commissioning-style analysis, including timestamped sensor readings, alarm events, and monitoring summaries.
  • Secret Mission: Implement an occupancy schedule that automatically adjusts HVAC setpoints between occupied and unoccupied modes based on time of day.

Ready to quiz yourself?