Metadata-Version: 2.5
Name: ace-bacnet-devices
Version: 0.1.0
Summary: Proprietary BACnet extensions extracted from PICS conformance documents
Author: Ace IoT Solutions
License-Expression: MIT
License-File: LICENSE
Keywords: bacnet,bacpypes,building-automation,pics
Requires-Python: >=3.13
Requires-Dist: pdfplumber>=0.11.10
Requires-Dist: pydantic>=2.13.4
Requires-Dist: typer>=0.27.0
Provides-Extra: bacpypes
Requires-Dist: bacpypes>=0.18.7; extra == 'bacpypes'
Provides-Extra: bacpypes3
Requires-Dist: bacpypes3>=0.0.102; extra == 'bacpypes3'
Description-Content-Type: text/markdown

# 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

```bash
pip install ace-bacnet-devices[bacpypes3]
```

```python
import bacpypes_extensions.all  # every bundled vendor

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

Or register a single vendor:

```python
import bacpypes_extensions.carrier
```

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

```python
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 |

```bash
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:

```bash
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:

```python
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_REFERENCE5` → `ITEM_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:

```bash
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,
but it has **not** been exercised against a real `bacpypes` install — that needs an older
interpreter.

## Development

```bash
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
```

Regenerate derived files after changing the bundled definitions:

```bash
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](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.
