Skip to main content

ace-bacnet-devices

Proprietary BACnet extensions, extracted from vendor PICS conformance statements and made usable from bacpypes3.

Vendors declare their proprietary properties and object types in the PICS (Protocol Implementation Conformance Statement) they publish for each product. Those documents are public but unstructured — PDFs of hand-built tables, a different layout per vendor. This project reads them into a semantic model and turns that model into registered extension handlers, so a proprietary property decodes as a typed value instead of an opaque blob.

Using the extensions

pip install ace-bacnet-devices[bacpypes3]
import bacpypes_extensions.all  # every bundled vendor

value = await app.read_property(address, ("device", 1), "serial-number")

Or register a single vendor:

import bacpypes_extensions.carrier

Both are plain imports with no configuration. The same thing as function calls, which is often clearer inside an application:

from bacpypes_extensions import load, load_all, loaded

load("carrier", "johnson-controls")
load_all()
loaded()  # {16: 'Carrier', 5: 'Johnson Controls Inc.', ...}

Importing the bacpypes_extensions package itself does nothing. Python runs a package's __init__ before any submodule, so a package that registered everything on import would make import bacpypes_extensions.carrier register everything plus Carrier — the opposite of asking for one vendor. Registration lives in the submodules, so what you import is what you get. It is idempotent per vendor id.

What ships

Definition Vendor id(s) Properties Object types
johnson-controls 5 406 32
tac-micronet 11 (also 10, 335) 70 5
honeywell-international 17 54 16 + 6 proprietary
carrier 16 29 5
automated-logic 24 29 5
oemctrl 446 29 5
ace-bacnet vendor list                     # what's bundled
ace-bacnet vendor show johnson-controls    # every property, with datatype and range
ace-bacnet vendor ids "Schneider Electric" # resolve a name to BACnet vendor ids

Extracting from your own PICS documents

Source PICS files are third-party vendor publications and are not distributed with this project. Point the extractor at a local folder of them:

ace-bacnet extract /path/to/pics/                     # a folder, searched recursively
ace-bacnet extract mydevice.pdf -o extracted/         # one document
ace-bacnet extract /path/to/pics/ -o extracted/ --vendor-id 16

Each document becomes a VendorExtensions — the same shape as the bundled definitions — so anything you extract loads exactly like the ones that ship:

from ace_bacnet_devices import install, vendors

install(vendors.load_file("extracted/mydevice.json"))

To run the extraction-agreement tests, set ACE_BACNET_PICS_DIR to a folder holding F-27461-2.pdf (the TAC MNB-1000 statement). Without it those tests skip.

How it works, and why it is careful

The datatype a vendor declares is the wire type

PICS tables routinely declare a property Unsigned and then describe an enumeration in prose — BBMD_Mode is "0 – IP node only, 1 – BBMD mode, 2 – foreign device". Emitting that as a BACnet Enumerated would move the application tag from 2 to 9 and silently mis-decode every read from a real device.

So every generated datatype subclasses the primitive the PICS declared. Encoding is inherited unchanged — there is a byte-level parity test against the base primitive for each one — and the subclass adds:

  • range limits, enforced on construction and on decode, so an out-of-range response from a device fails loudly;
  • length limits, since neither stack constrains OctetString/CharacterString and the documents state "8 octets" / "20 characters max.";
  • documented value names, as inert metadata. Naming a value must never affect encoding.

Extraction never guesses

Anything uninterpretable becomes a typed issue rather than a wrong declaration:

  • an unreadable datatype drops the property — a guessed primitive mis-decodes traffic, which is worse than a missing handler;
  • constructed types (Complex, Structure, Object Reference) are refused explicitly: there is no primitive to subclass, so no handler can exist;
  • a value legend needs two distinct entries before it is emitted;
  • "1, 4, or 6 octets" yields no length, because enforcing one would reject valid values;
  • an ExtractionResult holds no vendor id. A document can parse perfectly and still not be installable, because the vendor id is not in it — .build(vendor_id=…) is a separate, explicit step.

Four document layouts

The corpus is not one format. Three table layouts and one text layout are read, and they differ in where the proprietary marker lives:

GROUPED   | Object Type | Proprietary Properties: Name | ID | Datatype | Access | Range |
FLAGGED   | Property Name | Required | Optional | Proprietary | Property ID | Data Type |
COMBINED  | Property name | R/O/P | Supported | Property ID, Range, Data Type |
OUTLINE     5. Proprietary properties:
            4145 active-locale-index unsigned

A fifth shape — a single cell listing property names with no identifier and no datatype — cannot be read by any parser, because the document simply does not contain what a handler needs.

Vendor ids, consolidation, and ambiguity

Proprietary numbers are scoped per BACnet vendor id, but a PICS names its author only in prose. The id comes from ASHRAE's registry, bundled here as a snapshot (vendor-ids.json, 1638 entries).

Lookup deliberately refuses to guess between candidates, because a wrong vendor id silently mis-scopes every property. Two mechanisms handle the fallout:

  • VendorGroup — ids that may carry the same declarations. Derived from the registry where one organisation holds several (Siemens Schweiz AG is 7, 9 and 22), and curated with evidence for acquisitions: Schneider / TAC is 10, 11, 335, because the MNB family spans a 2008 TAC-branded statement and a 2022 Schneider-branded one for the same controllers. Installing registers under all of them, so a fleet spanning an acquisition decodes whichever id its firmware reports.
  • VendorOverride — a recorded human decision, with its reasoning, for a name the registry cannot disambiguate. "Carrier" matches three entries; 16 was chosen. The ambiguity stays visible via find(name, use_overrides=False).

Same-name grouping matches on the exact registered name, never the fuzzy lookup key — that key strips corporate suffixes, which would merge TAC (11) with TAC AB (19), a different company sharing a trading name.

Merging a vendor's documents

A vendor publishes one PICS per product, so the same property recurs across many files. VendorExtensions.merge_all() collapses them into one installable definition and reports genuine disagreements rather than silently overwriting. Merging is where the model gets stress-tested — 27 Johnson Controls documents disagree in hundreds of places:

  • a datatype disagreement has no safe resolution, so the property is dropped by default (binaryValue.DEFAULT_VALUE is Real in one document and Boolean in another);
  • a name disagreement is cosmetic, since the identifier is what travels on the wire;
  • PDF artifacts are folded rather than treated as conflicts — a line break truncates a name (continuous-continuous-proportional, prefer the longer) and a footnote marker attaches to one (ITEM_REFERENCE5ITEM_REFERENCE, prefer the shorter). What tells them apart is that a footnote adds only digits.

Registrability is enforced in one place used by both extraction and merging, because two documents can be unregisterable together while neither is alone.

The legacy bacpypes stack

Legacy bacpypes cannot be imported on Python 3.12+ — it depends on the removed asyncore module. Its adapter therefore emits source rather than registering at runtime:

ace-bacnet vendor generate-legacy tac-micronet -o tac_legacy.py

The generated module is syntax-checked and its guard logic unit-tested against stub bases on every supported interpreter. On 3.10 and 3.11 — where asyncore still exists, so real bacpypes installs — it is additionally executed against the real stack: every generated datatype is encoded and compared byte for byte against the primitive it subclasses, and each declared maximum is checked to actually reject an out-of-range value. Those tests skip on 3.12+.

Supported Python versions

3.10 and up, tested on 3.10, 3.11, 3.12 and 3.13. The floor is set by the dependencies — typer and pdfminer.six both require 3.10.

The only version-dependent code is enum.StrEnum, which arrived in 3.11; ace_bacnet_devices._compat supplies it on 3.10. A bare class Foo(str, Enum) is not equivalent — it stringifies as Foo.BAR rather than bar — and these values are written into JSON and interpolated into generated source, so the shim pins __str__ and __format__ to str exactly as the standard library does.

Development

uv sync --all-extras
uv run pytest
uv run ruff check . && uv run ruff format --check .
uv run pyrefly check
uv run pre-commit install

To run the suite against the oldest supported interpreter, which also enables the real bacpypes tests:

uv run --python 3.10 pytest

Regenerate derived files after changing the bundled definitions:

uv run python scripts/build_tac_mnb1000.py        # the hand-transcribed test oracle
uv run python scripts/build_extension_modules.py  # per-vendor import shims

License

MIT — see LICENSE.

The bundled vendor definitions are factual data extracted from publicly published BACnet conformance statements. The source documents themselves are the property of their respective vendors and are not distributed here.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ace_bacnet_devices-0.2.0.tar.gz (224.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ace_bacnet_devices-0.2.0-py3-none-any.whl (127.1 kB view details)

Uploaded Python 3

File details

Details for the file ace_bacnet_devices-0.2.0.tar.gz.

File metadata

  • Download URL: ace_bacnet_devices-0.2.0.tar.gz
  • Upload date:
  • Size: 224.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ace_bacnet_devices-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c1789e0049f6c9139844ee3dad0de86083fd41e3929d1b4e9eb93f4db3271d0e
MD5 e21c13df277169229e51dd4138c8e616
BLAKE2b-256 9121882fa1d3a959791be66ab3a6bb3cae63dd7e1a1f17111e63d9d2cd32904f

See more details on using hashes here.

Provenance

The following attestation bundles were made for ace_bacnet_devices-0.2.0.tar.gz:

Publisher: pypi-publish.yml on ACE-IoT-Solutions/ace-bacnet-devices

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ace_bacnet_devices-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for ace_bacnet_devices-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a2bca3ac1911f85bf6b4fc65bccee9c6a98bf5c67316b6baee66f12cf2fb64b1
MD5 6c1845792b0b379f49b390e02f323b0e
BLAKE2b-256 e007fb835dabf10fdcde4f6cafe93486f829da16c5bee486777687209f7bb574

See more details on using hashes here.

Provenance

The following attestation bundles were made for ace_bacnet_devices-0.2.0-py3-none-any.whl:

Publisher: pypi-publish.yml on ACE-IoT-Solutions/ace-bacnet-devices

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page