A simulated HVAC control system using BACnet/IP, designed as a companion to Caldera for OT for red/blue-team exercises involving cyber–physical systems.
Created by University of Hawaii at Manoa Students for Capstone Project: Elijah Saloma and Jake Dickinson
In collaboration with Caldera for OT tools (ot@mitre.org).
HVACSim is intended to grow open source collaboration in OT. We encourage forks and pull requests with improvements, new features, deployment notes, documentation, and tests.
The students' original BACpypes version is preserved in the v1.0.0 release. This version moves to the bac0 library, uses standard BACnet object types, and adds Caldera scenarios and a docker lab.
HVACSim provides a realistic, software-only BACnet simulation of a server-room HVAC controller. The system exposes writable BACnet objects (setpoint, fans, emergency stop) and simulates:
- Temperature dynamics
- Chiller load and PI feedback loop
- Sensor noise and an actuator lag
- An HMI with sliders, trend charts, and an emergency-stop; the HMI is intended to easily observe overrides by the Caldera for OT client.
This allows cybersecurity practitioners to emulate attacks against building HVAC systems without requiring physical industrial hardware. When paired with the Caldera BACnet plugin, HVACSim becomes an OT testbed for reconnaissance, manipulation, and response.
For a detailed walkthrough, please read our medium article on HVACSim! https://medium.com/@mitrecaldera/caldera-for-ot-hvacsim-expanding-access-to-ot-security-education-c4fcc47396ab
-
Python 3.10+
-
matplotlib(for the HMI) -
bac0(BACnet helper library; provides BACnet/IP via BACpypes3) -
Should run on Linux, macOS, or Windows
-
Caldera with its BACnet plugin
Linux users: Install system packages for matplotlib GUI support:
# Ubuntu/Debian
sudo apt-get install python3-tk
# Fedora/RHEL
sudo dnf install python3-tkinter- Clone the repository:
git clone https://github.com/mitre/hvac-sim.git- Install Python dependencies:
pip install -r requirements.txt- You can provide an INI-style config file to set the device instance and network address. Example provided in repo root (
config.ini):
[HVACSim]
objectIdentifier = 101
address = 127.0.0.1/24
temperature_unit = celsius
Address:
addressis the interface the BACnet device binds to, with the subnet mask it answers Who-Is on. Pass--address autoto take both from the host's primary interface, which is what the containers under docker/ do.
Temperature units: Set
temperature_unittocelsiusorfahrenheitto choose the display unit used in the HMI visualization. The BACnet objects always use °C internally; this setting only affects what is shown on screen.
docker/docker-compose.yml runs the sim, the web HMI, Caldera, and an agent on a shared bridge:
docker compose -f docker/docker-compose.yml up --build -dBACnet/IP communicates over UDP port 47808. The server and the HMI both need that port, and two programs can't share one port on the same machine, so each runs in its own container and reaches the other over the network by IP.
Caldera starts with the HVACSim fact source and adversaries loaded.
An optional Wireshark container shares the sim's network, so it sees the web HMI polling and the BACnet traffic from the Caldera agent:
docker compose -f docker/docker-compose.yml --profile capture up -d captureOpen https://127.0.0.1:3001 and start a capture on eth0. Saved captures land in docker/captures/.
The simulator exposes the following BACnet objects:
Object types follow standard BACnet/HVAC practice: sensors are Analog Inputs, fan actuator commands are Analog Outputs, the setpoint is an Analog Value, and the emergency stop is a Binary Value. Instances are numbered per type from 1.
| Type | Object Name | Access | Description |
|---|---|---|---|
| AV:1 | temperature_setpoint_c |
commandable | Desired room temperature (°C) |
| AO:1 | intake_fan_speed_percent |
commandable | Intake fan command (0–100%) |
| AO:2 | exhaust_fan_speed_percent |
commandable | Exhaust fan command (0–100%) |
| BV:1 | emergency_stop |
commandable | Safety kill switch for chiller/fans |
| AI:1 | current_temperature_c |
read-only | Measured room temperature (°C) |
| AI:2 | chiller_speed_percent |
read-only | PI-controlled chiller load (%) |
python3 -m simulator.plc --ini ./config.iniLaunching the simulator does two things:
- Starts the BACnet/IP device
- Spawns the HVAC control loop thread
The simulator no longer draws its own window. Read and write its points over BACnet, with the HMI in Step 2 or with Caldera.
If the program fails to start, verify Python, dependencies, and the .ini file. Any exceptions should be printed in the console.
Run the HMI on a different machine or container than the simulator, since BACnet/IP needs UDP 47808 on each end. On a single machine, run them with Docker.
python3 -m simulator.hmi --device-ip <simulator IP>To "attack" the device, one can access property values (i.e., read) and modify them as desired (i.e., write). Below is an example of the commands to set in the adversary profile in Caldera (or through CLI):
The ReadProperty service is used by a BACnet client to request the value of one property from one BACnet object.
./bacrp <device-instance> <object-type> <object-instance> <property> <index>
./bacrp 101 analog-input 1 presentValue -1
The WriteProperty service is used by a BACnet client to write a value to a specific property of a BACnet object.
./bacwp <device-instance> <object-type> <object-instance> <property> <priority> <index> <tag> <value>
./bacwp 101 analog-value 1 presentValue 8 -1 real 18.0
./bacwp 101 analog-output 1 presentValue 8 -1 real 75.0
./bacwp 101 binary-value 1 presentValue 8 -1 boolean true
Disclaimer: HVACSim models core thermal dynamics, but its primary purpose is to support Caldera/BACnet testing rather than to serve as a fully accurate physical HVAC model.
The simulator continuously computes room temperature using:
- Ambient heat entering from the outside
- Internal server load (internal heat source)
- Airflow-based cooling
- Chiller-based cooling
- Temperature sensor noise
- Actuator lag
The temperature differential follows:
dT_dt = ((ambient + internal_load) - room_temp) / room_time_constant \
- (cooling_from_fans + cooling_from_chiller)
The intake and exhaust sliders (or Caldera writes) produce an airflow percentage:
airflow = (intake + exhaust) / 2
Cooling power is proportional to airflow:
cooling_airflow = (airflow / 100) * AIRFLOW_MAX_COOL
The chiller is controlled by a Proportional-Integral (PI) loop:
error = current_temp - setpoint
integral = integral + error * tick
chiller_target = KP * error + KI * integral
The chiller actuator then moves toward the target following:
chiller_speed = chiller_speed + (chiller_target - chiller_speed) * CHILLER_LAG