bacpypes3 Type System Reference: Any, Choice, Real, Time and Schedule Types

Scripts & AutomationBACpypes3PythonBACnet protocolreference
May 24, 2026|14 min read

bacpypes3 mirrors the ASHRAE Standard 135 type system in Python rather than inventing its own. Primitives (Real, Time) subclass Atomic plus a native Python type, so they behave like the builtin they wrap. Constructed types (Sequence, Choice) model SEQUENCE and CHOICE productions. Open types (Any, AnyAtomic) hold tagged content whose concrete type is decided at runtime. Almost every “why did I get a wrapper object instead of a number?” and “why does this constructor raise a type error?” traces back to which of those three families you are holding.

How to Use This Reference

This is a single reference for the bacpypes3 classes people most often land on when an IDE jumps to a definition or a constructor rejects a value that looked correct. Each section covers what the class represents in ASHRAE 135, what its Python base classes actually contribute, and the construction pattern that avoids the usual type errors.

One caveat applies throughout, so it is stated once here rather than repeated in every section: module paths and accessor names have moved between bacpypes3 releases. Import locations in particular are not stable across versions. Treat every import line below as a starting point to confirm against the release you actually have installed, using the verification recipes in Verifying Definitions in Your Installed Release. Do not copy an import path from a blog post — including this one — and assume it resolves.

Jump to a class

The Three Families

Before the individual classes, the shape of the system. BACnet defines application datatypes in three broad groups, and bacpypes3 keeps that division intact:

The practical payoff: when a value surprises you, identify its family first. Atomic values interoperate with native Python operators. Constructed values need their elements supplied by name. Open values hold tagged content and must be cast before you get a native value back.

Real(Atomic, float)

ASHRAE 135 defines REAL as an ANSI/IEEE-754 single-precision (32-bit) floating point value. It is the datatype behind the present-value of an Analog Input, Analog Output, or Analog Value object, and behind most setpoints and process variables you will read or write.

The dual inheritance is the whole point of the class. Atomic contributes the BACnet side: application tag identity, and the encode/decode behaviour that lets the value be serialised onto the wire. float contributes the Python side: arithmetic, comparison, formatting, and the ability to hand the value to any code expecting a number without conversion.

from bacpypes3.primitivedata import Real

x = Real(72.5)
print(float(x))       # 72.5  — works because Real is-a float
print(x + 1.0)        # 73.5  — arithmetic flows through the float base
print(type(x))        # <class 'bacpypes3.primitivedata.Real'>

Writing one to a device follows the ordinary WriteProperty pattern. Note the explicit Real(...) wrapper — passing a bare Python float leaves the library to infer an application tag, which is exactly the ambiguity the wrapper removes:

from bacpypes3.primitivedata import Real, ObjectIdentifier
# ... inside an Application subclass with an established device ...

setpoint_value = Real(68.0)
await self.write_property(
    device_address,                       # e.g. Address("10.0.5.42")
    ObjectIdentifier("analog-value,1"),   # target object
    "present-value",                      # property identifier
    setpoint_value,                       # value
    priority=8,                           # optional, for commandable objects
)

The precision pitfall. REAL is single-precision; Python's float is double-precision. A value that round-trips exactly in Python can come back from a device with a small difference after being narrowed to 32 bits. Never compare a value read from a device to a literal with ==; compare with a tolerance appropriate to the point. This bites hardest in test assertions and in change-detection logic that decides whether to issue a write.

The related trap is priority. Writing to a commandable object without a priority is not the same as writing at the default priority — see BACnet Write Priority Array Explained for what the 16 slots do and how to relinquish cleanly.

Time(Atomic, tuple)

If you expected a datetime.time and got something you can index, this is why. ASHRAE 135 defines TIME as four separate octets — hour, minute, second, and hundredths of a second — and bacpypes3 represents that structure directly:

class Time(Atomic, tuple):
    ...

The choice is deliberate rather than a shortcut. BACnet's TIME can express things datetime.time cannot, so mapping onto the stdlib type would lose information. Each of the four fields may independently carry the value 255, meaning unspecified. That wildcard is how a schedule expresses “every hour at :30” or how a recipient states it does not care about a field. A datetime.time has no way to represent an unspecified minute; a 4-tuple does.

The fields, in order, are hour (0–23), minute (0–59), second (0–59), and hundredth (0–99) — hundredths of a second, not milliseconds. Writing 50 intending 50 ms yields half a second.

from bacpypes3.primitivedata import Time

# A Time IS a 4-tuple: (hour, minute, second, hundredth)
t = Time((9, 30, 0, 0))          # 09:30:00.00

# Because Time subclasses tuple, index and unpack it like any tuple
t[0]                             # 9  (the hour)
hour, minute, second, hundredth = t

# 255 marks a field as "unspecified" (the BACnet wildcard)
any_minute = Time((9, 255, 255, 255))   # 9 o'clock, rest unspecified

# Passing the wrong number of elements is the most common mistake
Time((9, 30, 0))                 # ValueError: 4-tuple expected

Pitfalls. Three recur. Passing a 3-tuple because you forgot hundredths. Passing a datetime.time directly and expecting coercion. And treating 255 as a valid numeric field value in arithmetic — if you average or compare Time fields without filtering wildcards first, the results are nonsense.

Choice(Sequence)

The line class Choice(Sequence) reads like a category error on first encounter: in ASHRAE 135, a CHOICE and a SEQUENCE are opposites. A SEQUENCE carries all of its named elements; a CHOICE carries exactly one of its named alternatives.

The inheritance is about implementation reuse, not semantics. What Choice needs from Sequence is the machinery for declaring named, typed elements: the metaclass handling that collects element declarations off the class body, the encode/decode plumbing that walks them, and the attribute access that exposes them by name. A CHOICE needs an identical declaration mechanism — it just applies a different rule about how many may be populated at once. Subclassing inherits the machinery, then constrains the rule.

That is where _choice and _elements come in. _elements is the declared element table shared with the Sequence machinery: the names and types of every alternative. _choice is the CHOICE-specific marker that flags the type as one-of rather than all-of, so the encoder emits only the populated alternative and the decoder accepts exactly one. Reading them together tells you the full contract of any stock CHOICE type in the library.

# Locate the Choice base class in the installed version
import bacpypes3
from bacpypes3 import constructeddata  # may also be re-exported elsewhere
print(constructeddata.Choice.__module__)
print(constructeddata.Choice.__qualname__)

# Inspect a stock CHOICE type to see _elements in action
from bacpypes3 import basetypes
ts = basetypes.TimeStamp  # example: an ASHRAE 135 CHOICE
print(ts.__mro__)
print(ts._elements)

TimeStamp is the clearest worked example in the standard: a timestamp is a time, or a sequence number, or a datetime — never more than one.

The construction pitfall. Populating two alternatives does not raise at assignment time in every release; it surfaces later as an encoding error or a device rejection, far from the line that caused it. Construct a CHOICE by supplying exactly one alternative by keyword, and when you mutate one, clear the other rather than assuming assignment displaces it.

Any

Any is the open value container modelling ABSTRACT-SYNTAX.&Type — the position in the protocol where the encoder cannot know the datatype in advance, because the actual type is whatever the tag on the wire says it is.

Property access is the clearest case. ReadProperty and WriteProperty carry the property value in a parameter typed as the open type, because one service has to move an analog setpoint, a binary state, a character string, or a structured value depending on which property you addressed. The service encoding is fixed; the value it carries is not. You also meet the open type inside constructed datatypes, wherever a SEQUENCE or CHOICE includes a member whose type depends on context.

Because Any holds tagged content rather than a decoded value, working with it is a two-step process: put a typed value in, cast a typed value out. When you read a property back and got an Any, the reliable approach is to tell bacpypes3 the datatype you expect so it can resolve the open value to a concrete class. The expected type comes from the property you addressed — the standard, or the device's PICS, tells you whether that property is a Real, an Enumerated, a structured type, and so on.

The original BACpypes line exposed this as explicit cast-in and cast-out operations. bacpypes3 reworked the encoding layer substantially, so accessor names and call patterns are not guaranteed to match older examples. Confirm what your installed release provides before relying on a method name.

AnyAtomic(Any)

AnyAtomic is the same open-type idea narrowed to exactly one primitive — no Sequence, no Array, no nested CHOICE. The standard uses it where a slot must stay open but the value is guaranteed to be a single atomic. The inheritance direction follows from that: the restricted form is a special case of the general container, so it subclasses it and tightens what it accepts.

Practically, the choice between the two is not yours to make — it follows from the property's datatype in Standard 135. If the standard permits a constructed value in that position you need Any; if it restricts the position to a primitive you need AnyAtomic, and supplying a constructed value will be rejected. Check the property's declared datatype rather than guessing.

The place most people first hit it is the Schedule object, where the scheduled values in a TimeValue are typed as AnyAtomic — see TimeValue and DailySchedule below.

Getting the value out. An AnyAtomic read back from a device is a wrapper, not a number. Resolve it by casting to the concrete type the property declares. The failure mode to watch for is code that appears to work because str() or an f-string renders the wrapper sensibly, while the value is still not a number — the error then surfaces downstream in arithmetic or in a comparison that silently returns False.

TimeValue(Sequence)

TimeValue is a genuine SEQUENCE — an ordered record carrying both of its elements, which is why it subclasses Sequence honestly rather than for reuse the way Choice does:

class TimeValue(Sequence):
    ...

Its two elements are the ones you have to get right, and they come from different families:

Nearly every TimeValue type error is one of those two substitutions. The pairing is what a schedule entry fundamentally is: at this time, this value takes effect.

DailySchedule and Weekly_Schedule

These compose the pieces above into the Schedule object, and the nesting is where people get lost. Working outward from the value:

Three pitfalls account for most failures. Supplying fewer than seven days — the array is fixed-length, and a short list is rejected. Assuming Sunday-first ordering, which produces a schedule that runs correctly but on the wrong days, the worst kind of bug because nothing errors. And supplying bare Python values for the value element instead of wrapping them, which fails at encode time with a message that points at the schedule rather than at the value.

An empty day-schedule is legal and meaningful: it means no scheduled transitions that day, so the object falls back to its default. That is different from omitting the day.

Verifying Definitions in Your Installed Release

Because import paths move between releases, verify against the code you have rather than against any guide. Start by locating the package and grepping for the class:

# Find where bacpypes3 is installed
python -c "import bacpypes3, os; print(os.path.dirname(bacpypes3.__file__))"

# Grep that directory for the class definition
grep -rn "class AnyAtomic" /path/printed/above

Then confirm inheritance and available methods at runtime, which is more reliable than reading a file you may have found in the wrong version:

import inspect
import bacpypes3.primitivedata as pd

# Path to the primitivedata.py that ships with your installed release
print(inspect.getsourcefile(pd))

# Print a class definition directly
print(inspect.getsource(pd.Time))

# Confirm the MRO and the public method surface
print(pd.Real.__mro__)
print([m for m in dir(pd.Real) if not m.startswith("__")])

Record the version alongside any code you keep — pip show bacpypes3 — so a future reader knows which release the import paths were confirmed against.

Common Pitfalls at a Glance

When to Escalate

If a value encodes correctly in Python but the device rejects it, capture the exchange before assuming a library bug — Wireshark Display Filters for BACnet shows how to isolate the APDU and read the tag that was actually sent. A mismatch between the tag on the wire and the property's declared datatype localises the problem immediately. If the tag is correct and the device still refuses, the question belongs upstream with the library or the device vendor rather than in your code.

For getting started with bacpypes3 rather than debugging its type system, see BACpypes3: Custom BACnet Applications in Python. For a higher-level library that hides most of this machinery, see Python BACnet Scripting with BAC0.

Source Attribution

The technical guidance in this entry is informed by the following sources:

Class behaviour described here was checked against bacpypes3 v0.0.106. Additional testing and field validation by SiteConduit.

BACpypes3PythonBACnet protocolreferencetype systemASHRAE 135

Was this article helpful?

Related Articles

Need to do this remotely? SiteConduit provides Layer 2 access that preserves BACnet broadcasts — no BBMD needed for remote sessions. Join the waitlist.

SC

SiteConduit Technical Team

Idea Networks Inc.

SiteConduit builds managed remote access for building automation. Our knowledge base is maintained by BAS professionals with hands-on experience deploying and troubleshooting BACnet, Niagara, Modbus, and Facility Explorer systems.