From c9bc589cf1f3981d85b776d3608ffdffa926c206 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Mon, 25 May 2026 11:50:47 -0400 Subject: [PATCH] feat: add bacnet read-only adapter foundation --- docs/LEARNING_LOG.md | 54 +++ services/ingestion/README.md | 57 +++ .../bacnet_read_only_adapter.py | 314 ++++++++++++++++ .../tests/test_bacnet_read_only_adapter.py | 343 ++++++++++++++++++ 4 files changed, 768 insertions(+) create mode 100644 services/ingestion/factory_ingestion/bacnet_read_only_adapter.py create mode 100644 services/ingestion/tests/test_bacnet_read_only_adapter.py diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 94ef7ec..bf2c8f2 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -22,6 +22,60 @@ This file should be updated by Codex after each meaningful change. ### What to learn next ``` +## 2026-05-25 - BACnet Read-Only Adapter Foundation + +### What changed + +Added a read-only BACnet adapter foundation that loads enabled BACnet +connection profiles, polls configured object references through an injected +object reader, and emits normalized `process.measurement.recorded` +FactoryEvents. + +### Why it matters + +This completes the first foundation pass for the three protocol families in the +connection-management epic. BACnet data can now follow the same safe path as +OPC-UA and MQTT: configured source reads become FactoryEvents without adding +write operations, commandable-property changes, arbitrary object discovery, or +UI ingestion controls. + +### How it works + +`load_enabled_bacnet_profiles()` reads the local connection profile store and +filters to enabled BACnet profiles. `run_bacnet_read_only_adapter()` passes each +profile to a `BacnetObjectReader`, which reads only configured object +references for the configured device address, device instance, optional network +number, and polling interval. Each numeric object reading becomes a process +measurement event whose source metadata includes the profile ID, device/network +descriptor, object reference, and poll index. Unavailable devices, missing +objects, unconfigured objects, stale readings, and nonnumeric values raise clear +adapter errors. + +### How to run it + +The foundation is currently exercised from Python code and tests. It reads the +same local profile store used by the connection profile API and writes to the +same event store interface used by ingestion. + +### How to test it + +```bash +.venv/bin/python -m pytest services/ingestion/tests/test_bacnet_read_only_adapter.py +``` + +### Key files + +- `services/ingestion/factory_ingestion/bacnet_read_only_adapter.py` +- `services/ingestion/tests/test_bacnet_read_only_adapter.py` +- `services/ingestion/README.md` + +### What to learn next + +Add a real BACnet/IP object reader only after connector runtime orchestration, +network approval, and object mapping rules are defined. Keep BACnet writes and +commandable-property changes out of this path unless a dedicated ADR approves +them. + ## 2026-05-25 - MQTT Read-Only Adapter Foundation ### What changed diff --git a/services/ingestion/README.md b/services/ingestion/README.md index 5341c4f..152bbdd 100644 --- a/services/ingestion/README.md +++ b/services/ingestion/README.md @@ -272,6 +272,63 @@ configuration access, Sparkplug-style JSON payload mapping, broker-unavailable errors, malformed payload errors, unmapped topic errors, and the absence of a publish/writeback surface. +## BACnet Read-Only Adapter Foundation + +The read-only BACnet adapter foundation reads enabled BACnet +`ProtocolConnectionProfile` records from the local connection profile store, +uses an injected object reader to poll configured object references, and emits +normalized `process.measurement.recorded` FactoryEvents into an event store. + +This foundation does not add a BACnet/IP runtime, object discovery, write path, +commandable-property changes, or UI ingestion controls. Until a connector ADR +explicitly expands the behavior, the adapter is limited to: + +- enabled profiles where `protocol = "bacnet"`; +- the configured device address in `endpoint`; +- configured `device_instance` and optional `network_number`; +- configured object references such as `analogInput:1.presentValue`; +- the configured polling interval in `acquisition.poll_interval_seconds`; +- read-only object polling through an injected reader. + +Each object reading supplies the object reference, numeric present value, unit, +optional object name, quality, and optional read timestamp. The adapter maps +each reading into a process measurement event and captures source metadata: + +- `source.system` is `bacnet:`; +- `source.adapter` is `bacnet-read-only-adapter`; +- `source.source_event_id` includes the connection profile ID, network/device + descriptor, object reference, and poll index; +- `payload.tag_name` stores the configured BACnet object reference. + +Stale readings can be rejected by passing `max_reading_age_seconds`; stale +flags from the object reader are rejected immediately. Unavailable devices, +missing objects, unconfigured objects, stale readings, and nonnumeric values +raise clear adapter errors. + +### Docker Desktop BACnet demo limitations + +Local BACnet/IP demos are UDP based and can behave differently under Docker +Desktop networking than they do on a real plant network. Broadcast discovery, +BBMD/foreign-device registration, host networking, routing between VLANs, and +UDP port exposure can all vary by host OS and Docker configuration. Treat any +Docker Desktop BACnet demo as a local smoke path only. + +Production BACnet/IP expectations are separate: configured devices and objects +should be read through network-approved routes, with explicit device addressing, +object mapping, polling intervals, and operations approval. This foundation does +not claim production readiness and does not write BACnet properties. + +Run the focused tests: + +```bash +.venv/bin/python -m pytest services/ingestion/tests/test_bacnet_read_only_adapter.py +``` + +The test suite covers fake BACnet clients, device address/network handling, +object references, polling interval access, units/object mapping, +device-unavailable errors, missing object errors, stale reading errors, and the +absence of write or commandable-property surfaces. + ## Accepted Event Storage Accepted events are written to the local JSONL event store: diff --git a/services/ingestion/factory_ingestion/bacnet_read_only_adapter.py b/services/ingestion/factory_ingestion/bacnet_read_only_adapter.py new file mode 100644 index 0000000..d1df361 --- /dev/null +++ b/services/ingestion/factory_ingestion/bacnet_read_only_adapter.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Literal, Protocol + +from factory_events import ( + EventContext, + EventEnvelope, + EventMetadata, + EventSource, + ProcessMeasurementPayload, + ProtocolConnectionProfile, + validate_connection_profile, +) + +from factory_ingestion.ingest import EventStore + + +class BacnetReadOnlyAdapterError(RuntimeError): + """Base error for read-only BACnet adapter failures.""" + + +class BacnetConnectionProfilesStoreError(BacnetReadOnlyAdapterError): + """Raised when the configured connection profile store cannot be read.""" + + +class BacnetDeviceUnavailableError(BacnetReadOnlyAdapterError): + """Raised when a configured BACnet/IP device cannot be reached.""" + + +class BacnetMissingObjectError(BacnetReadOnlyAdapterError): + """Raised when a configured BACnet object/property cannot be read.""" + + +class BacnetStaleReadingError(BacnetReadOnlyAdapterError): + """Raised when a configured BACnet object reading is stale.""" + + +class BacnetUnmappedObjectError(BacnetReadOnlyAdapterError): + """Raised when a BACnet reading is outside the configured object list.""" + + +@dataclass(frozen=True) +class BacnetObjectReading: + object_reference: str + value: float | int + unit: str + object_name: str | None = None + quality: Literal["good", "uncertain", "bad"] = "good" + read_at: datetime | None = None + stale: bool = False + + +class BacnetObjectReader(Protocol): + async def read_configured_objects( + self, + profile: ProtocolConnectionProfile, + ) -> Sequence[BacnetObjectReading]: + """Read only the BACnet objects configured on the supplied profile.""" + + +@dataclass(frozen=True) +class BacnetEventContextDefaults: + site_id: str = "unmapped_site" + area_id: str = "unmapped_area" + line_id: str = "unmapped_line" + asset_id: str | None = None + batch_id: str | None = None + work_order_id: str | None = None + simulated: bool = False + + +@dataclass(frozen=True) +class BacnetAdapterRunResult: + profiles_read: int + objects_read: int + emitted_events: int + events_store_path: Path | None + + +class UnconfiguredBacnetObjectReader: + async def read_configured_objects( + self, + profile: ProtocolConnectionProfile, + ) -> Sequence[BacnetObjectReading]: + msg = ( + f"no BACnet/IP object reader is configured for profile {profile.id} " + f"at {profile.endpoint}" + ) + raise BacnetDeviceUnavailableError(msg) + + +def load_enabled_bacnet_profiles(profile_store_path: Path) -> list[ProtocolConnectionProfile]: + if not profile_store_path.exists(): + return [] + + try: + raw_profiles = json.loads(profile_store_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + msg = f"connection profile store is not valid JSON: {profile_store_path}" + raise BacnetConnectionProfilesStoreError(msg) from exc + + if not isinstance(raw_profiles, list): + msg = f"connection profile store must contain a list: {profile_store_path}" + raise BacnetConnectionProfilesStoreError(msg) + + profiles: list[ProtocolConnectionProfile] = [] + for raw_profile in raw_profiles: + if not isinstance(raw_profile, dict): + msg = "connection profile store entries must be JSON objects" + raise BacnetConnectionProfilesStoreError(msg) + profile = validate_connection_profile(raw_profile) + if profile.protocol == "bacnet" and profile.enabled: + profiles.append(profile) + return profiles + + +async def run_bacnet_read_only_adapter( + *, + profile_store_path: Path, + events_store: EventStore, + object_reader: BacnetObjectReader | None = None, + context_defaults: BacnetEventContextDefaults | None = None, + poll_index: int = 0, + timestamp: datetime | None = None, + max_reading_age_seconds: float | None = None, +) -> BacnetAdapterRunResult: + if poll_index < 0: + msg = "poll_index must be greater than or equal to 0" + raise ValueError(msg) + if max_reading_age_seconds is not None and max_reading_age_seconds <= 0: + msg = "max_reading_age_seconds must be greater than 0 when provided" + raise ValueError(msg) + + profiles = load_enabled_bacnet_profiles(profile_store_path) + reader = object_reader or UnconfiguredBacnetObjectReader() + defaults = context_defaults or BacnetEventContextDefaults() + event_timestamp = timestamp or datetime.now(UTC) + objects_read = 0 + emitted_events = 0 + + for profile in profiles: + try: + readings = await reader.read_configured_objects(profile) + except BacnetReadOnlyAdapterError: + raise + except (ConnectionError, TimeoutError) as exc: + msg = ( + f"unable to read BACnet/IP device {profile.endpoint} " + f"for profile {profile.id}: {exc}" + ) + raise BacnetDeviceUnavailableError(msg) from exc + + objects_read += len(readings) + for reading in readings: + _ensure_configured_object(profile, reading.object_reference) + _ensure_fresh_reading( + profile, + reading, + event_timestamp=event_timestamp, + max_reading_age_seconds=max_reading_age_seconds, + ) + event = build_process_measurement_event( + profile, + reading, + poll_index=poll_index, + timestamp=event_timestamp, + context_defaults=defaults, + ) + events_store.append(event) + emitted_events += 1 + + return BacnetAdapterRunResult( + profiles_read=len(profiles), + objects_read=objects_read, + emitted_events=emitted_events, + events_store_path=getattr(events_store, "path", None), + ) + + +def build_process_measurement_event( + profile: ProtocolConnectionProfile, + reading: BacnetObjectReading, + *, + poll_index: int, + timestamp: datetime, + context_defaults: BacnetEventContextDefaults | None = None, +) -> EventEnvelope: + defaults = context_defaults or BacnetEventContextDefaults() + object_key = _object_key(reading.object_reference) + return EventEnvelope( + event_id=f"evt_bacnet_{_identifier_key(profile.id)}_{object_key}_{poll_index:04d}", + event_type="process.measurement.recorded", + schema_version="1.0.0", + timestamp=timestamp, + source=EventSource( + system=f"bacnet:{profile.id}", + adapter="bacnet-read-only-adapter", + source_event_id=( + f"bacnet:{profile.id}:{_device_descriptor(profile)}:" + f"{reading.object_reference}:poll:{poll_index:04d}" + ), + ), + context=EventContext( + site_id=defaults.site_id, + area_id=defaults.area_id, + line_id=defaults.line_id, + asset_id=defaults.asset_id, + batch_id=defaults.batch_id, + work_order_id=defaults.work_order_id, + ), + payload=ProcessMeasurementPayload( + signal_id=object_key, + signal_name=reading.object_name or _signal_name(object_key), + tag_name=reading.object_reference, + value=_numeric_reading_value(profile, reading), + unit=reading.unit, + quality=reading.quality, + ), + metadata=EventMetadata( + simulated=defaults.simulated, + trace_id=f"trace_bacnet_{_identifier_key(profile.id)}_{poll_index:04d}", + ), + ) + + +def _ensure_configured_object( + profile: ProtocolConnectionProfile, + object_reference: str, +) -> None: + if profile.bacnet is None: + msg = f"BACnet profile {profile.id} is missing its bacnet config block." + raise BacnetUnmappedObjectError(msg) + if object_reference in profile.bacnet.object_references: + return + msg = ( + f"BACnet object {object_reference} is not configured for profile {profile.id}" + ) + raise BacnetUnmappedObjectError(msg) + + +def _ensure_fresh_reading( + profile: ProtocolConnectionProfile, + reading: BacnetObjectReading, + *, + event_timestamp: datetime, + max_reading_age_seconds: float | None, +) -> None: + if reading.stale: + msg = ( + f"BACnet object {reading.object_reference} for profile {profile.id} " + "returned a stale reading" + ) + raise BacnetStaleReadingError(msg) + if max_reading_age_seconds is None or reading.read_at is None: + return + if reading.read_at.tzinfo is None or reading.read_at.utcoffset() != UTC.utcoffset( + reading.read_at + ): + msg = ( + f"BACnet object {reading.object_reference} for profile {profile.id} " + "returned a read_at timestamp that is not timezone-aware UTC" + ) + raise BacnetStaleReadingError(msg) + max_age = timedelta(seconds=max_reading_age_seconds) + if event_timestamp - reading.read_at > max_age: + msg = ( + f"BACnet object {reading.object_reference} for profile {profile.id} " + f"returned a stale reading older than {max_reading_age_seconds:g} seconds" + ) + raise BacnetStaleReadingError(msg) + + +def _numeric_reading_value( + profile: ProtocolConnectionProfile, + reading: BacnetObjectReading, +) -> float: + if isinstance(reading.value, bool) or not isinstance(reading.value, int | float): + msg = ( + f"BACnet object {reading.object_reference} for profile {profile.id} " + "returned a nonnumeric value that cannot be normalized as a process measurement" + ) + raise BacnetMissingObjectError(msg) + return float(reading.value) + + +def _device_descriptor(profile: ProtocolConnectionProfile) -> str: + if profile.bacnet is None: + return "unknown_device" + if profile.bacnet.network_number is None: + return f"device:{profile.bacnet.device_instance}@{profile.endpoint}" + return ( + f"network:{profile.bacnet.network_number}:" + f"device:{profile.bacnet.device_instance}@{profile.endpoint}" + ) + + +def _object_key(object_reference: str) -> str: + return _identifier_key(object_reference) + + +def _identifier_key(value: str) -> str: + lowered = value.lower() + normalized = re.sub(r"[^a-z0-9]+", "_", lowered).strip("_") + return normalized or "unknown_object" + + +def _signal_name(signal_id: str) -> str: + return " ".join(part.capitalize() for part in signal_id.split("_")) diff --git a/services/ingestion/tests/test_bacnet_read_only_adapter.py b/services/ingestion/tests/test_bacnet_read_only_adapter.py new file mode 100644 index 0000000..9fdf4ec --- /dev/null +++ b/services/ingestion/tests/test_bacnet_read_only_adapter.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import asyncio +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from factory_events import ProtocolConnectionProfile +from factory_ingestion.bacnet_read_only_adapter import ( + BacnetAdapterRunResult, + BacnetDeviceUnavailableError, + BacnetEventContextDefaults, + BacnetMissingObjectError, + BacnetObjectReading, + BacnetReadOnlyAdapterError, + BacnetStaleReadingError, + BacnetUnmappedObjectError, + build_process_measurement_event, + load_enabled_bacnet_profiles, + run_bacnet_read_only_adapter, +) +from factory_ingestion.storage import JsonlEventStore + +REPO_ROOT = Path(__file__).resolve().parents[3] +PROFILE_FIXTURES = REPO_ROOT / "packages" / "test-fixtures" / "valid-connection-profiles" + + +class RecordingBacnetObjectReader: + def __init__( + self, + readings: list[BacnetObjectReading], + *, + failure: Exception | None = None, + ) -> None: + self.readings = readings + self.failure = failure + self.calls: list[dict[str, object]] = [] + + async def read_configured_objects( + self, + profile: ProtocolConnectionProfile, + ) -> list[BacnetObjectReading]: + assert profile.bacnet is not None + assert profile.acquisition.poll_interval_seconds is not None + self.calls.append( + { + "endpoint": profile.endpoint, + "device_instance": profile.bacnet.device_instance, + "network_number": profile.bacnet.network_number, + "object_references": tuple(profile.bacnet.object_references), + "poll_interval_seconds": profile.acquisition.poll_interval_seconds, + } + ) + if self.failure is not None: + raise self.failure + return self.readings + + +def _load_fixture(name: str) -> dict[str, object]: + return json.loads((PROFILE_FIXTURES / name).read_text(encoding="utf-8")) + + +def _bacnet_profile_data(**updates: object) -> dict[str, object]: + profile = _load_fixture("bacnet_connection_profile.json") + profile.update({"enabled": True, "health_state": "unknown"}) + profile.update(updates) + return profile + + +def _write_profiles(path: Path, profiles: list[dict[str, object]]) -> None: + path.write_text(json.dumps(profiles), encoding="utf-8") + + +def test_load_enabled_bacnet_profiles_reads_only_enabled_bacnet_profiles( + tmp_path: Path, +) -> None: + store_path = tmp_path / "connection_profiles.json" + disabled_bacnet = _bacnet_profile_data( + id="disabled-bacnet", + enabled=False, + health_state="disabled", + ) + enabled_mqtt = _load_fixture("mqtt_connection_profile.json") + enabled_bacnet = _bacnet_profile_data(id="enabled-bacnet") + _write_profiles(store_path, [disabled_bacnet, enabled_mqtt, enabled_bacnet]) + + profiles = load_enabled_bacnet_profiles(store_path) + + assert [profile.id for profile in profiles] == ["enabled-bacnet"] + assert profiles[0].protocol == "bacnet" + + +def test_bacnet_adapter_reads_configured_objects_and_emits_factory_events( + tmp_path: Path, +) -> None: + profile_store_path = tmp_path / "connection_profiles.json" + profile = _bacnet_profile_data() + _write_profiles(profile_store_path, [profile]) + events_store = JsonlEventStore(tmp_path / "events.jsonl") + reader = RecordingBacnetObjectReader( + [ + BacnetObjectReading( + object_reference="analogInput:1.presentValue", + object_name="Cleanroom Temperature", + value=21.4, + unit="degC", + read_at=datetime(2026, 5, 25, 12, 0, tzinfo=UTC), + ), + BacnetObjectReading( + object_reference="analogInput:2.presentValue", + object_name="Cleanroom Relative Humidity", + value=48.2, + unit="percent", + read_at=datetime(2026, 5, 25, 12, 0, tzinfo=UTC), + ), + ] + ) + + result = asyncio.run( + run_bacnet_read_only_adapter( + profile_store_path=profile_store_path, + events_store=events_store, + object_reader=reader, + context_defaults=BacnetEventContextDefaults( + site_id="site_demo", + area_id="area_packaging", + line_id="line_1", + asset_id="asset_cleanroom_1", + simulated=True, + ), + poll_index=4, + timestamp=datetime(2026, 5, 25, 12, 0, tzinfo=UTC), + max_reading_age_seconds=60, + ) + ) + + assert result == BacnetAdapterRunResult( + profiles_read=1, + objects_read=2, + emitted_events=2, + events_store_path=events_store.path, + ) + assert reader.calls == [ + { + "endpoint": "192.0.2.20:47808", + "device_instance": 12001, + "network_number": 10, + "object_references": ( + "analogInput:1.presentValue", + "analogInput:2.presentValue", + ), + "poll_interval_seconds": 30.0, + } + ] + + stored_events = events_store.list_events() + assert len(stored_events) == 2 + assert {event.event_type for event in stored_events} == {"process.measurement.recorded"} + assert {event.source.adapter for event in stored_events} == {"bacnet-read-only-adapter"} + assert {event.source.system for event in stored_events} == {"bacnet:conn_bacnet_hvac_line_1"} + assert { + event.source.source_event_id for event in stored_events + } == { + "bacnet:conn_bacnet_hvac_line_1:network:10:device:12001@192.0.2.20:47808:" + "analogInput:1.presentValue:poll:0004", + "bacnet:conn_bacnet_hvac_line_1:network:10:device:12001@192.0.2.20:47808:" + "analogInput:2.presentValue:poll:0004", + } + assert {event.payload.tag_name for event in stored_events} == { + "analogInput:1.presentValue", + "analogInput:2.presentValue", + } + assert {event.payload.unit for event in stored_events} == {"degC", "percent"} + assert {event.context.asset_id for event in stored_events} == {"asset_cleanroom_1"} + assert {event.metadata.simulated for event in stored_events} == {True} + + +def test_bacnet_event_mapping_captures_object_source_and_units() -> None: + profile = ProtocolConnectionProfile.model_validate(_bacnet_profile_data()) + + event = build_process_measurement_event( + profile, + BacnetObjectReading( + object_reference="analogInput:7.presentValue", + object_name="Room Differential Pressure", + value=0.13, + unit="inH2O", + ), + poll_index=2, + timestamp=datetime(2026, 5, 25, 13, 0, tzinfo=UTC), + context_defaults=BacnetEventContextDefaults( + site_id="site", + area_id="area", + line_id="line", + ), + ) + + assert event.event_id == ( + "evt_bacnet_conn_bacnet_hvac_line_1_analoginput_7_presentvalue_0002" + ) + assert event.source.source_event_id == ( + "bacnet:conn_bacnet_hvac_line_1:network:10:device:12001@192.0.2.20:47808:" + "analogInput:7.presentValue:poll:0002" + ) + assert event.payload.signal_id == "analoginput_7_presentvalue" + assert event.payload.signal_name == "Room Differential Pressure" + assert event.payload.tag_name == "analogInput:7.presentValue" + assert event.payload.value == 0.13 + assert event.payload.unit == "inH2O" + + +def test_bacnet_adapter_reports_unavailable_device_clearly(tmp_path: Path) -> None: + profile_store_path = tmp_path / "connection_profiles.json" + _write_profiles(profile_store_path, [_bacnet_profile_data(endpoint="192.0.2.99:47808")]) + reader = RecordingBacnetObjectReader([], failure=ConnectionError("udp timeout")) + + with pytest.raises(BacnetReadOnlyAdapterError) as exc_info: + asyncio.run( + run_bacnet_read_only_adapter( + profile_store_path=profile_store_path, + events_store=JsonlEventStore(tmp_path / "events.jsonl"), + object_reader=reader, + ) + ) + + assert isinstance(exc_info.value, BacnetDeviceUnavailableError) + assert "192.0.2.99:47808" in str(exc_info.value) + assert "conn_bacnet_hvac_line_1" in str(exc_info.value) + + +def test_bacnet_adapter_reports_missing_configured_object_clearly( + tmp_path: Path, +) -> None: + profile_store_path = tmp_path / "connection_profiles.json" + _write_profiles(profile_store_path, [_bacnet_profile_data()]) + reader = RecordingBacnetObjectReader( + [], + failure=BacnetMissingObjectError( + "failed to read configured BACnet object analogInput:99.presentValue" + ), + ) + + with pytest.raises(BacnetMissingObjectError) as exc_info: + asyncio.run( + run_bacnet_read_only_adapter( + profile_store_path=profile_store_path, + events_store=JsonlEventStore(tmp_path / "events.jsonl"), + object_reader=reader, + ) + ) + + assert "analogInput:99.presentValue" in str(exc_info.value) + assert "configured BACnet object" in str(exc_info.value) + + +def test_bacnet_adapter_rejects_unconfigured_object_reference(tmp_path: Path) -> None: + profile_store_path = tmp_path / "connection_profiles.json" + _write_profiles(profile_store_path, [_bacnet_profile_data()]) + reader = RecordingBacnetObjectReader( + [ + BacnetObjectReading( + object_reference="analogInput:99.presentValue", + object_name="Unmapped Object", + value=1.0, + unit="unknown", + ) + ] + ) + + with pytest.raises(BacnetUnmappedObjectError) as exc_info: + asyncio.run( + run_bacnet_read_only_adapter( + profile_store_path=profile_store_path, + events_store=JsonlEventStore(tmp_path / "events.jsonl"), + object_reader=reader, + ) + ) + + assert "analogInput:99.presentValue" in str(exc_info.value) + assert "not configured" in str(exc_info.value) + + +def test_bacnet_adapter_reports_stale_reading_clearly(tmp_path: Path) -> None: + profile_store_path = tmp_path / "connection_profiles.json" + _write_profiles(profile_store_path, [_bacnet_profile_data()]) + reader = RecordingBacnetObjectReader( + [ + BacnetObjectReading( + object_reference="analogInput:1.presentValue", + value=21.4, + unit="degC", + read_at=datetime(2026, 5, 25, 11, 58, tzinfo=UTC), + ) + ] + ) + + with pytest.raises(BacnetStaleReadingError) as exc_info: + asyncio.run( + run_bacnet_read_only_adapter( + profile_store_path=profile_store_path, + events_store=JsonlEventStore(tmp_path / "events.jsonl"), + object_reader=reader, + timestamp=datetime(2026, 5, 25, 12, 0, tzinfo=UTC), + max_reading_age_seconds=30, + ) + ) + + assert "stale reading" in str(exc_info.value) + assert "30 seconds" in str(exc_info.value) + + +def test_bacnet_adapter_reports_nonnumeric_reading_clearly() -> None: + profile = ProtocolConnectionProfile.model_validate(_bacnet_profile_data()) + + with pytest.raises(BacnetMissingObjectError) as exc_info: + build_process_measurement_event( + profile, + BacnetObjectReading( + object_reference="analogInput:1.presentValue", + value=True, + unit="unknown", + ), + poll_index=0, + timestamp=datetime(2026, 5, 25, 13, 0, tzinfo=UTC), + ) + + assert "nonnumeric value" in str(exc_info.value) + + +def test_bacnet_adapter_has_no_write_or_commandable_property_surface() -> None: + adapter_source = ( + REPO_ROOT + / "services" + / "ingestion" + / "factory_ingestion" + / "bacnet_read_only_adapter.py" + ).read_text(encoding="utf-8") + + assert "def write" not in adapter_source + assert ".write" not in adapter_source + assert "write_property" not in adapter_source + assert "commandable" not in adapter_source.lower()