Skip to content

Commit 670d6a2

Browse files
committed
Version 1.5.7 - See changelog for updates.
1 parent 6b4ee83 commit 670d6a2

31 files changed

+340
-245
lines changed

CHANGELOG.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,75 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.7] - 2026-02-24
9+
10+
### Fixed
11+
12+
- **Client decode bugs**: Fixed five methods in `app/client.py` that performed
13+
`isinstance` checks on raw application-tagged bytes without first calling
14+
`decode_and_unwrap()` or `decode_all_application_values()`:
15+
`_traverse_hierarchy_recursive`, `discover_extended` enrichment,
16+
`_discover_device_oid`, `_poll_backup_restore_state`, and `backup_device`
17+
config file decoding. These could cause silent data misinterpretation or
18+
`TypeError` at runtime.
19+
- **WritePropertyMultiple server decode**: `handle_write_property_multiple` in
20+
`app/server.py` now calls `decode_and_unwrap()` on property values before
21+
writing, matching the pattern used elsewhere.
22+
- **Segmentation double-counting**: `SegmentationManager._total_bytes` no longer
23+
double-counts when a duplicate segment is received, which could cause
24+
premature reassembly completion.
25+
- **Audit log query unbound variable**: `AuditLogQueryByTargetACK.decode()` and
26+
`AuditLogQueryBySourceACK.decode()` in `services/audit.py` now initialize
27+
`inner_end` before the while loop, fixing an `UnboundLocalError` on empty
28+
inner sequences.
29+
- **`extract_context_value()` closing tag validation**: The function now verifies
30+
that the closing tag number matches the expected opening tag number, raising
31+
`ValueError` on mismatch instead of silently accepting malformed packets.
32+
- **TSM falsy-value bug**: Three places in `app/tsm.py` used `x or default`
33+
which incorrectly replaced valid falsy values (e.g., `0`, `0.0`) with
34+
defaults. Changed to explicit `if x is not None` checks.
35+
- **Schedule engine midnight race**: `ScheduleEngine._evaluate_all` now reads
36+
`datetime.now()` once per evaluation instead of calling `date.today()` and
37+
`datetime.now().time()` separately, preventing a rare midnight-crossing
38+
inconsistency.
39+
40+
### Changed
41+
42+
- **`LifeSafetyOperationRequest.decode` tag class check**: Added explicit
43+
`TagClass.CONTEXT` validation before decoding context tag 0, raising
44+
`ValueError` with a clear message if an application tag is encountered.
45+
- **Decode assertions → ValueError**: Replaced 10 bare `assert` statements in
46+
`services/alarm_summary.py` and `services/event_notification.py` decode paths
47+
with `raise ValueError(...)`, ensuring decode errors are never silenced by
48+
`-O` optimization.
49+
- **VT session hardening**: Added source-address validation on
50+
`VT-Close` / `VT-Data` requests and a 64-session cap to prevent resource
51+
exhaustion via unbounded VT session creation.
52+
- **Routing table cap**: `RoutingTable.update_route` now enforces a 2048-entry
53+
limit, rejecting new entries when the table is full to prevent memory
54+
exhaustion from malicious route advertisements.
55+
- **Address decode bounds checks**: `BIPAddress.decode` and `BIP6Address.decode`
56+
now validate minimum buffer lengths (6 and 18 bytes respectively) before
57+
parsing, raising `ValueError` instead of producing truncated addresses.
58+
- **BIBB conformance public API**: `ServiceRegistry` now exposes
59+
`has_confirmed_handler()` and `has_unconfirmed_handler()` methods;
60+
`conformance/bibb.py` uses these instead of accessing private `_confirmed` /
61+
`_unconfirmed` dictionaries.
62+
63+
### Removed
64+
65+
- **Dead code**: Removed unused `_min_unsigned_bytes()` from
66+
`encoding/primitives.py`, superseded `_MAX_SEGMENTS_DECODE` /
67+
`_MAX_APDU_DECODE` dicts from `encoding/apdu.py`, and 84-line
68+
`encode_npdu_with_source()` from `network/npdu.py`.
69+
- **Redundant branches**: Eliminated no-op ternary and identical if/else
70+
branches in `app/event_engine.py`, and a redundant conditional in
71+
`segmentation/manager.py` `handle_segment_ack`.
72+
- **Unused loggers**: Removed 8 unused `_logger` definitions and their
73+
`import logging` statements from service modules (`alarm_summary`,
74+
`object_mgmt`, `cov`, `virtual_terminal`, `read_property_multiple`,
75+
`write_property_multiple`, `write_group`, `audit`).
76+
877
## [1.5.6] - 2026-02-23
978

1079
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bac-py"
3-
version = "1.5.6"
3+
version = "1.5.7"
44
description = "Asynchronous BACnet protocol library for Python — BACnet/IP, IPv6, Ethernet, and Secure Connect"
55
readme = "README.md"
66
requires-python = ">=3.13"

src/bac_py/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
value = await client.read("192.168.1.100", "ai,1", "pv")
99
"""
1010

11-
__version__ = "1.5.6"
11+
__version__ = "1.5.7"
1212

1313
from bac_py.app.application import (
1414
BACnetApplication,

src/bac_py/app/client.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,9 @@ async def _enrich_device(dev: DiscoveredDevice) -> DiscoveredDevice:
12541254
if elem.property_access_error is not None:
12551255
continue
12561256
pid = elem.property_identifier
1257-
val = elem.property_value
1257+
val = (
1258+
decode_and_unwrap(elem.property_value) if elem.property_value else None
1259+
)
12581260
if pid == PropertyIdentifier.PROFILE_NAME:
12591261
profile_name = val if isinstance(val, str) else None
12601262
elif pid == PropertyIdentifier.PROFILE_LOCATION:
@@ -1337,13 +1339,13 @@ async def _traverse_hierarchy_recursive(
13371339
PropertyIdentifier.SUBORDINATE_LIST,
13381340
timeout=timeout,
13391341
)
1340-
subordinates = ack.property_value
1341-
if not isinstance(subordinates, list):
1342+
raw_subordinates = decode_all_application_values(ack.property_value)
1343+
if not isinstance(raw_subordinates, list):
13421344
return
13431345
except (BACnetError, BACnetTimeoutError, TimeoutError):
13441346
return
13451347

1346-
for sub in subordinates:
1348+
for sub in raw_subordinates:
13471349
if isinstance(sub, ObjectIdentifier):
13481350
result.append(sub)
13491351
if sub.object_type == ObjectType.STRUCTURED_VIEW:
@@ -2780,12 +2782,13 @@ async def backup_device(
27802782
timeout=timeout,
27812783
)
27822784
config_file_ids: list[ObjectIdentifier] = []
2783-
if isinstance(ack.property_value, list):
2784-
for v in ack.property_value:
2785+
decoded_files = decode_all_application_values(ack.property_value)
2786+
if isinstance(decoded_files, list):
2787+
for v in decoded_files:
27852788
if isinstance(v, ObjectIdentifier):
27862789
config_file_ids.append(v)
2787-
elif isinstance(ack.property_value, ObjectIdentifier):
2788-
config_file_ids.append(ack.property_value)
2790+
elif isinstance(decoded_files, ObjectIdentifier):
2791+
config_file_ids.append(decoded_files)
27892792

27902793
# Step 4: Download each file
27912794
file_contents: list[tuple[ObjectIdentifier, bytes]] = []
@@ -2881,8 +2884,9 @@ async def _discover_device_oid(
28812884
PropertyIdentifier.OBJECT_IDENTIFIER,
28822885
timeout=timeout,
28832886
)
2884-
if isinstance(ack.property_value, ObjectIdentifier):
2885-
return ack.property_value
2887+
decoded = decode_and_unwrap(ack.property_value)
2888+
if isinstance(decoded, ObjectIdentifier):
2889+
return decoded
28862890
return ObjectIdentifier(ObjectType.DEVICE, 4194303)
28872891

28882892
async def _poll_backup_restore_state(
@@ -2892,21 +2896,28 @@ async def _poll_backup_restore_state(
28922896
target_states: tuple[BackupAndRestoreState, ...],
28932897
poll_interval: float = 1.0,
28942898
timeout: float | None = None,
2899+
overall_timeout: float = 300.0,
28952900
) -> BackupAndRestoreState:
28962901
"""Poll BACKUP_AND_RESTORE_STATE until it reaches a target state."""
2902+
deadline = asyncio.get_event_loop().time() + overall_timeout
28972903
while True:
28982904
ack = await self.read_property(
28992905
address,
29002906
device_oid,
29012907
PropertyIdentifier.BACKUP_AND_RESTORE_STATE,
29022908
timeout=timeout,
29032909
)
2904-
raw_state = ack.property_value
2905-
if isinstance(raw_state, int):
2906-
state = BackupAndRestoreState(raw_state)
2910+
decoded_state = decode_and_unwrap(ack.property_value)
2911+
if isinstance(decoded_state, int):
2912+
state = BackupAndRestoreState(decoded_state)
29072913
if state in target_states:
29082914
return state
2909-
await asyncio.sleep(poll_interval)
2915+
remaining = deadline - asyncio.get_event_loop().time()
2916+
if remaining <= 0:
2917+
from bac_py.services.errors import BACnetTimeoutError
2918+
2919+
raise BACnetTimeoutError("Timed out waiting for backup/restore state")
2920+
await asyncio.sleep(min(poll_interval, remaining))
29102921

29112922
async def _download_file(
29122923
self,

src/bac_py/app/event_engine.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -824,11 +824,7 @@ def _evaluate_intrinsic(self, obj: BACnetObject, now: float) -> None:
824824
reliability = obj._properties.get(
825825
PropertyIdentifier.RELIABILITY, Reliability.NO_FAULT_DETECTED
826826
)
827-
fault_result = (
828-
reliability
829-
if reliability != Reliability.NO_FAULT_DETECTED
830-
else Reliability.NO_FAULT_DETECTED
831-
)
827+
fault_result = reliability
832828

833829
# Check Reliability_Evaluation_Inhibit (Clause 13.2.2, p.638)
834830
rel_inhibit = obj._properties.get(PropertyIdentifier.RELIABILITY_EVALUATION_INHIBIT)
@@ -931,14 +927,11 @@ def _run_intrinsic_algorithm(
931927
life_safety_alarm_values = obj._properties.get(
932928
PropertyIdentifier.LIFE_SAFETY_ALARM_VALUES, ()
933929
)
934-
if not isinstance(alarm_values, tuple):
935-
alarm_values = tuple(int(v) for v in alarm_values)
936-
else:
937-
alarm_values = tuple(int(v) for v in alarm_values)
938-
if not isinstance(life_safety_alarm_values, tuple):
939-
life_safety_alarm_values = tuple(int(v) for v in life_safety_alarm_values)
940-
else:
941-
life_safety_alarm_values = tuple(int(v) for v in life_safety_alarm_values)
930+
alarm_values = tuple(int(v) for v in alarm_values)
931+
life_safety_alarm_values = obj._properties.get(
932+
PropertyIdentifier.LIFE_SAFETY_ALARM_VALUES, ()
933+
)
934+
life_safety_alarm_values = tuple(int(v) for v in life_safety_alarm_values)
942935
return evaluate_change_of_life_safety(
943936
tracking_value, int(mode), alarm_values, life_safety_alarm_values
944937
)

src/bac_py/app/schedule_engine.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ async def _run_loop(self) -> None:
9696
def _evaluate_cycle(self) -> None:
9797
"""Run one evaluation cycle."""
9898
db = self._app.object_db
99-
today = datetime.date.today()
100-
now = datetime.datetime.now().time()
99+
_now = datetime.datetime.now()
100+
today = _now.date()
101+
now = _now.time()
101102

102103
# 1. Evaluate all Calendar objects
103104
for cal_obj in db.get_objects_of_type(ObjectType.CALENDAR):

src/bac_py/app/server.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,13 @@ async def handle_write_property_multiple(
915915
# Pass 2: Apply all writes
916916
for obj, properties in validated:
917917
for pv in properties:
918+
try:
919+
write_value = decode_and_unwrap(pv.value)
920+
except (ValueError, IndexError):
921+
raise BACnetError(ErrorClass.PROPERTY, ErrorCode.INVALID_DATA_TYPE) from None
918922
await obj.async_write_property(
919923
pv.property_identifier,
920-
pv.value,
924+
write_value,
921925
pv.priority,
922926
pv.property_array_index,
923927
)
@@ -2071,11 +2075,16 @@ async def handle_vt_open(
20712075
raise BACnetError(ErrorClass.VT, ErrorCode.UNKNOWN_VT_CLASS)
20722076

20732077
# Allocate a session — use a simple counter on the app
2078+
sessions: dict[int, dict[str, Any]] = getattr(self._app, "_vt_sessions", {})
2079+
max_vt_sessions = 64
2080+
if len(sessions) >= max_vt_sessions:
2081+
logger.warning("vt_open: session limit (%d) reached from %s", max_vt_sessions, source)
2082+
raise BACnetError(ErrorClass.RESOURCES, ErrorCode.NO_VT_SESSIONS_AVAILABLE)
2083+
20742084
session_counter = getattr(self._app, "_vt_session_counter", 0) + 1
20752085
self._app._vt_session_counter = session_counter # type: ignore[attr-defined]
20762086

20772087
# Store session mapping
2078-
sessions = getattr(self._app, "_vt_sessions", {})
20792088
sessions[session_counter] = {
20802089
"source": source,
20812090
"vt_class": request.vt_class,
@@ -2103,6 +2112,9 @@ async def handle_vt_close(
21032112
if session_id not in sessions:
21042113
logger.warning("vt_close: unknown VT session %s from %s", session_id, source)
21052114
raise BACnetError(ErrorClass.VT, ErrorCode.UNKNOWN_VT_SESSION)
2115+
if sessions[session_id]["source"] != source:
2116+
logger.warning("vt_close: session %s not owned by %s", session_id, source)
2117+
raise BACnetError(ErrorClass.VT, ErrorCode.UNKNOWN_VT_SESSION)
21062118
del sessions[session_id]
21072119
logger.info(
21082120
"VT-Close from %s: sessions=%s",
@@ -2124,13 +2136,21 @@ async def handle_vt_data(
21242136
"""
21252137
request = VTDataRequest.decode(data)
21262138
sessions = getattr(self._app, "_vt_sessions", {})
2127-
if request.vt_session_identifier not in sessions:
2139+
session = sessions.get(request.vt_session_identifier)
2140+
if session is None:
21282141
logger.warning(
21292142
"vt_data: unknown VT session %d from %s",
21302143
request.vt_session_identifier,
21312144
source,
21322145
)
21332146
raise BACnetError(ErrorClass.VT, ErrorCode.UNKNOWN_VT_SESSION)
2147+
if session["source"] != source:
2148+
logger.warning(
2149+
"vt_data: session %d not owned by %s",
2150+
request.vt_session_identifier,
2151+
source,
2152+
)
2153+
raise BACnetError(ErrorClass.VT, ErrorCode.UNKNOWN_VT_SESSION)
21342154
logger.debug(
21352155
"VT-Data from %s: session=%d, %d bytes, flag=%s",
21362156
source,

src/bac_py/app/tsm.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ async def send_request(
150150
:raises BACnetAbortError: On Abort-PDU response.
151151
:raises BACnetTimeoutError: On timeout after all retries.
152152
"""
153-
effective_max_apdu = max_apdu_override or self._max_apdu_length
153+
effective_max_apdu = (
154+
max_apdu_override if max_apdu_override is not None else self._max_apdu_length
155+
)
154156
loop = self._loop
155157
if loop is None:
156158
loop = self._loop = asyncio.get_running_loop()
@@ -366,7 +368,7 @@ def _send_confirmed_request(
366368
self, txn: ClientTransaction, effective_max_apdu: int | None = None
367369
) -> None:
368370
"""Encode and send a non-segmented confirmed request APDU."""
369-
max_apdu = effective_max_apdu or self._max_apdu_length
371+
max_apdu = effective_max_apdu if effective_max_apdu is not None else self._max_apdu_length
370372
pdu = ConfirmedRequestPDU(
371373
segmented=False,
372374
more_follows=False,
@@ -388,7 +390,7 @@ def _send_segmented_request(
388390
self, txn: ClientTransaction, effective_max_apdu: int | None = None
389391
) -> None:
390392
"""Begin sending a segmented request."""
391-
max_apdu = effective_max_apdu or self._max_apdu_length
393+
max_apdu = effective_max_apdu if effective_max_apdu is not None else self._max_apdu_length
392394
try:
393395
sender = SegmentSender.create(
394396
payload=txn.request_data,

src/bac_py/conformance/bibb.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,5 +362,7 @@ def _is_supported(self, bibb: BIBBDefinition) -> bool:
362362

363363
# B-role: check server handlers
364364
return all(
365-
svc.value in self._registry._confirmed for svc in bibb.confirmed_services
366-
) and all(svc.value in self._registry._unconfirmed for svc in bibb.unconfirmed_services)
365+
self._registry.has_confirmed_handler(svc.value) for svc in bibb.confirmed_services
366+
) and all(
367+
self._registry.has_unconfirmed_handler(svc.value) for svc in bibb.unconfirmed_services
368+
)

src/bac_py/encoding/apdu.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,6 @@
2828
_MAX_SEGMENTS_UNSPECIFIED = 0 # B'000'
2929
_MAX_SEGMENTS_OVER_64 = 7 # B'111'
3030

31-
_MAX_SEGMENTS_DECODE: dict[int, int | None] = {
32-
0: None, # Unspecified
33-
1: 2,
34-
2: 4,
35-
3: 8,
36-
4: 16,
37-
5: 32,
38-
6: 64,
39-
7: None, # Greater than 64 (also treated as unlimited)
40-
}
41-
4231
# Fast tuple lookup for decode (indexed 0-7)
4332
_MAX_SEGMENTS_DECODE_TUPLE: tuple[int | None, ...] = (None, 2, 4, 8, 16, 32, 64, None)
4433

@@ -52,15 +41,6 @@
5241
1476: 5,
5342
}
5443

55-
_MAX_APDU_DECODE: dict[int, int] = {
56-
0: 50,
57-
1: 128,
58-
2: 206,
59-
3: 480,
60-
4: 1024,
61-
5: 1476,
62-
}
63-
6444
# Fast tuple lookup for decode (indexed 0-5, default 1476 for 6+)
6545
_MAX_APDU_DECODE_TUPLE: tuple[int, ...] = (50, 128, 206, 480, 1024, 1476)
6646

0 commit comments

Comments
 (0)