README.md says what this example is. This document is the how: how to extend it into your own device, who serves which property, how to review the result for conformance, and what goes wrong when you get it subtly right.
Read this once before you start changing main.py. The most expensive
mistake in this example is silent, and the section it lives in is
Add a second analog input.
- Extending the example
- What each object type needs you to serve
- Who serves what: the application or the stack?
- Interactive keys are a simplification
- Reviewing your device
- Troubleshooting
The example is intentionally small so it's easy to change.
Change a sensor's name - edit the "Bronze" (etc.) string in
get_property_character_string in main.py.
Replace the Simulation with your hardware - section 2 of main.py
(sensor_simulation_tick()) is the only code that changes the sensor values;
the get_property_* callbacks just hand back g_analog_input_1_value,
g_binary_input_1_value and g_multi_state_input_1_value. Replace the
function body with your own sampling and keep the callbacks as they are. Sample
on a timer and only store the result: the callbacks run inside
BACnetStack_Tick(), so a callback that waits for an ADC conversion or an I2C
transaction delays every BACnet request. (If you sample on another thread, that
thread only stores values and never calls a BACnetStack_* function - the
stack is single-threaded.) Delete SENSOR_FAULT_FILE_VARIABLE and its
environment-variable code - it is a test hook, not something to ship.
Report a sensor fault - Reliability is how a BACnet sensor says "do not
trust this reading" (cl. 12.2.9). It is optional, so it takes two steps: enable
it with BACnetStack_SetPropertyEnabled(... PROPERTY_IDENTIFIER_RELIABILITY ...)
and serve it from get_property_enumerated. The stack then builds the FAULT
bit of Status_Flags from it by itself (cl. 12.2.7) - you never serve
Status_Flags. Once it is enabled, always answer it: the stack reads
Reliability through your callback every time a client reads Status_Flags,
and a callback that declines does not fail loudly - the stack substitutes the
datatype default, 0, which is no-fault-detected. A forgotten branch
therefore reports a healthy sensor forever, with FAULT never set.
(tests/failure_modes_test.py is what catches that: it breaks the sensor and
expects the fault.) Set it from your hardware's own diagnostics - open-loop
(4) for a broken wire, shorted-loop (5), over-range (2) / under-range (3)
for a reading on the rails, no-sensor (1) for a sensor that stopped
answering. If you add Out_Of_Service writes later, cl. 12.2.10 makes
Reliability writable while the object is out of service.
Change the device identity before you ship - vendor ID, vendor name,
model name, description, firmware revision and device name are all in the
CHANGE ALL OF THIS BEFORE YOU SHIP block at the top of main.py §1, with
a per-field note on each saying what to change it to. That block is the
authoritative checklist; it is in the source rather than here so it cannot be
skipped by someone who only reads the code. Keep the application-owned socket
cas_example_helper.register_common_callbacks()shape, the habit of checking everyBACnetStack_*return value, and the callbacks-rooted-in-a-module-level-list pattern - those are part of the shape, not the identity, and every sibling example (C++ or C#) keeps the first two of those too.
Read this whole recipe before starting - the last step is the one that is easy to miss and the one BTL will fail you for.
Why there are four edits, not three - and why skipping one is SILENT. Most of the
get_property_*callbacks match on both object type and instance (object_instance == ANALOG_INPUT_INSTANCE), so a new instance falls through every one of them.get_property_bool'sOut_Of_Servicebranch is the exception: it matches onproperty_identifier(andobject_type) alone, soOut_Of_Serviceworks for a new instance for free.Here is the part that matters, and it is the opposite of what most people assume. Falling through a callback does not reliably produce an error. The stack errors only for the few properties it refuses to invent -
Present_Value,Number_Of_States,Relinquish_Default,Local_Date,Local_Time, and a Network Port'sAPDU_Length. For everything else it silently substitutes a default:
Property If you forget to serve it Loud? Present_ValueError ( read-access-denied)yes Object_Namereads back as the literal string "undefined"no Unitsreads back as no-units(95)no Out_Of_Serviceserved on property + type alone - works by accident n/a Doesn't the
errorCodeout-parameter fix this? Only if you use it, and only where it is right to. Eachget_property_*callback receives a trailingerrorCodepointer that the stack pre-seeds withsuccessand reads only when you returnFalse, so you can turn any decline into a chosen BACnet error. But writing an error code on the catch-all breaks the device: the stack's decline-and-fabricate path is what answers required properties an application is not expected to serve - the Device'sMax_APDU_Length_Accepted,APDU_TimeoutandNumber_Of_APDU_Retriesamong them. Writeerror_code[0]only where this device knows the read is wrong;main.pydoes it in exactly one place -State_Textwith an out-of-range array index (cas_bacnet_stack_example_constants.ERROR_CODE_INVALID_ARRAY_INDEX).It is worse than "wrong value": the object's
Property_Liststill advertisesUnits(117). So the object actively claims to have the property, and then answers with a default. Nothing on the wire says you forgot anything.So a half-added object does not look broken; it looks healthy. Add two of them and both report
Object_Name "undefined"- duplicate object names inside one device, which is a spec violation and a hard BTL failure that every scan tool will render as a perfectly good object. "It scanned OK" is exactly the failure mode, not evidence against it.
# 1) a new instance number (in §1 Configuration).
# Naming: a second object of a type is "<Colour> 2" - so Analog Input 2 is
# "Bronze 2", NOT a new colour. Each object TYPE owns one colour series-wide.
ANALOG_INPUT_2_INSTANCE = 2 # "Bronze 2"
g_analog_input_2_value = 23.1 # its live value
# 2) add the object (in main(), next to the other BACnetStack_AddObject calls).
# Check the return, like every other stack call in this file.
if not bacnet.BACnetStack_AddObject(g_device_instance, c.OBJECT_TYPE_ANALOG_INPUT, ANALOG_INPUT_2_INSTANCE):
print("Error: Failed to add Analog Input 2 (Bronze 2).")
return 1
# 3) serve its Present_Value + Object_Name:
# get_property_real: AI/2 + Present_Value -> value[0] = g_analog_input_2_value
# get_property_character_string: AI/2 + Object_Name -> return_character_string("Bronze 2", ...)
# 4) DO NOT SKIP: serve its Units, in get_property_enumerated.
# Units is a REQUIRED property of an Analog Input. The existing check reads
# `object_instance == ANALOG_INPUT_INSTANCE`, which is instance 1 - so
# without this, reading Analog Input 2's Units returns no-units(95) instead
# of erroring, and the object is NON-CONFORMANT. It will still appear in
# Object_List and its Present_Value will read back perfectly, so the device
# looks healthy right up until BTL certification.
# get_property_enumerated: AI/2 + Units -> value[0] = c.ENGINEERING_UNITS_DEGREES_CELSIUSThen re-run the README's Verify steps against Analog Input 2, not just
Analog Input 1 - read every required property and diff it against Analog
Input 1. Any property that comes back "undefined", no-units, or 0
where object 1 returns something real is a step you missed. Because the
failure is silent (see the table above), this diff is the only thing that
catches it.
There is no commandable-output section in this tutorial - B-SS has no
outputs: every object in this profile is a read-only input, so there is no
priority array, no Relinquish_Default, no Set*Property callback anywhere
in main.py. If you need a commandable object (Analog Output, Binary
Output, Multi-State Output, or a Value object with Priority_Array
enabled), that is a different profile (B-SA or B-ASC) - see the C++ or C#
editions of the example series for the commandable-output model
(Commandable, CommandWrite, CommandRelinquish) and the
Set*Property callback shape it requires (there is no Python edition of
that example yet - port the shape using this example's ctypes conventions).
Bolting that model onto this example without also enabling WriteProperty
(SERVICE_WRITE_PROPERTY) and the object's
Priority_Array/Relinquish_Default properties would not make the object
writable anyway - review
§4 Application services supported
in the PICS before you reach for it.
The recipe that is relevant to a Smart Sensor - adding another read-only
input of a different type - follows the same four-step shape as
Add a second analog input above: a new instance
number, BACnetStack_AddObject, Object_Name + the type's other required
value property (Polarity for a Binary Input, Number_Of_States for a
Multi-State Input), and a diff against the existing object of that type.
The application must serve every REQUIRED property the stack does not generate. It differs per type - this is the checklist, so you do not have to infer it:
| Object type | You must serve | Plus |
|---|---|---|
| Analog Input | Present_Value (Real), Object_Name, Units |
Out_Of_Service |
| Binary Input | Present_Value (Enumerated), Object_Name |
Polarity, Out_Of_Service |
| Multi-State Input | Present_Value (Unsigned), Object_Name |
Number_Of_States, Out_Of_Service |
Out_Of_Service is served once, in get_property_bool, matched on
property_identifier alone across all three input types plus the Network
Port - it is the one callback in this file that does NOT also match on
object type/instance, which is exactly why it is safe to leave unmatched: a
read-only sensor is never out of service, so the answer is always False.
The single most common question when reading this file is "who answers this property?" For Analog Input 1, the whole picture:
| Property | Served by | How |
|---|---|---|
Object_Identifier |
stack | generated from the object you added |
Object_Type |
stack | generated |
Object_List |
stack | generated (Device object) |
Property_List |
stack | generated |
Status_Flags |
stack | generated |
Event_State |
stack, sort of | no intrinsic alarming here, so nothing serves it - it reads normal only because normal is the enumeration's zero value and the stack substitutes a datatype default. Correct by coincidence, not design. |
Out_Of_Service |
you | get_property_bool - matched on property (+ type) only |
Present_Value |
you | get_property_real |
Object_Name |
you | get_property_character_string |
Units |
you | get_property_enumerated |
Reliability (optional, enabled) |
you | get_property_enumerated - no-fault-detected, or open-loop while the simulated wire is broken |
Status_Flags FAULT bit |
stack | derived from Reliability (cl. 12.2.7) |
Every object, not just this one, is in docs/PICS.md.
Two more traps worth calling out explicitly, both silent:
- Two
networkType/networkPortInstanceidentifiers, easy to confuse. The Network Port object'sNETWORK_PORT_NETWORK_TYPE_IPV4(5, a property enumeration passed toBACnetStack_AddNetworkPortObject) is a different thing from the link identifier every transport call andSendIAmuse - the Network Port object's own instance (NETWORK_PORT_INSTANCE, 1 in this example), whichcommon/cas_example_helper.py'sregister_common_callbacks()andsend_i_am()both take as a parameter. Mixing them up runs without a Python exception (both are plain integers) and can silently misroute a multi-port device; a single-port example like this one is forgiving of the mistake, which is exactly why it is worth naming. - The stack's own errorCode default is NOT "no error."
get_property_character_string'sState_Textbranch is the one place this file writeserror_code[0]- it is pre-seeded withsuccess, so areturn Falsethere without setting it would put "Error Class PROPERTY, Error Code success" on the wire instead ofinvalid-array-index. Every otherreturn Falsein this file leaveserror_codeuntouched on purpose (see the callout in Add a second analog input above).
Going beyond this (WriteProperty, COV, alarms, scheduling) means implementing a richer profile - see the series table in README.md.
The keys (h, q, up / down) are read by common/cas_example_helper.py's
poll_key(), the Python edition of the C++ common/'s PollKey(), called once
per pass of main()'s loop. It never blocks, so BACnetStack_Tick() keeps
running:
- Windows console -
msvcrt.kbhit()/getch(), like the C++ edition's_kbhit()/_getch()(msvcrtreports an arrow key as a two-byte prefix + code). - POSIX - stdin is made non-blocking and, when it is a terminal, put into
cbreak mode; each call
os.read()s what is waiting and decodes theESC [ A/ESC [ Barrow sequences - the C++ edition'sEnableRawInput()+read().restore_input()puts the terminal back on exit. - Windows, stdin redirected (a pipe) - a pipe there has no non-blocking
"is a key waiting" check, so a daemon thread reads it and queues
q/hforpoll_key(). That thread touches only that queue, never the stack or the socket. It is what lets the Wire Tests stop the example by writingqon every platform (the C++ edition ignores a redirected stdin on Windows).
A pipe carries no arrow keys, so on a redirected stdin only h and q work.
Ctrl+C / SIGTERM stop the device cleanly in every case, through the
signal.signal() handlers registered in main().
After you have changed anything, review it against the conformance statement rather than against "it looked fine in the explorer":
- Regenerate docs/PICS.md after editing
docs/objects.json(see Keeping the PICS honest below). A ⚠ row is a required property nothing serves. - Read every property listed for every object with a BACnet client,
and compare the value against the PICS.
"undefined",no-unitsand0are the three shapes a missed callback takes. - Diff a new object of a type against the existing one of that type. Anything that differs and shouldn't is a callback that matched on instance.
- Send a WriteProperty to any object and confirm it is rejected - a B-SS
Smart Sensor is read-only; this example never enables WriteProperty, so the
stack answers Reject
unrecognized-service. - Read
State_Text[4]on Multi-State Input 1 (which only has 3 states) and confirminvalid-array-index, not an empty string. - Run the Wire Tests (
python tests/run_wire_tests.py, README "Wire Tests"). They do steps 2, 4 and 5 over the wire, check the readings follow the Simulation, and break the simulated sensor wire to checkReliabilityand the FAULT bit.
docs/PICS.md is partly generated. docs/objects.json describes each object
and who serves which property; the series tool regenerates the object tables
from it plus the stack's own docs/property-profile-reference.md at the
pinned commit:
python3 tools/gen-objects-properties.py BACnetProfileExample-B-SS-Python # rewrite
python3 tools/gen-objects-properties.py BACnetProfileExample-B-SS-Python --check # fail if stale(That tool lives in the example-series repository, not in this one. If you
only have this repository, edit the generated block by hand and keep it
matching the callbacks in main.py.)
When you add an object or a property to main.py, update
docs/objects.json in the same change and regenerate. The app list is what
the callbacks serve; accepted is for a required property you deliberately
leave to the stack's default, and each one needs a justification. Anything
required, not in app and not in accepted, comes out as a ⚠ row - that is a
defect, not a feature.
| Symptom | Cause / fix |
|---|---|
ERROR: This example cannot run without the CAS BACnet Stack library. and exit code 1, with Missing file: CASBACnetStack_x64_Release.dll (or libCASBACnetStack_x64_Release.so) |
The stack library is in neither folder listed under Looked in: - the folder that holds main.py, then the checkout's submodules/cas-bacnet-stack/bin/. Build it (README.md Build the CAS BACnet Stack library); a release never includes it. The same message appears if the file is there but cannot be loaded (wrong architecture, built for another OS, or from another stack commit than the adapter): rebuild it from the submodule as it is checked out now. |
The same message with Missing file: CASBACnetStackAdapterBindings.py |
The library was found but the stack's Python adapter was not: check out the submodule (git submodule update --init --recursive), or, running a release, put CASBACnetStackAdapter.py and CASBACnetStackAdapterBindings.py from the submodule's adapters/python/ next to main.py. |
OSError mentioning a mismatched architecture (%1 is not a valid Win32 application, or similar on Linux) |
The native library's architecture does not match this Python interpreter's. This example expects an x64 build - make sure both your Python interpreter and the DLL/.so you built are x64. |
| App prints "Failed to bind UDP port 47808" | Another BACnet program is already using 47808. Stop it, or run with --port <n>. |
| Client sends Who-Is but sees no I-Am | Firewall is blocking UDP 47808, or the client and device are on different subnets (Who-Is is a broadcast). Allow the port; test on the same subnet first. |
| A WriteProperty you sent is rejected | Correct behaviour - this device is read-only. WriteProperty is never enabled and there is no BACnetStack_RegisterCallbackSetProperty* call anywhere in main.py; the stack answers Reject unrecognized-service. |
Analog Input 1 Reliability reads open-loop and the reading is frozen |
The test-only broken-wire hook is on: CAS_EXAMPLE_TEST_SENSOR_FAULT_FILE names a file that exists. Unset the variable or delete the file. |
| The process crashes with no Python traceback, sometime after start-up | Almost always an unrooted ctypes.CFUNCTYPE callback that was garbage-collected. Grep your changes for CFUNCTYPE( and confirm every result is appended to a module-level list before being passed to a RegisterCallback* call - see AGENTS.md's callback-rooting rule. |