bacnet-ip-rest-client 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ build:
10
+ name: Build package
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: astral-sh/setup-uv@v6
16
+ with:
17
+ python-version: "3.12"
18
+
19
+ - name: Build sdist and wheel
20
+ run: uv build
21
+
22
+ - name: Check package metadata
23
+ run: uvx twine check dist/*
24
+
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ test:
31
+ name: Test (Python ${{ matrix.python-version }})
32
+ runs-on: ubuntu-latest
33
+ strategy:
34
+ fail-fast: false
35
+ matrix:
36
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ - uses: astral-sh/setup-uv@v6
41
+ with:
42
+ python-version: ${{ matrix.python-version }}
43
+
44
+ - name: Run tests
45
+ run: uv run --locked pytest
@@ -0,0 +1,66 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - "docs/**"
8
+ - "src/**"
9
+ - "README.md"
10
+ - "mkdocs.yml"
11
+ - "pyproject.toml"
12
+ - "uv.lock"
13
+ - ".github/workflows/docs.yml"
14
+ pull_request:
15
+ paths:
16
+ - "docs/**"
17
+ - "src/**"
18
+ - "README.md"
19
+ - "mkdocs.yml"
20
+ - "pyproject.toml"
21
+ - "uv.lock"
22
+ - ".github/workflows/docs.yml"
23
+ workflow_dispatch:
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ concurrency:
29
+ group: pages
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ build:
34
+ name: Build documentation
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+
39
+ - uses: astral-sh/setup-uv@v6
40
+ with:
41
+ python-version: "3.12"
42
+
43
+ # The API reference is generated from the docstrings in src/ by mkdocstrings.
44
+ - name: Build site
45
+ run: uv run --locked --only-group docs mkdocs build --strict
46
+
47
+ - uses: actions/upload-pages-artifact@v3
48
+ with:
49
+ path: site/
50
+
51
+ # Publishing requires GitHub Pages to be enabled for the repository, with "GitHub Actions" as
52
+ # the source (Settings > Pages > Build and deployment).
53
+ deploy:
54
+ name: Publish to GitHub Pages
55
+ if: github.event_name != 'pull_request'
56
+ needs: build
57
+ runs-on: ubuntu-latest
58
+ permissions:
59
+ pages: write
60
+ id-token: write
61
+ environment:
62
+ name: github-pages
63
+ url: ${{ steps.deployment.outputs.page_url }}
64
+ steps:
65
+ - id: deployment
66
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,57 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build package
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: astral-sh/setup-uv@v6
16
+ with:
17
+ python-version: "3.12"
18
+
19
+ - name: Set package version from tag
20
+ run: uv version --frozen "${GITHUB_REF_NAME#v}"
21
+
22
+ - name: Run tests
23
+ run: uv run --frozen pytest
24
+
25
+ - name: Build sdist and wheel
26
+ run: uv build
27
+
28
+ - name: Check package metadata
29
+ run: uvx twine check dist/*
30
+
31
+ - uses: actions/upload-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ name: Publish to PyPI
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ environment:
41
+ name: pypi
42
+ url: https://pypi.org/p/bacnet-ip-rest-client
43
+ permissions:
44
+ id-token: write
45
+ steps:
46
+ - uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ # Uses PyPI Trusted Publishing (OIDC) -- no API token required. Before the first
52
+ # release, register this repo as a *pending* publisher (the project doesn't exist on
53
+ # PyPI yet) at https://pypi.org/manage/account/publishing/ with:
54
+ # PyPI project name: bacnet-ip-rest-client
55
+ # Owner: stblassitude Repository: bacnet-ip-rest-client
56
+ # Workflow name: publish.yml Environment name: pypi
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,28 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+
19
+ - name: Create GitHub release
20
+ env:
21
+ GH_TOKEN: ${{ github.token }}
22
+ run: |
23
+ PREV_TAG=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)
24
+ RANGE="${PREV_TAG:+$PREV_TAG..}${GITHUB_REF_NAME}"
25
+ git log "$RANGE" --pretty=format:'- %s' > release-notes.md
26
+ gh release create "$GITHUB_REF_NAME" \
27
+ --title "$GITHUB_REF_NAME" \
28
+ --notes-file release-notes.md
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stefan Bethke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: bacnet-ip-rest-client
3
+ Version: 1.0.0
4
+ Summary: Async Python client for the BACnet/IP REST proxy
5
+ Project-URL: Homepage, https://github.com/stblassitude/bacnet-ip-rest-client
6
+ Project-URL: Documentation, https://stblassitude.github.io/bacnet-ip-rest-client/
7
+ Project-URL: Issues, https://github.com/stblassitude/bacnet-ip-rest-client/issues
8
+ Author-email: Stefan Bethke <stb@lassitu.de>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,bacnet,building-automation,httpx,rest
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Home Automation
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: httpx>=0.28.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # bacnet-ip-rest-client
29
+
30
+ An async Python client for the
31
+ [BACnet/IP REST proxy](https://github.com/stblassitude/bacnet-ip-rest-proxy).
32
+ It is built on [httpx](https://www.python-httpx.org/).
33
+
34
+ ```python
35
+ from bacnet_ip_rest_client import Client, ProxyError
36
+
37
+ async with Client("https://bacnet-proxy.example.net", token) as client:
38
+ for obj in await client.list_objects("my-device"):
39
+ print(obj.objid, obj.name, obj.access)
40
+
41
+ temp = await client.read_property("my-device", "analog-input,1", "present-value")
42
+ slots = await client.read_property("my-device", "analog-value,5", "priority-array")
43
+ await client.write_property("my-device", "analog-value,5", "present-value", 21.5, priority=10)
44
+ await client.write_property("my-device", "analog-value,5", "present-value", None, priority=10) # relinquish
45
+ ```
46
+
47
+ ## Relationship to bacpypes3
48
+
49
+ `Client` has the same role as a bacpypes3 `Application`, and
50
+ `read_property()` and `write_property()` take bacpypes3's arguments, with
51
+ some exceptions. It doesn't try to hide the ways an HTTP proxy works
52
+ differently from a BACnet/IP stack:
53
+
54
+ | | bacpypes3 | this client |
55
+ |---|---|---|
56
+ | Addressing a device | BACnet `Address` | the proxy's device id: a configured alias, or a hostname/IP[:port] |
57
+ | Object identifiers | `ObjectIdentifier` with an `ObjectType` enum | `ObjectIdentifier(type: str, instance: int)`, also a 2-tuple; parses `"analog-value,1"` |
58
+ | Values | decoded into the property's BACnet datatype | JSON as the proxy sends it: enumerations as numbers, bit strings as lists of bools. REAL properties of analog objects are always `float`, including NaN/±Infinity |
59
+ | Errors | `ErrorPDU`/`RejectPDU`/`AbortPDU`, which derive from `BaseException`, and are sometimes returned instead of raised | always raised, all derived from `ProxyError(Exception)`, see below |
60
+ | Object discovery | read `object-list`, then each object's properties | `list_objects()`: the proxy's cached catalog with names, units and access, in one request |
61
+ | ReadPropertyMultiple | one request, one transaction | none. `read_properties()` sends concurrent single reads, each with its own result |
62
+ | WriteProperty with array index | supported | not supported by the proxy |
63
+ | Authorization | none | a bearer token per `Client`. The proxy's rules decide what it may read and write (`ObjectSummary.access`) |
64
+
65
+ ## Errors
66
+
67
+ | Exception | Cause | Retried |
68
+ |---|---|---|
69
+ | `CommunicationError` | proxy unreachable, or a reply that couldn't be decoded | yes |
70
+ | `BadRequestError` | 400, malformed request | no |
71
+ | `UnauthorizedError` | 401, token not accepted | no |
72
+ | `ForbiddenError` | 403, the proxy's authorization denies the request | no |
73
+ | `NotFoundError` | 404, unknown device, object or property | no |
74
+ | `ValueRejectedError` | 422, the device rejected the written value | no |
75
+ | `BACnetError` | 502, BACnet Error/Reject/Abort from the device; `error_class`/`error_code` hold its numeric Error_Class/Error_Code | yes, except unknown-object/unknown-property |
76
+ | `BACnetTimeoutError` | 504, the device didn't answer | yes |
77
+
78
+ `ProxyStatusError.is_unknown_object_or_property` is true when the proxy
79
+ reports a missing object or property, whether it does so as a 404 or as a
80
+ 502 from the device. Code that probes for optional properties, such as
81
+ `priority-array`, can check this one attribute.
82
+
83
+ Retries use exponential backoff as configured by `RetryPolicy` (default:
84
+ 5 attempts, 1s doubling to at most 8s). Retry warnings are logged to the
85
+ `bacnet_ip_rest_client` logger. Writes are retried too, since writing the
86
+ same value at the same priority again has no further effect.
87
+
88
+ `concurrency` (default 10) caps the number of requests one `Client` has in
89
+ flight. The proxy serializes traffic to the device itself; this cap only
90
+ keeps a large `read_properties()` from opening hundreds of connections to
91
+ the proxy.
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ uv run pytest
97
+ ```
@@ -0,0 +1,70 @@
1
+ # bacnet-ip-rest-client
2
+
3
+ An async Python client for the
4
+ [BACnet/IP REST proxy](https://github.com/stblassitude/bacnet-ip-rest-proxy).
5
+ It is built on [httpx](https://www.python-httpx.org/).
6
+
7
+ ```python
8
+ from bacnet_ip_rest_client import Client, ProxyError
9
+
10
+ async with Client("https://bacnet-proxy.example.net", token) as client:
11
+ for obj in await client.list_objects("my-device"):
12
+ print(obj.objid, obj.name, obj.access)
13
+
14
+ temp = await client.read_property("my-device", "analog-input,1", "present-value")
15
+ slots = await client.read_property("my-device", "analog-value,5", "priority-array")
16
+ await client.write_property("my-device", "analog-value,5", "present-value", 21.5, priority=10)
17
+ await client.write_property("my-device", "analog-value,5", "present-value", None, priority=10) # relinquish
18
+ ```
19
+
20
+ ## Relationship to bacpypes3
21
+
22
+ `Client` has the same role as a bacpypes3 `Application`, and
23
+ `read_property()` and `write_property()` take bacpypes3's arguments, with
24
+ some exceptions. It doesn't try to hide the ways an HTTP proxy works
25
+ differently from a BACnet/IP stack:
26
+
27
+ | | bacpypes3 | this client |
28
+ |---|---|---|
29
+ | Addressing a device | BACnet `Address` | the proxy's device id: a configured alias, or a hostname/IP[:port] |
30
+ | Object identifiers | `ObjectIdentifier` with an `ObjectType` enum | `ObjectIdentifier(type: str, instance: int)`, also a 2-tuple; parses `"analog-value,1"` |
31
+ | Values | decoded into the property's BACnet datatype | JSON as the proxy sends it: enumerations as numbers, bit strings as lists of bools. REAL properties of analog objects are always `float`, including NaN/±Infinity |
32
+ | Errors | `ErrorPDU`/`RejectPDU`/`AbortPDU`, which derive from `BaseException`, and are sometimes returned instead of raised | always raised, all derived from `ProxyError(Exception)`, see below |
33
+ | Object discovery | read `object-list`, then each object's properties | `list_objects()`: the proxy's cached catalog with names, units and access, in one request |
34
+ | ReadPropertyMultiple | one request, one transaction | none. `read_properties()` sends concurrent single reads, each with its own result |
35
+ | WriteProperty with array index | supported | not supported by the proxy |
36
+ | Authorization | none | a bearer token per `Client`. The proxy's rules decide what it may read and write (`ObjectSummary.access`) |
37
+
38
+ ## Errors
39
+
40
+ | Exception | Cause | Retried |
41
+ |---|---|---|
42
+ | `CommunicationError` | proxy unreachable, or a reply that couldn't be decoded | yes |
43
+ | `BadRequestError` | 400, malformed request | no |
44
+ | `UnauthorizedError` | 401, token not accepted | no |
45
+ | `ForbiddenError` | 403, the proxy's authorization denies the request | no |
46
+ | `NotFoundError` | 404, unknown device, object or property | no |
47
+ | `ValueRejectedError` | 422, the device rejected the written value | no |
48
+ | `BACnetError` | 502, BACnet Error/Reject/Abort from the device; `error_class`/`error_code` hold its numeric Error_Class/Error_Code | yes, except unknown-object/unknown-property |
49
+ | `BACnetTimeoutError` | 504, the device didn't answer | yes |
50
+
51
+ `ProxyStatusError.is_unknown_object_or_property` is true when the proxy
52
+ reports a missing object or property, whether it does so as a 404 or as a
53
+ 502 from the device. Code that probes for optional properties, such as
54
+ `priority-array`, can check this one attribute.
55
+
56
+ Retries use exponential backoff as configured by `RetryPolicy` (default:
57
+ 5 attempts, 1s doubling to at most 8s). Retry warnings are logged to the
58
+ `bacnet_ip_rest_client` logger. Writes are retried too, since writing the
59
+ same value at the same priority again has no further effect.
60
+
61
+ `concurrency` (default 10) caps the number of requests one `Client` has in
62
+ flight. The proxy serializes traffic to the device itself; this cap only
63
+ keeps a large `read_properties()` from opening hundreds of connections to
64
+ the proxy.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ uv run pytest
70
+ ```
@@ -0,0 +1,3 @@
1
+ # API reference
2
+
3
+ ::: bacnet_ip_rest_client
@@ -0,0 +1 @@
1
+ --8<-- "README.md"
@@ -0,0 +1,69 @@
1
+ site_name: BACnet/IP REST Client
2
+ site_description: Async Python client for the BACnet/IP REST proxy
3
+ site_url: https://stblassitude.github.io/bacnet-ip-rest-client/
4
+ repo_url: https://github.com/stblassitude/bacnet-ip-rest-client
5
+ repo_name: stblassitude/bacnet-ip-rest-client
6
+ edit_uri: edit/main/docs/
7
+
8
+ theme:
9
+ name: material
10
+ features:
11
+ - content.code.copy
12
+ - navigation.footer
13
+ - navigation.instant
14
+ - search.highlight
15
+ palette:
16
+ - media: "(prefers-color-scheme: light)"
17
+ scheme: default
18
+ primary: teal
19
+ accent: blue
20
+ toggle:
21
+ icon: material/brightness-7
22
+ name: Switch to dark mode
23
+ - media: "(prefers-color-scheme: dark)"
24
+ scheme: slate
25
+ primary: teal
26
+ accent: blue
27
+ toggle:
28
+ icon: material/brightness-4
29
+ name: Switch to light mode
30
+
31
+ markdown_extensions:
32
+ - admonition
33
+ - attr_list
34
+ - md_in_html
35
+ - pymdownx.details
36
+ - pymdownx.highlight:
37
+ anchor_linenums: true
38
+ - pymdownx.snippets:
39
+ check_paths: true
40
+ - pymdownx.superfences
41
+ - tables
42
+ - toc:
43
+ permalink: true
44
+
45
+ plugins:
46
+ - search
47
+ - mkdocstrings:
48
+ handlers:
49
+ python:
50
+ paths: [src]
51
+ options:
52
+ docstring_style: google
53
+ members_order: source
54
+ merge_init_into_class: true
55
+ separate_signature: true
56
+ show_signature_annotations: true
57
+ signature_crossrefs: true
58
+ show_source: false
59
+ show_root_heading: false
60
+ show_root_toc_entry: false
61
+ show_symbol_type_heading: true
62
+ show_symbol_type_toc: true
63
+ filters: ["!^_"]
64
+ inventories:
65
+ - https://docs.python.org/3/objects.inv
66
+
67
+ nav:
68
+ - Overview: index.md
69
+ - API reference: api.md
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "bacnet-ip-rest-client"
3
+ version = "1.0.0"
4
+ authors = [
5
+ { name = "Stefan Bethke", email = "stb@lassitu.de" },
6
+ ]
7
+ description = "Async Python client for the BACnet/IP REST proxy"
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ keywords = ["bacnet", "building-automation", "rest", "httpx", "asyncio"]
13
+ dependencies = [
14
+ "httpx>=0.28.1",
15
+ ]
16
+
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Framework :: AsyncIO",
20
+ "Intended Audience :: Developers",
21
+ "Natural Language :: English",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Home Automation",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/stblassitude/bacnet-ip-rest-client"
34
+ Documentation = "https://stblassitude.github.io/bacnet-ip-rest-client/"
35
+ Issues = "https://github.com/stblassitude/bacnet-ip-rest-client/issues"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=8",
40
+ ]
41
+ # MkDocs 2.0 is incompatible with Material for MkDocs
42
+ docs = [
43
+ "mkdocs>=1.6,<2",
44
+ "mkdocs-material>=9.5",
45
+ "mkdocstrings[python]>=0.29",
46
+ "ruff",
47
+ ]
48
+
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
@@ -0,0 +1,45 @@
1
+ """Client for the BACnet/IP REST proxy
2
+ (https://github.com/stblassitude/bacnet-ip-rest-proxy)."""
3
+
4
+ from .client import API_PATH, Client, RetryPolicy, is_transient
5
+ from .errors import (
6
+ ERROR_CODE_UNKNOWN_OBJECT,
7
+ ERROR_CODE_UNKNOWN_PROPERTY,
8
+ BACnetError,
9
+ BACnetTimeoutError,
10
+ BadRequestError,
11
+ CommunicationError,
12
+ ForbiddenError,
13
+ NotFoundError,
14
+ ProxyError,
15
+ ProxyStatusError,
16
+ UnauthorizedError,
17
+ ValueRejectedError,
18
+ )
19
+ from .objects import PRIORITY_COUNT, Access, ObjectIdentifier, ObjectSummary
20
+ from .values import decode_value, encode_value
21
+
22
+ __all__ = [
23
+ "API_PATH",
24
+ "Access",
25
+ "BACnetError",
26
+ "BACnetTimeoutError",
27
+ "BadRequestError",
28
+ "Client",
29
+ "CommunicationError",
30
+ "ERROR_CODE_UNKNOWN_OBJECT",
31
+ "ERROR_CODE_UNKNOWN_PROPERTY",
32
+ "ForbiddenError",
33
+ "NotFoundError",
34
+ "ObjectIdentifier",
35
+ "ObjectSummary",
36
+ "PRIORITY_COUNT",
37
+ "ProxyError",
38
+ "ProxyStatusError",
39
+ "RetryPolicy",
40
+ "UnauthorizedError",
41
+ "ValueRejectedError",
42
+ "decode_value",
43
+ "encode_value",
44
+ "is_transient",
45
+ ]