Easy ASO is a lightweight, Python-based orchestration layer that acts as the easy button for Automated Supervisory Optimization (ASO) in BACnet-powered HVAC systems. Built on top of an asyncio-first architecture, it provides a clean and modern control loop for energy optimization strategies while handling all BACnet/IP communication in the background.
Whether used as a standalone service, embedded inside an EMIS pipeline, or integrated into a larger IoT/microservice framework, Easy ASO simplifies the complex task of supervisory control. Its design supports BACnet out of the box and can be extended to additional building automation protocols as your platform evolves.
Control BACnet systems on a simple on_start, on_step, and on_stop structure:
class CustomHvacAso(EasyASO):
async def on_start(self):
print("Custom ASO is deploying! Lets do something!")
async def on_step(self):
# BACnet read request
sensor = await self.bacnet_read("192.168.0.122", "analog-input,1")
# Custom step logic - BACnet write request
override_valve = sensor + 5.0
await self.bacnet_write("192.168.0.122", "analog-output,2", override_valve)
print("Executing step actions... The system is being optimized!")
await asyncio.sleep(60)
async def on_stop(self):
# Custom stop logic - BACnet release request
await self.bacnet_write("192.168.0.122", "analog-output,2", 'null')
print("Executing stop actions... The system is released back to normal!")Preproject Exploring Remote BACnet Sites
The tester.py script, located in the scripts directory, provides a utility for exploring a remote BACnet site via the bacpypes3 console.
This tool is designed to assist in the setup and configuration of the easy-aso project, streamlining the integration process.
For detailed information and instructions on using the Tester.py script, please refer to the setup_scripts directory README for more information.
- Read Property (
read_property) - Write Property (
write_property) - Read Property Multiple (
read_property_multiple) - Read Priority Array (
read_propertywithpriority-array) - Device Discovery (
who-is) - Object Discovery (
who-has) - Read All Points (
do_point_discovery) - Router Discovery (
who_is_router_to_network)
Getting Setup and Running Tests
Make sure you run system updates in Linux and install Docker and Docker Compose before proceeding.
First, (I'm on Windows) clone the easy-aso repository to your local machine:
git clone https://github.com/bbartling/easy-asoRun these bash commands from project root directory after cloning. Make sure docker-compose alias works:
echo "alias docker-compose='docker compose'" >> ~/.bashrc
source ~/.bashrc
docker-compose versionSetup Python Environment:
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -e ".[test]"
pytest
You should notice in console when tests are completed.
collected 5 items
tests/test_abc.py .... [ 80%]
tests/test_bacnet.py . [100%]
The test suite verifies two major behaviors in the system. The first set of tests ensures the EasyASO abstract base class behaves correctly by enforcing the required method contract for any ASO application. These tests confirm that subclasses must fully implement on_start, on_step, and on_stop, that argument parsing for flags such as --no-bacnet-server works correctly, and that improper subclasses raise errors when abstract methods are missing. Together these checks guarantee a stable API boundary before any BACnet communication logic is ever exercised.
The second test validates full BACnet communication between two simulated devices running inside Docker containers: a fake BACnet device and a fake easy-aso instance. Over roughly fifteen seconds of runtime, the test confirms the client can read, write, and release BACnet points across the bridge network defined in the Compose file, including alternating present-value writes, null-priority releases, and the end-to-end kill-switch logic based on optimization status. After execution, container logs are inspected to assert that no Python errors occurred, that optimization toggled True/False as expected, and that all overrides were successfully released during shutdown. This test ensures the complete lifecycle of read, write, override release, and kill-switch behavior works correctly in a realistic BACnet/IP environment.
Examples and Best Practices
This project is designed so that you can start with simple ASO “bots” and grow
into more capable HVAC optimization services without rewriting the core
plumbing. The pattern is straightforward: keep main.py clean and declarative,
and put all optimization logic into dedicated algorithm.py modules. Each
example follows the same layout:
algorithm.py # ASO logic (reads, writes, decisions)
main.py # orchestration only (wires things together)
The bacnet_ping_pong example shows a single algorithm reading a BACnet sensor
point and writing to a command point (e.g., AV2) at a defined priority. In real
HVAC applications this pattern can evolve into meaningful strategies:
- Treat AV1 as a sensor (supply temperature, zone temp, or measured kW)
- Treat AV2 as a command (fan speed, valve position, discharge temperature)
- Use the built-in EasyASO optimization flag (
optimization_enabled_bv) as the enable/disable kill switch, accessible via the local methodget_optimization_enabled_status() - Add incremental control logic in
algorithm.py(reset curves, demand limits, deadband logic)
The example below shows how the built-in kill switch is checked before running
any optimization logic. This kill switch is not a network request; it is a
local boolean stored in the EasyASO app and also exposed as
binaryValue,1 to the BAS.
import asyncio
import random
from easy_aso import EasyASO
BACNET_DEVICE = "bacnet-server"
AV1 = "analog-value,1"
AV2 = "analog-value,2"
WRITE_PRIORITY = 10
INTERVAL = 5.0 # seconds between steps
class BacnetPingPongAso(EasyASO):
"""
Demonstrates a basic BACnet-read/write ASO controller using EasyASO.
Flow:
- Honor the built-in optimization kill switch (optimization_enabled_bv)
- Read present-value of AV1
- Write to AV2 at the configured priority
- Release overrides safely on stop
"""
async def on_start(self):
print("[BacnetPingPongAso] on_start: starting BACnet ping-pong controller")
async def on_step(self):
# Local optimization enable flag (built-in BV1 exposed to the BAS)
optimization_enabled = self.get_optimization_enabled_status()
if not optimization_enabled:
print("[BacnetPingPongAso] Optimization disabled (kill switch).")
await self.release_all()
await asyncio.sleep(INTERVAL)
return
print("[BacnetPingPongAso] on_step: polling BACnet points")
av1_val = await self.bacnet_read(BACNET_DEVICE, AV1)
av2_prev = await self.bacnet_read(BACNET_DEVICE, AV2)
print(f" AV1 pv: {av1_val}")
print(f" AV2 pv before write: {av2_prev}")
new_val = random.uniform(0.0, 100.0)
print(f" Writing {new_val:.2f} → AV2 @ priority {WRITE_PRIORITY}")
await self.bacnet_write(BACNET_DEVICE, AV2, new_val, WRITE_PRIORITY)
await asyncio.sleep(INTERVAL)
async def on_stop(self):
print("[BacnetPingPongAso] on_stop: releasing all overrides…")
await self.release_all()
print("[BacnetPingPongAso] on_stop: shutdown complete")And a simple main.py orchestrates it:
import asyncio
from .algorithm import BacnetPingPongAso
async def main():
bot = BacnetPingPongAso()
await bot.run()
if __name__ == "__main__":
asyncio.run(main())Run the example:
python main.pyYou can run multiple ASO algorithms at once. A common pattern is to keep each
strategy in its own module (such as a supply-air reset, demand-limit module,
and a ping-pong diagnostic task) and wire them together in one main.py using
asyncio.gather. Telemetry publishers (MQTT, HTTP, file logs) can be included
the same way.
# main.py
import asyncio
from .supply_air_reset.algorithm import SupplyAirResetAso
from .demand_limit.algorithm import DemandLimitAso
from .bacnet_ping_pong.algorithm import BacnetPingPongAso
from .telemetry.mqtt_publisher import MqttTelemetry
async def main():
supply_reset_bot = SupplyAirResetAso()
demand_limit_bot = DemandLimitAso()
ping_pong_bot = BacnetPingPongAso()
telemetry_task = MqttTelemetry(
topic_prefix="building/easy-aso",
interval_seconds=10.0,
)
await asyncio.gather(
supply_reset_bot.run(),
demand_limit_bot.run(),
ping_pong_bot.run(),
telemetry_task.run(),
)
if __name__ == "__main__":
asyncio.run(main())This structure keeps each algorithm independent while allowing a single process to coordinate several optimization routines and telemetry publishers. It also makes it simple to move algorithms into separate containers or supervisors without modifying the optimization code. Everything runs under asyncio, including the underlying BACnet stack, so the same design can be extended into a FastAPI service for edge deployments.
Everything here is MIT Licensed — free, open source, and made for the BAS community.
Use it, remix it, or improve it — just share it forward so others can benefit too. 🥰🌍
【MIT License】
Copyright 2025 Ben Bartling
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.