diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9805249 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,171 @@ +# Build the B-RTR example against the CAS BACnet Stack STATIC library and publish a GitHub +# Release (binaries + metrics.json) when a vX.Y.Z tag is pushed. PRs and pushes to main run +# the same build + smoke test without publishing. +# +# The CAS BACnet Stack is a PRIVATE git submodule: the repository secret CAS_STACK_PAT must +# hold a PAT with read access to chipkin/cas-bacnet-stack. +name: build-and-release + +on: + pull_request: + push: + branches: [main] + tags: ["v*.*.*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # windows-2022, not windows-latest: the stack's vcxproj names the v143 (VS 2022) + # toolset; newer images may not carry it. + - os: windows-2022 + artifact: BACnetExampleBRTR-windows-x64 + exe: build/Release/BACnetExampleBRTR.exe + lib: submodules/cas-bacnet-stack/bin/CASBACnetStack_x64_Release.lib + metrics: metrics-windows.json + - os: ubuntu-latest + artifact: BACnetExampleBRTR-linux-x64 + exe: build/BACnetExampleBRTR + lib: submodules/cas-bacnet-stack/bin/libCASBACnetStack_x64_Release.a + metrics: metrics-linux.json + steps: + - name: Enable git long paths (Windows) + if: runner.os == 'Windows' + run: git config --system core.longpaths true + + - name: Checkout (with the CAS BACnet Stack submodule) + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.CAS_STACK_PAT || github.token }} + + - name: Stack commit (cache key) + id: stack + shell: bash + run: echo "sha=$(git -C submodules/cas-bacnet-stack rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # The static library depends only on the stack commit, so it is cached on that SHA. + # A moved pin therefore always rebuilds it - a stale library against a new header is + # an ABI mismatch that links and misbehaves. + - name: Restore the prebuilt static library + id: libcache + uses: actions/cache@v4 + with: + path: submodules/cas-bacnet-stack/bin + key: cas-stack-static-${{ runner.os }}-${{ steps.stack.outputs.sha }} + + - name: Build the CAS BACnet Stack static library (ReleaseLib, customer surface only) + if: steps.libcache.outputs.cache-hit != 'true' + shell: bash + run: | + set -e + if [ "$RUNNER_OS" = "Windows" ]; then + # microsoft/setup-msbuild's PATH update does not reach this Git-Bash step + # (msbuild: command not found even after that step ran), so resolve it + # directly the same way tools/build-stack-static.sh does locally. + vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe' + MSBUILD=$("$vswhere" -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | head -1) + # The vcxproj names PlatformToolset=v145; windows-2022 only carries the VS 2022 + # v143 build tools, so force v143 (MSB8020 otherwise: "build tools for v145 ... + # cannot be found"). CMake's own "Visual Studio 17 2022" generator resolves to + # the same v143 toolset on this runner, so the two stay compiler-matched. + "$MSBUILD" submodules/cas-bacnet-stack/projects/msvs/CASBACnetStack/CASBACnetStack.vcxproj -p:Configuration=ReleaseLib -p:Platform=x64 -p:PlatformToolset=v143 -m -v:minimal + else + make -C submodules/cas-bacnet-stack/projects/linux/Release/x64 -j"$(nproc)" all + make -C submodules/cas-bacnet-stack/projects/linux/Release/x64 install + fi + ls -l "${{ matrix.lib }}" + + - name: Configure (STATIC link mode) + run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DCAS_BACNET_STACK_LINK=STATIC + + - name: Confirm the link mode CMake actually used + shell: bash + run: | + mode=$(grep -E '^CAS_BACNET_STACK_LINK:' build/CMakeCache.txt | cut -d= -f2) + echo "CAS_BACNET_STACK_LINK=$mode" + [ "$mode" = "STATIC" ] || { echo "FAIL: expected STATIC, got '$mode'"; exit 1; } + + - name: Build + run: cmake --build build --config Release + + - name: Smoke test + metrics (starts, binds, stays up; measures time to 'ready') + shell: bash + run: | + start=$(date +%s%3N) + "${{ matrix.exe }}" --port 47821 > smoke.log 2>&1 & + PID=$! + ready_ms="" + for i in $(seq 1 100); do + if grep -q "ready" smoke.log; then ready_ms=$(( $(date +%s%3N) - start )); break; fi + sleep 0.1 + done + sleep 2 + if kill -0 "$PID" 2>/dev/null; then kill "$PID" 2>/dev/null || true; else echo "FAIL: device exited early"; cat smoke.log; exit 1; fi + [ -n "$ready_ms" ] || { echo "FAIL: no 'ready' line"; cat smoke.log; exit 1; } + cat smoke.log + bytes=$(stat -c %s "${{ matrix.exe }}" 2>/dev/null || wc -c < "${{ matrix.exe }}") + sha=$(sha256sum "${{ matrix.exe }}" | cut -c1-16) + stackver=$(grep -oE 'CAS BACnet Stack version: [0-9.]+' smoke.log | head -1 | grep -oE '[0-9.]+' || true) + appver=$(grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' smoke.log | head -1 || true) + # CMAKE_CXX_COMPILER is not a CMakeCache.txt entry under the multi-config Visual + # Studio generator (only single-config generators like the Linux Unix Makefiles + # one cache it directly) - grep finding nothing here is expected on Windows, and + # with pipefail that makes the pipeline's exit status non-zero, so `|| true` keeps + # a genuinely missing compiler string from aborting the whole metrics step. + compiler=$(grep -E '^CMAKE_CXX_COMPILER_VERSION|^CMAKE_CXX_COMPILER:' build/CMakeCache.txt | head -2 | tr '\n' ' ' || true) + [ -n "$compiler" ] || compiler="$(grep -E '^CMAKE_GENERATOR:' build/CMakeCache.txt | cut -d= -f2 || true) ${{ matrix.os }}" + cat > "${{ matrix.metrics }}" <> "$GITHUB_STEP_SUMMARY" + + - name: Package + shell: bash + run: | + mkdir -p dist + cp "${{ matrix.exe }}" "${{ matrix.metrics }}" dist/ + cp README.md LICENSE dist/ 2>/dev/null || true + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/* + + release: + name: publish release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + generate_release_notes: true + files: artifacts/**/* diff --git a/.gitignore b/.gitignore index 68d6cf9..8723bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # CMake build output build/ + +build-static/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c02c14f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "submodules/cas-bacnet-stack"] + path = submodules/cas-bacnet-stack + url = https://github.com/chipkin/cas-bacnet-stack.git + branch = 6.x diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a38f11a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. See + for the format. Human contributors should read +[README.md](README.md) first. + +## What this project is + +A **tutorial** C++ example that implements the BACnet **B-RTR (Router)** device +profile using the CAS BACnet Stack. It is one of a series - one git repo per +BACnet profile - seeded from B-ASC (Application Specific Controller) minus +DeviceCommunicationControl, plus a second Network Port and physical +inter-datalink routing. It is the series' canonical example for +**F-MULTIPORT** (more than one Network Port) and **F-ROUTER** (router +configuration). The top priority is that the code reads like a tutorial a +customer can learn from and copy-paste. Favour clarity over cleverness. + +## Layout + +This repository is self-contained: + +- `main.cpp` - the example device. +- `common/` - the shared helper, vendored in-repo (not referenced via a path + outside the repository). +- `submodules/cas-bacnet-stack/` - the **CAS BACnet Stack** as a git submodule + (private). After cloning, run `git submodule update --init --recursive`. + +## Build + +This example links the CAS BACnet Stack as a prebuilt **STATIC** library - build +the library once from the pinned submodule commit, then configure and build: + +```bash +git submodule update --init --recursive # once, if not cloned with --recursive +tools/build-stack-static.sh BACnetProfileExample-B-RTR-CPP # from the series root +cmake -B build -S . -DCAS_BACNET_STACK_LINK=STATIC +cmake --build build --config Release +``` + +The stack library build compiles the whole stack (~600 files) once and takes a +few minutes; the example itself then builds in seconds, and later incremental +rebuilds are fast. Use `-D CAS_STACK_DIR=...` only if your stack lives outside +the bundled submodule. The adapter also offers a SOURCE mode (compiles the +stack straight into the executable, no library build); this example builds and +ships STATIC only. + +On Windows, if `msbuild`/CMake pick a toolset the stack's `.vcxproj` lacks, +force `v143` on both: `TOOLSET=v143 tools/build-stack-static.sh ...` and +`cmake -B build -S . -T v143 ...`. + +## Run + +```bash +./build/BACnetExampleBRTR [--port 47808] [--port2 47809] [--deviceID 389018] # Linux/macOS +.\build\Release\BACnetExampleBRTR.exe [--port 47808] [--port2 47809] [--deviceID 389018] # Windows +``` + +Interactive keys while running: `h` help, `q` quit, up/down nudge Analog Input 1, +`r` re-send I-Am-Router-To-Network on both ports. + +## Conventions + +- Device is named "Rainbow"; objects use the series' colour names; vendor id 389. +- Implement **only** the services and objects the B-RTR profile requires - but + expose **every required property** of each object for Protocol_Revision 24. +- Outputs are **commandable**: store the 16-slot `Priority_Array` + + `Relinquish_Default` in the app (the `Commandable` struct); let the stack + resolve `Present_Value`. Writes land via the `SetProperty*` callbacks (value) + and `SetPropertyNull` (relinquish). This part is unchanged from B-SA/B-ASC. +- **Two Network Ports, two UDP sockets (F-MULTIPORT).** Use + `CASExampleHelper::SetupUDP(port, networkPortInstance)` / + `SendIAm(deviceInstance, networkPortInstance)` (`common/` 2.3.0+) - never + reach for a second, separate socket implementation. The shared receive/send + callbacks already dispatch on `networkPortInstance`. +- **Router setup (F-ROUTER).** `BACnetStack_AddRouterPort`'s `networkType` + parameter is the STACK'S OWN internal enum (`BACnetPacket::NetworkType_IP = + 0`), NOT the Network Port object's `Network_Type` property + (`NETWORK_PORT_NETWORK_TYPE_IPV4 = 5`). Mixing them up fails with "unknown + network type" - see `ROUTER_NETWORK_TYPE_IP` in `main.cpp`. +- **Do not enable `Routing_Table`.** Verified against the pinned stack: it + aborts on read outside a test-tool build (stack issue #2038). Do not + re-enable it without first checking whether that stack gap is fixed. +- **Do not claim cross-network forwarding works.** The pinned stack cannot + route between two ports of the same network type (stack issue #2037) - only + router *configuration* is verified. If a future stack pin fixes this, + update README's gap notice, `TODO.md`, and the "Verify" section together. +- Every `GetProperty*` callback ends with `uint32_t* errorCode`. Leave it alone + on a catch-all decline (the stack's decline-and-fabricate default answers + required properties this app does not serve); set it only where this device + knows the read is wrong (`State_Text` out of range is the one case here). +- Match the surrounding code style: `const`-correct parameters, check every stack + return value, keep `main.cpp` linear and well-commented. +- **Never edit `common/` in this repo alone** - it is a vendored copy shared by + every example in the series, with its own version (`COMMON_VERSION`) and + changelog (`common/CHANGELOG.md`). To change it: edit it in + `BACnetProfileExample-B-SS-CPP` (the source of truth) on a branch, bump the + version, add a changelog entry, open a PR there, merge, THEN re-copy + `common/` into every example repository including this one. + +## How to verify a change + +There are no unit tests; verification is behavioural: + +1. Build, then run one instance with `--port`/`--port2` on clear UDP ports. +2. With a BACnet client (e.g. bacpypes3 or CAS BACnet Explorer), send + **Who-Is** on EACH port and confirm **I-Am** from the device instance on + both. +3. **ReadProperty** every required property of every object (both Network + Ports too) directly against each port and confirm the values; confirm + `Protocol_Revision` is 24. +4. **WriteProperty** a commandable output's `Present_Value` at a priority, + re-read it, then write NULL to relinquish. +5. **Router announcement**: confirm `I-Am-Router-To-Network` and + `Network-Number-Is` are sent (TX log lines) at start-up and on the `r` key. +6. **Do NOT assume cross-network forwarding works** - it currently does not + (see Conventions above); do not write a test that assumes it does without + first re-checking the stack gap is still open. + +## Releasing + +Bump `APP_VERSION` in `main.cpp` and add an entry to [CHANGELOG.md](CHANGELOG.md), +then tag `vX.Y.Z`. The GitHub Actions workflow builds and publishes the release. + +## License + +The example source code is dedicated to the public domain under +[CC0-1.0](LICENSE). The CAS BACnet Stack is a separate, commercially licensed +product and is not covered by that dedication. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb0be32 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - unreleased + +### Added + +- First release: implements the **B-RTR (Router)** profile - a stand-alone + BACnet router with two BACnet/IP Network Port objects ("Vermilion", + "Vermilion 2") and physical inter-datalink routing between them. +- BIBBs: DS-RP-B, DS-WP-B, DM-DDB-A/B, DM-DOB-B, NM-RC-B (partial - see below). +- Objects: Device 389018 "Rainbow"; Analog/Binary/Multi-State Input 1 + ("Bronze"/"Emerald"/"Hot Pink"); commandable Analog/Binary/Multi-State + Output 1 ("Chartreuse"/"Fuchsia"/"Indigo"); two Network Ports ("Vermilion" / + network 1, "Vermilion 2" / network 2). +- Seeded from `BACnetProfileExample-B-ASC-CPP` minus DeviceCommunicationControl + (DM-DCC-B), per the profile card. +- **F-MULTIPORT** (series-canonical): uses `common/` 2.3.0's multi-port + `SetupUDP(port, networkPortInstance)` / `SendIAm(deviceInstance, + networkPortInstance)` overloads to own two independent UDP sockets, one per + Network Port. +- **F-ROUTER** (series-canonical): `BACnetStack_AddRouterPort`, + `BACnetStack_AddRouterRoute` (one demonstration-only route), + `BACnetStack_SetRouterEnabled`, `BACnetStack_SendIAmRouterToNetwork` (also + bound to the new `r` key, `common/` 2.4.0), `BACnetStack_SendWhoIsRouterToNetwork`, + `BACnetStack_SendNetworkNumberIs`. +- Pinned to CAS BACnet Stack `6.x` @ `abd4cee1` (reports 6.0.21), linked as a + prebuilt STATIC library (`tools/build-stack-static.sh`). +- Vendors `common/` 2.5.0 (adds `PROPERTY_IDENTIFIER_ROUTING_TABLE` and the + `r` key on top of 2.3.0's multi-port sockets). + +### Known gaps (see `TODO.md`) + +- **DM-LM-B (AddListElement/RemoveListElement, services 8/9) not implemented.** + The customer-facing STATIC build of the pinned stack does not compile in + `STACK_OPTION_DM_LM_LIST_MANIPULATION`. [stack issue #2033](https://github.com/chipkin/cas-bacnet-stack/issues/2033) +- **Inter-network NPDU forwarding does not work.** The pinned stack holds one + internal datalink instance per network type; two BACnet/IP router ports + cannot both attribute ingress traffic, so nothing is actually forwarded + between the two networks, even though router configuration + (`AddRouterPort`/`AddRouterRoute`/`SetRouterEnabled`) succeeds. + [stack issue #2037](https://github.com/chipkin/cas-bacnet-stack/issues/2037) +- **`Routing_Table` cannot be read.** Enabling it and reading it aborts the + request; the pinned stack has no `UseCallbackGetList` case for Network + Port/Routing_Table outside a test-tool build. Not enabled in this example. + [stack issue #2038](https://github.com/chipkin/cas-bacnet-stack/issues/2038) + +### Verified + +- STATIC build, zero warnings from `main.cpp`/`common/`. +- Live-client (bacpypes3) wire verification: ReadProperty of the Device and + both Network Port objects directly on each port; Network_Number = 1/2 and + Network_Number_Quality = configured on the respective ports; WriteProperty + + readback on Analog Output 1; I-Am-Router-To-Network transmission on both + ports at start-up and on the `r` key. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..10bce1f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,99 @@ +# ============================================================================= +# CMake build for the BACnet B-RTR (Router) C++ example. +# +# This example links the CAS BACnet Stack through the CASBACnetStack::Adapter CMake +# target (submodules/cas-bacnet-stack/adapters/cpp) and calls the stack's C API +# (BACnetStack_*, via CASBACnetStackAdapter.h) directly. This example builds and +# ships in STATIC mode: CAS_BACNET_STACK_LINK=STATIC links a prebuilt +# CASBACnetStack_x64_Release.lib / libCASBACnetStack_x64_Release.a, built first by +# tools/build-stack-static.sh from the stack's own project files. (The adapter also +# offers a SOURCE mode that compiles the stack's source/ straight into the +# executable with no separate library build; this example is not built that way.) +# +# Build: +# tools/build-stack-static.sh BACnetProfileExample-B-RTR-CPP (from the series root) +# cmake -B build -S . -DCAS_BACNET_STACK_LINK=STATIC +# cmake --build build --config Release --parallel +# Run (from the example folder): +# ./build/BACnetExampleBRTR (Linux/macOS) +# .\build\Release\BACnetExampleBRTR.exe (Windows) +# ============================================================================= + +cmake_minimum_required(VERSION 3.15) +project(BACnetProfileExample_CPP_B_RTR CXX C) + + +# Default to a Release build. +# +# Without this, a single-config generator (Unix Makefiles / Ninja - the default +# on Linux and macOS) leaves CMAKE_BUILD_TYPE EMPTY, and the documented +# `cmake --build build --config Release` is SILENTLY IGNORED there: --config +# only means anything to a multi-config generator like Visual Studio. The result +# is that the whole stack (~600 files) compiles at -O0 and the customer never +# finds out. Windows is unaffected, which is exactly why this survived so long. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# --- Locate the pieces this project ships ----------------------------------- +# The shared helper is vendored into this repository at ./common. +if(NOT DEFINED EXAMPLES_COMMON_DIR) + set(EXAMPLES_COMMON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/common") +endif() + +# The CAS BACnet Stack is a git submodule of this repository, at +# ./submodules/cas-bacnet-stack. Run `git submodule update --init --recursive` +# after cloning. (Override with -D CAS_STACK_DIR=... to use a stack elsewhere.) +if(NOT DEFINED CAS_STACK_DIR) + set(CAS_STACK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/submodules/cas-bacnet-stack") +endif() + +if(NOT EXISTS "${CAS_STACK_DIR}/adapters/cpp/CMakeLists.txt") + message(FATAL_ERROR + "CAS BACnet Stack adapter not found under: ${CAS_STACK_DIR}\n" + "The stack is a git submodule. After cloning this repository, run:\n" + " git submodule update --init --recursive\n" + "Or pass -D CAS_STACK_DIR=/path/to/cas-bacnet-stack to use a stack elsewhere.") +endif() + +# --- The CAS BACnet Stack, via its CMake adapter target --------------------- +# CAS_BACNET_STACK_LINK is the adapter's own cache variable (SOURCE/STATIC/DLL, +# default SOURCE) - set it BEFORE add_subdirectory() so the adapter target picks it up. +# See submodules/cas-bacnet-stack/adapters/cpp/CMakeLists.txt for what each mode does. +add_subdirectory("${CAS_STACK_DIR}/adapters/cpp" cas-bacnet-stack-adapter) + +# --- The shared example helper ---------------------------------------------- +set(COMMON_SOURCES + "${EXAMPLES_COMMON_DIR}/CASExampleHelper.cpp" + "${EXAMPLES_COMMON_DIR}/SimpleUDP.cpp") + +# --- The executable --------------------------------------------------------- +add_executable(BACnetExampleBRTR + main.cpp + ${COMMON_SOURCES}) + +target_include_directories(BACnetExampleBRTR PRIVATE + "${EXAMPLES_COMMON_DIR}") + +target_link_libraries(BACnetExampleBRTR PRIVATE CASBACnetStack::Adapter) + +# --- Warnings on OUR sources only ------------------------------------------- +# Apply strict warnings to the example + helper sources, but NOT to the bundled +# third-party stack (~600 files, built inside the CASBACnetStack::Adapter target) +# which would be noisy and isn't ours to fix. +if(MSVC) + set(EXAMPLE_WARNINGS "/W4") +else() + set(EXAMPLE_WARNINGS "-Wall;-Wextra") +endif() +set_source_files_properties(main.cpp ${COMMON_SOURCES} + PROPERTIES COMPILE_OPTIONS "${EXAMPLE_WARNINGS}") + +# --- Platform sockets ------------------------------------------------------- +# ws2_32 = Winsock (UDP); iphlpapi = GetAdaptersInfo (local broadcast address). +if(WIN32) + target_link_libraries(BACnetExampleBRTR PRIVATE ws2_32 iphlpapi) +endif() diff --git a/README.md b/README.md index 9c3ea3e..1065be6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,670 @@ -# BACnet B-RTR (Router) — C++ example (planned) +# BACnet B-RTR (Router) - C++ example -Part of the CAS BACnet Stack **BACnet profile example series** — one self-contained -C++ tutorial per ASHRAE 135 Annex L device profile. +A minimal, copy-paste-friendly example showing how to implement the BACnet +**B-RTR (Router)** device profile in C++ using the +[CAS BACnet Stack](https://store.chipkin.com/services/stacks/bacnet-stack). +It is a stand-alone BACnet router: it owns **two BACnet/IP Network Port +objects**, one per network it joins, and configures physical inter-datalink +routing between them, answers **ReadProperty**/**WriteProperty**, and is +discoverable via **Who-Is / I-Am**. -> **Status: planned.** This repository currently holds the implementation plan -> only. See [`docs/plan.md`](docs/plan.md) for the design (required BIBBs, objects, -> shared features, build steps). The example code (`main.cpp`, vendored `common/`, -> the `cas-bacnet-stack` submodule, CMake, full README) is scaffolded from that -> plan, following the -> [B-SS reference](https://github.com/chipkin/BACnetProfileExample-B-SS-CPP). +Part of the CAS BACnet Stack **BACnet profile example series** - one repository +per BACnet device profile. This example claims **only** B-RTR, and is the +series' canonical example for **F-ROUTER** and **F-MULTIPORT** (a device +owning more than one Network Port). -It will implement the **B-RTR (Router)** profile and nothing more, so it stays a -clean, copy-paste tutorial. Example source is CC0-1.0; the CAS BACnet Stack is a -separate commercial product. +Reading order: this repository stands on its own - **you can start here.** If you also want the +gentler introductions to the shared sensor/actuator objects, [B-SS (Smart +Sensor)](https://github.com/chipkin/BACnetProfileExample-B-SS-CPP) is the first example in the +series; this one repeats everything it needs. + +> **Versions:** this document describes **example v1.0.0**, built and verified +> against **CAS BACnet Stack 6.0.21** (`6.x` @ `abd4cee1`), linked as a static +> library, at **Protocol_Revision 24**, with the vendored `common/` helper at +> **v2.5.0**. Running the example prints all three - if what it prints +> disagrees with this line, trust the program and check `CHANGELOG.md`. + +> **IMPORTANT - read before you rely on this example for routing.** The pinned +> CAS BACnet Stack build genuinely accepts this device's router **configuration** +> (`AddRouterPort`, `AddRouterRoute`, `SetRouterEnabled` all succeed; `Network_Number` +> and `Network_Number_Quality` read back correctly on both ports; I-Am-Router-To-Network +> / Who-Is-Router-To-Network / Network-Number-Is all send) - but it does **NOT** +> forward NPDUs between the two ports, because both are BACnet/IP and the stack +> holds only one internal datalink instance per network type at this pin +> (a scope gap left by stack issue #304, which fixed this only for BACnet/SC; see +> `TODO.md` and [stack issue #2037](https://github.com/chipkin/cas-bacnet-stack/issues/2037)). +> A client reachable only through the OTHER port is **not** actually routed to. +> Each port answers ReadProperty/WriteProperty directly and correctly on its own +> network - what does not work is forwarding traffic **between** the two networks. + +## Quickstart + +You need a CAS BACnet Stack licence and access to its private submodule (see +[Requires the CAS BACnet Stack](#requires-the-cas-bacnet-stack-licensed-product)). +Then: + +```bash +git clone --recursive https://github.com/chipkin/BACnetProfileExample-B-RTR-CPP.git +cd BACnetProfileExample-B-RTR-CPP +tools/build-stack-static.sh BACnetProfileExample-B-RTR-CPP # from the series root; builds the static library +cmake -B build -S . -DCAS_BACNET_STACK_LINK=STATIC +cmake --build build --config Release +./build/Release/BACnetExampleBRTR.exe # Windows; drop Release/ on Linux +``` + +The device binds two UDP ports, announces itself on both networks, and prints +`Press 'h' for help`. + +> **You will see one or more red `Error:` lines at start-up. Most of the +> device is fine** - see [Troubleshooting](#troubleshooting); the +> `FindPortByNetworkType` / `BindRouterPort` lines specifically are the known, +> documented routing gap above, not a bug in this example. + +## What is a B-RTR (Router) profile? + +A **device profile** is a standard "template" defined in Annex L of ANSI/ASHRAE +135. It lists the capabilities a class of device must support so that any +compliant client knows what to expect, and the BACnet Testing Laboratories (BTL) +certify devices against it. (New to BACnet in general? See Chipkin's +[What is BACnet?](https://docs.chipkin.com/protocols/bacnet/) guide.) + +**B-RTR (Router)** is a stand-alone BACnet router (ANSI/ASHRAE 135 clause 6.6): +a device with two (or more) Network Port objects that forwards NPDUs between +the BACnet networks they connect, and speaks the network-layer messages other +devices and routers use to discover it and learn the internetwork's topology. + +**Reading the capability names.** Each capability below is a **BIBB** (BACnet +Interoperability Building Block). Every BIBB name ends in **-A** or **-B**: +**-A** = the device that *initiates* a request; **-B** = the device that +*responds*. `DM-DDB-A` means this device also **initiates** Who-Is (not just +answers it) - appropriate for a router, which is expected to actively discover +devices on the networks it joins. + +**What the profile requires:** + +- **Data Sharing - ReadProperty - B side (DS-RP-B):** answer **ReadProperty**. +- **Data Sharing - WriteProperty - B side (DS-WP-B):** accept **WriteProperty** + to its objects. +- **Device Management - Dynamic Device Binding - A/B (DM-DDB-A/B):** answer + **Who-Is** with **I-Am**, broadcast an unsolicited I-Am on each of its + networks at start-up, and (A side) itself send **Who-Is** to discover other + devices. +- **Device Management - Dynamic Object Binding - B side (DM-DOB-B):** answer + **Who-Has** with **I-Have**. +- **Network Management - Router Configuration - B side (NM-RC-B):** configure + router ports/routes; answer **Who-Is-Router-To-Network** with + **I-Am-Router-To-Network**; announce **Network-Number-Is** on each network. + +**What the profile does NOT require** - and this example omits on purpose: +**alarming / event reporting**, **scheduling**, **trending**, and (see the +gap notice above and `TODO.md`) **AddListElement/RemoveListElement (DM-LM-B)**, +which the pinned stack's customer-facing build does not compile in. + +## The device this example creates + +``` +Device 389018 "Rainbow" (Vendor 389 - Chipkin Automation Systems) + | + +-- Analog Input 1 "Bronze" Present_Value 21.5 (REAL, degrees Celsius; read-only) + +-- Binary Input 1 "Emerald" Present_Value inactive (0 = inactive / 1 = active; read-only) + +-- Multi-State Input 1 "Hot Pink" Present_Value 1 (state, 1..3; read-only) + +-- Analog Output 1 "Chartreuse" Present_Value 20.0 (REAL setpoint; WRITABLE, commandable) + +-- Binary Output 1 "Fuchsia" Present_Value inactive (0/1; WRITABLE, commandable) + +-- Multi-State Output 1 "Indigo" Present_Value 1 (state, 1..3; WRITABLE, commandable) + +-- Network Port 1 "Vermilion" BACnet/IP, network 1 (port A of the router) + +-- Network Port 2 "Vermilion 2" BACnet/IP, network 2 (port B of the router) +``` + +The three **input** objects and three **output** objects are the shared +minimum/commandable pattern every example in this series carries (inherited +from B-SA/B-ASC, minus B-ASC's DeviceCommunicationControl). The **two Network +Ports** are this example's own contribution - see F-MULTIPORT/F-ROUTER below. + +## F-MULTIPORT: two Network Ports, two UDP sockets + +Every other example in this series owns exactly one Network Port and one UDP +socket. This example is the series' first with two, and is canonical for that +pattern (`common/` 2.3.0+): `CASExampleHelper::SetupUDP(port, networkPortInstance)` +binds an additional socket keyed to a specific Network Port instance, and the +shared receive/send callbacks (`common/CASExampleHelper.cpp`) dispatch on that +instance internally - `main.cpp` just calls `SetupUDP` twice and +`SendIAm(deviceInstance, networkPortInstance)` once per port. See +`common/CHANGELOG.md`'s 2.3.0 entry and the "WHY THIS IS SAFE FOR SINGLE-PORT +EXAMPLES" comment in `CASExampleHelper.cpp` for why this did not change any +other (single-port) example's behaviour. + +Both ports run on **one host** and are distinguished purely by **UDP port +number** (`--port` / `--port2`, default `--port + 1`) - they report the same +`IP_Address`/`IP_Subnet_Mask` and different `BACnet_IP_UDP_Port` / +`Network_Number`. + +## F-ROUTER: router configuration + +At start-up, `main.cpp`: + +1. `BACnetStack_AddRouterPort` once per Network Port, registering the network + it directly connects (network 1 on Vermilion, network 2 on Vermilion 2). +2. `BACnetStack_AddRouterRoute` for one DEMONSTRATION-ONLY network (3), one hop + beyond port 2, with no next-hop MAC configured - this exercises the API; no + device in this example series actually sits on network 3 (see `main.cpp`'s + `DEMO_REMOTE_NETWORK` comment). +3. `BACnetStack_SetRouterEnabled(deviceInstance, true)` - routing is opt-in + even with ports configured. +4. Broadcasts **I-Am-Router-To-Network** on both ports (`networkNumbers = NULL`, + announcing this router's full configured routing table per the stack's + `#1014` fix), and again whenever the **`r`** key is pressed. +5. Broadcasts **Network-Number-Is** on both ports, announcing each network's + own (configured) number. +6. Broadcasts **Who-Is-Router-To-Network** (unrestricted) on both ports, the + polite router start-up probe for any other router already present (none is + expected to answer in this example). + +`BACnetStack_AddRouterPort`'s `networkType` parameter is the stack's own +internal datalink enum (`BACnetPacket::NetworkType_IP = 0`), which is a +**different enumeration** from the Network Port object's `Network_Type` +property (`NETWORK_PORT_NETWORK_TYPE_IPV4 = 5`, used with +`AddNetworkPortObject`) - passing the wrong one fails with "unknown network +type"; see the `ROUTER_NETWORK_TYPE_IP` comment in `main.cpp`. + +**`Routing_Table` (cl. 12.56.62) is deliberately NOT enabled or claimed.** +Verified on the wire: enabling it and reading it aborts the request, because +the pinned stack's `BACnetBusinessLogic::UseCallbackGetList` has no case for +Network Port/Routing_Table outside a test-tool build. See `TODO.md` and +[stack issue #2038](https://github.com/chipkin/cas-bacnet-stack/issues/2038). + +## What this example supports + +### BIBBs (BACnet Interoperability Building Blocks) + +| BIBB | Description | Supported | +|------|-------------|:---------:| +| DS-RP-B | Data Sharing - ReadProperty - B | ✅ | +| DS-WP-B | Data Sharing - WriteProperty - B | ✅ | +| DM-DDB-A | Device Management - Dynamic Device Binding - A (initiates Who-Is) | ✅ | +| DM-DDB-B | Device Management - Dynamic Device Binding - B | ✅ | +| DM-DOB-B | Device Management - Dynamic Object Binding - B | ✅ | +| NM-RC-B | Network Management - Router Configuration - B | 🟡 partial - see the gap notice above; configuration/announcement genuinely work, inter-network *forwarding* does not at this stack pin | +| DM-LM-B | Device Management - List Manipulation - B (AddListElement/RemoveListElement) | ❌ not implemented - `TODO.md`, [stack issue #2033](https://github.com/chipkin/cas-bacnet-stack/issues/2033) | + +### Services (executed / B-side) + +| Service | Notes | +|---------|-------| +| ReadProperty (12) | Responds to property reads on both networks (DS-RP-B). Verified with a live client. | +| WriteProperty (15) | Accepts writes to the commandable outputs' Present_Value (DS-WP-B). Verified with a live client. | +| Who-Is / I-Am | Answers Who-Is with I-Am; broadcasts I-Am on start-up on BOTH networks (DM-DDB-A/B). | +| Who-Has / I-Have | Answers Who-Has with I-Have (DM-DOB-B). | +| Who-Is-Router-To-Network / I-Am-Router-To-Network | Sent on both ports at start-up and on the `r` key (NM-RC-B). Verified sent (TX on the wire); not verified that this device *answers* an incoming Who-Is-Router-To-Network with a matching reply - see Verify below. | +| Network-Number-Is | Sent on both ports at start-up (NM-RC-B). | +| AddListElement (8) / RemoveListElement (9) | **Not implemented** - see `TODO.md`. | + +### Object types + +| Object type | Instance | Name | Access | +|-------------|:--------:|------|--------| +| Device | 389018 | Rainbow | - | +| Analog Input | 1 | Bronze | read-only | +| Binary Input | 1 | Emerald | read-only | +| Multi-State Input | 1 | Hot Pink | read-only | +| Analog Output | 1 | Chartreuse | writable (commandable) | +| Binary Output | 1 | Fuchsia | writable (commandable) | +| Multi-State Output | 1 | Indigo | writable (commandable) | +| Network Port | 1 | Vermilion | network 1 | +| Network Port | 2 | Vermilion 2 | network 2 | + +## Before you ship + +This example is a tutorial, and it identifies itself as one. Everything in this +table is read by clients and shown to the operator in **every discovery tool on +the network**. Left as-is, your product appears on a real site announcing itself +as a Chipkin demo. None of it is cosmetic. + +| Constant (`main.cpp`) | Ships as | Change it to | +|---|---|---| +| `VENDOR_IDENTIFIER` | `389` (Chipkin) | **Your** company's vendor ID. Assigned by ASHRAE, free: | +| `VENDOR_NAME` | `Chipkin Automation Systems` | Your company name — must match the vendor ID above. | +| `DEVICE_NAME` | `"Rainbow"` | Your device's `Object_Name`. **Must be unique across the BACnet internetwork.** | +| `MODEL_NAME` | `CAS BACnet Stack Example - B-RTR` | Your model designation. | +| `DEVICE_DESCRIPTION` | a description of *this example* | What your device actually is. | +| `FIRMWARE_REVISION` / `APPLICATION_SOFTWARE_VERSION` | `1.0.0` | Your real versions. | +| Device instance | `389018` (`--deviceID` overrides) | Must be unique on the internetwork. | +| `NETWORK_NUMBER_PORT_1` / `NETWORK_NUMBER_PORT_2` | `1` / `2` | Your site's actual network numbers - these must be unique across the whole BACnet internetwork, same as a device instance. | + +`main.cpp` marks this block with a `CHANGE ALL OF THIS BEFORE YOU SHIP` banner. + +## Requires the CAS BACnet Stack (licensed product) + +This example **builds against the CAS BACnet Stack, which is a commercial Chipkin +product** - it is not free or open source, and there is no public/trial build. +The stack is referenced here as the **private** git submodule +`submodules/cas-bacnet-stack`; you can only fetch and build it once you have a CAS +BACnet Stack license and access to that repository. + +**To get the CAS BACnet Stack (and access to build this example), contact +Chipkin:** or +sales@chipkin.com. + +You do not need a stack licence to *read* this example. Every file outside +submodules/ is CC0 public domain. + +## What's in this repository + +- `main.cpp` - the example device. +- `common/` - the shared helper (UDP, callbacks, CLI, keyboard) vendored in. +- `submodules/cas-bacnet-stack/` - the **CAS BACnet Stack as a git submodule** + (private; requires a license). Built into a prebuilt **STATIC** library by + the stack's own project files (`tools/build-stack-static.sh`), then linked - + no DLL is shipped. + +## Prerequisites + +- A C++17 compiler (MSVC, GCC, or Clang). +- CMake >= 3.15. +- Git (to fetch the stack submodule). + +### Windows + +- **C++ compiler** - install + [Visual Studio Community](https://visualstudio.microsoft.com/downloads/) + (free) and select the **"Desktop development with C++"** workload. +- **CMake** - from , or `winget install Kitware.CMake`. + +### Linux / macOS + +- Debian/Ubuntu: `sudo apt install build-essential cmake git` +- macOS: `xcode-select --install` and `brew install cmake` + +## Get the code + +```bash +git clone --recursive https://github.com/chipkin/BACnetProfileExample-B-RTR-CPP.git +cd BACnetProfileExample-B-RTR-CPP + +# already cloned without --recursive? fetch the submodule: +git submodule update --init --recursive +``` + +## Build + +This example links the CAS BACnet Stack as a prebuilt **STATIC** library. Build +the library once from the pinned submodule commit, then configure and build the +example against it: + +```bash +tools/build-stack-static.sh BACnetProfileExample-B-RTR-CPP # from the series root +cmake -B build -S . -DCAS_BACNET_STACK_LINK=STATIC +cmake --build build --config Release +``` + +> **The stack library build takes a few minutes** the first time - it compiles +> the entire CAS BACnet Stack (~600 source files) once. The example itself then +> builds in seconds and rebuilds incrementally. Build in parallel to cut that +> down: `cmake --build build --config Release --parallel`. +> +> On Windows, if `msbuild` picks a toolset the stack's `.vcxproj` does not have +> installed, force the one Visual Studio 2022 ships (`v143`): +> `TOOLSET=v143 tools/build-stack-static.sh BACnetProfileExample-B-RTR-CPP`, and +> configure CMake with the matching generator toolset: `cmake -B build -S . -T v143 ...`. + +If your CAS BACnet Stack lives somewhere other than the bundled submodule, point +CMake at it: `cmake -B build -S . -D CAS_STACK_DIR=/path/to/cas-bacnet-stack`. + +### Link mode + +This example links the stack through the `CASBACnetStack::Adapter` CMake target +in **STATIC** mode - `-DCAS_BACNET_STACK_LINK=STATIC` links the prebuilt +`CASBACnetStack_x64_Release.lib` / `libCASBACnetStack_x64_Release.a` built by +`tools/build-stack-static.sh` above. Every mode requires calling +`LoadBACnetFunctions()` once at the top of `main()` before any other +`BACnetStack_*` call. On MSVC the adapter also forces the static CRT (`/MT`) to +match how the library is built. A **SOURCE** mode also exists (compiles the +stack's `source/*.cpp` straight into the executable) - this example is built +and published in **STATIC** mode only. + +## Run + +```bash +# Linux / macOS +./build/BACnetExampleBRTR --port 47808 --port2 47809 + +# Windows +.\build\Release\BACnetExampleBRTR.exe --port 47808 --port2 47809 +``` + +Expected output: + +``` +BACnet B-RTR (Router) Example - C++ v1.0.0 +CAS BACnet Stack version: 6.0.21.0 +Common helper (common/) version: 2.5.0 +FYI: Listening for BACnet/IP on UDP port 47808 (Network Port 1). +FYI: Listening for BACnet/IP on UDP port 47809 (Network Port 2). +... (router-port bind/attribution "Error:" lines - see the gap notice above) ... +TX 21 bytes to 192.168.3.255:47808 (broadcast) (Network Port 1) +TX 21 bytes to 192.168.3.255:47809 (broadcast) (Network Port 2) +FYI: I-Am-Router-To-Network broadcast on Network Port 1 (Vermilion): sent; on Network Port 2 (Vermilion 2): sent +FYI: Device 389018 ("Rainbow") ready. Vendor ID 389. Routing network 1 (Vermilion, UDP 47808) <-> network 2 (Vermilion 2, UDP 47809). Press 'h' for help. +``` + +The device listens on UDP **47808** and **47809** by default. Allow both ports +through your firewall. + +### Command-line options + +| Option | Default | Meaning | +|--------|---------|---------| +| `--port ` | `47808` | UDP port for Network Port 1 (Vermilion). | +| `--port2 ` | `--port + 1` | UDP port for Network Port 2 (Vermilion 2). Must differ from `--port`. | +| `--deviceID ` | `389018` | The device's BACnet instance number. | +| `--help`, `-h` | - | Show usage and exit. | +| `--version` | - | Print the example, stack, and `common/` helper versions, then exit. | + +### Interactive commands + +| Key | Action | +|-----|--------| +| `h` | Show the version information and this command list. | +| `q` | Quit. | +| up arrow | Increase Analog Input 1 (`Bronze`) by 1.1. | +| down arrow | Decrease Analog Input 1 (`Bronze`) by 1.1. | +| `r` | Re-send I-Am-Router-To-Network on both ports now (`common/` 2.4.0+). | + +## Verify + +You need a BACnet client. [bacpypes3](https://github.com/JoelBender/bacpypes3) +or a GUI tool such as [YABE](https://sourceforge.net/projects/yetanotherbacnetexplorer/) +both work. + +**Confirmed working, on the live wire, against this build:** + +1. **Discover on each network independently** - ReadProperty of Device + `Object_Name` succeeds directly against `127.0.0.1:47808` (network 1) AND + `127.0.0.1:47809` (network 2), both returning `"Rainbow"` (DS-RP-B works on + both ports). +2. **Network_Number / Network_Number_Quality** - ReadProperty of Network Port 1's + `Network_Number` = `1`, `Network_Number_Quality` = `configured`; Network Port + 2's = `2` / `configured`. Both `Object_Name`s (`Vermilion` / `Vermilion 2`) + read back correctly. +3. **WriteProperty (DS-WP-B)** - WriteProperty Analog Output 1 `Present_Value` = + `25.5` at priority 8; re-read confirms `25.5`. +4. **I-Am-Router-To-Network transmission** - confirmed via the device's own TX + log at start-up and on the `r` key. + +**NOT verified / known not to work - do not rely on these:** + +- **Cross-network forwarding.** A ReadProperty sent to a device reachable only + through the OTHER Network Port is **not** routed - see the gap notice at the + top of this README and `TODO.md` ([stack issue #2037](https://github.com/chipkin/cas-bacnet-stack/issues/2037)). + This was the runbook's own wire-verification target for this example and it + genuinely fails at this stack pin; it is not a gap in this example's code. +- **`Routing_Table` readback.** Deliberately not enabled - see `TODO.md` + ([stack issue #2038](https://github.com/chipkin/cas-bacnet-stack/issues/2038)). +- **This device answering an incoming Who-Is-Router-To-Network.** Only the + outbound (start-up / `r`-key) direction was exercised in verification; a + peer router's unsolicited query was not tested against this build. +- **The `DEMO_REMOTE_NETWORK` (3) route.** No device sits on it; `AddRouterRoute` + accepting the call is confirmed, an actual forwarded packet is not (and + could not be, given the forwarding gap above). + +## Troubleshooting + +| Symptom | Cause / fix | +|---------|-------------| +| Start-up prints `BindRouterPort ... second port of networkType=[0] ... issue #304` and `FindPortByNetworkType ... Cannot attribute an incoming NPDU` | **Expected, not your bug.** The pinned stack cannot route between two ports of the same network type (both BACnet/IP here) - see the gap notice at the top of this README, `TODO.md`, and [stack issue #2037](https://github.com/chipkin/cas-bacnet-stack/issues/2037). Each port still works fine on its own network. | +| ReadProperty of `Routing_Table` aborts | Expected - not enabled in this example; see `TODO.md` and [stack issue #2038](https://github.com/chipkin/cas-bacnet-stack/issues/2038). | +| On start-up the app also prints a `UUID has not been set` line | Benign - the stack starts a BACnet/SC datalink this BACnet/IP-only example never configures. Same as every other example in the series. | +| CMake error: *"CAS BACnet Stack adapter not found under: ..."* | Submodules not initialized. Run `git submodule update --init --recursive`. | +| `Failed to bind UDP port ...` | Another program is using that port. Stop it, or pass a different `--port`/`--port2`. | +| `--port2` equals `--port` | Rejected on purpose - they must be two different UDP ports (two different simulated networks). | +| First build seems stuck for minutes | Normal - it's compiling ~600 stack files. Only the first build is slow. | +| `git submodule update` fails with *Permission denied* / *repository not found* | The CAS BACnet Stack submodule is a **private** repo - see [Requires the CAS BACnet Stack](#requires-the-cas-bacnet-stack-licensed-product). | + +## The BACnet profile example series + + +The CAS BACnet Stack supports every standardized device profile in ASHRAE 135-2024 Annex L. One example repository per profile shows how. ✅ = the required BIBB (service) is supported by the CAS BACnet Stack; the **Example** column is the state of that profile's tutorial repository. + +### Controllers (Annex L.4) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-SS** Smart Sensor | [B-SS-CPP](https://github.com/chipkin/BACnetProfileExample-B-SS-CPP) ✅ | ✅ DS-RP-B · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-SA** Smart Actuator | [B-SA-CPP](https://github.com/chipkin/BACnetProfileExample-B-SA-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-ASC** Application Specific Controller | [B-ASC-CPP](https://github.com/chipkin/BACnetProfileExample-B-ASC-CPP) ✅ · [B-ASC-Node](https://github.com/chipkin/BACnetProfileExample-B-ASC-Node) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B | +| **B-AAC** Advanced Application Controller | [B-AAC-CPP](https://github.com/chipkin/BACnetProfileExample-B-AAC-CPP) ✅ | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ AE-N-I-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-CRL-B · ✅ SCHED-I-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B | +| **B-BC** Building Controller | [B-BC-CPP](https://github.com/chipkin/BACnetProfileExample-B-BC-CPP) 📝 | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-RPM-B · ✅ DS-WP-A · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ AE-N-I-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-CRL-B · ✅ SCHED-E-B · ✅ T-VMT-I-B · ✅ T-ATR-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B · ✅ DM-BR-B | + +### Life safety controllers (Annex L.5) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-LSC** Life Safety Controller | [B-LSC-CPP](https://github.com/chipkin/BACnetProfileExample-B-LSC-CPP) 📝 | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-B · ✅ AE-LS-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B | +| **B-ALSC** Advanced Life Safety Controller | [B-ALSC-CPP](https://github.com/chipkin/BACnetProfileExample-B-ALSC-CPP) 📝 | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-B · ✅ AE-LS-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-EL-I-B · ✅ SCHED-I-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B | + +### Access control controllers (Annex L.6) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-ACC** Access Control Controller | [B-ACC-CPP](https://github.com/chipkin/BACnetProfileExample-B-ACC-CPP) 📝 | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-B · ✅ DS-ACUC-B · ✅ DS-ACSC-B · ✅ AE-AC-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-EL-I-B · ✅ SCHED-I-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B · ✅ DM-BR-B | +| **B-AACC** Advanced Access Control Controller | [B-AACC-CPP](https://github.com/chipkin/BACnetProfileExample-B-AACC-CPP) 📝 | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-RPM-B · ✅ DS-WP-A · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-A · ✅ DS-COV-B · ✅ DS-ACAD-A · ☐ DS-ACCDI-A · ✅ DS-ACUC-B · ✅ DS-ACSC-B · ✅ AE-AC-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-EL-I-B · ✅ SCHED-I-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B · ✅ DM-BR-B | + +### Lighting controllers (Annex L.11) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-LD** Lighting Device | [B-LD-CPP](https://github.com/chipkin/BACnetProfileExample-B-LD-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DS-LO-B / DS-BLO-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B | +| **B-LS** Lighting Supervisor | [B-LS-CPP](https://github.com/chipkin/BACnetProfileExample-B-LS-CPP) 📝 | ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-WP-B · ✅ DS-WG-E-B · ✅ DS-ALO-A · ✅ SCHED-E-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B | + +### Elevator controllers (Annex L.13) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-EM** Elevator Monitor | [B-EM-CPP](https://github.com/chipkin/BACnetProfileExample-B-EM-CPP) ✅ | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-COV-B · ✅ DS-COVM-B · ✅ AE-N-I-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B | +| **B-EC** Elevator Controller | [B-EC-CPP](https://github.com/chipkin/BACnetProfileExample-B-EC-CPP) 📝 | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-B · ✅ DS-COVM-B · ✅ AE-N-I-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-RD-B | +| **B-AEC** Advanced Elevator Controller | [B-AEC-CPP](https://github.com/chipkin/BACnetProfileExample-B-AEC-CPP) 📝 | ✅ DS-RP-B · ✅ DS-RPM-B · ✅ DS-WP-B · ✅ DS-WPM-B · ✅ DS-COV-B · ✅ DS-COVM-B · ✅ AE-N-I-B · ✅ AE-ACK-B · ✅ AE-INFO-B · ✅ AE-EL-I-B · ✅ SCHED-I-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B · ✅ DM-OCD-B · ✅ DM-RD-B · ✅ DM-BR-B | + +### Authentication and authorization (Annex L.14) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-AS** Authorization Server | [B-AS-CPP](https://github.com/chipkin/BACnetProfileExample-B-AS-CPP) 📝 | ✅ DS-RP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ AA-AS-B | + +### Miscellaneous (Annex L.7, combinable with any one family) + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-BBMD** Broadcast Management Device | [B-BBMD-CPP](https://github.com/chipkin/BACnetProfileExample-B-BBMD-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ NM-BBMDC-B | +| **B-ACDC** Access Control Door Controller | [B-ACDC-CPP](https://github.com/chipkin/BACnetProfileExample-B-ACDC-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DS-ACAD-B · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-ACCR** Access Control Credential Reader | [B-ACCR-CPP](https://github.com/chipkin/BACnetProfileExample-B-ACCR-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DS-COV-B · ✅ DS-ACCDI-B · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-RTR** Router | [B-RTR-CPP](https://github.com/chipkin/BACnetProfileExample-B-RTR-CPP) 📝 | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-A · ✅ DM-DOB-B · ✅ DM-LM-B · ✅ NM-RC-B | +| **B-GW** Gateway | [B-GW-CPP](https://github.com/chipkin/BACnetProfileExample-B-GW-CPP) 📝 | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ GW-EO-B / GW-VN-B | +| **B-DAP** Device Address Proxy | [B-DAP-CPP](https://github.com/chipkin/BACnetProfileExample-B-DAP-CPP) ✅ | ✅ DS-RP-B · ✅ DS-WP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DAB-B | +| **B-SCHUB** BACnet/SC Hub | [B-SCHUB-CPP](https://github.com/chipkin/BACnetProfileExample-B-SCHUB-CPP) 📝 | ✅ DS-RP-B · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ NM-SCH-B | +| **B-GENERAL** General device (Annex L.8) | *(satisfied by every example above)* | ✅ DS-RP-B · ✅ DM-DDB-B · ✅ DM-DOB-B | + +### Operator interfaces and workstations (Annex L.1–L.3, L.9–L.10, L.12) — client-side profiles + +| Profile | Example | Required BIBBs (services) | +|---|---|---| +| **B-OD** Operator Display | [B-OD-CPP](https://github.com/chipkin/BACnetProfileExample-B-OD-CPP) ✅ | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-V-A · ✅ DS-M-A · ✅ AE-N-A · ✅ AE-VN-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-OWS** Operator Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-V-A · ✅ DS-M-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-VM-A · ✅ AE-VN-A · ✅ SCHED-VM-A · ✅ T-V-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-MTS-A | +| **B-AWS** Advanced Operator Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-AV-A · ✅ DS-AM-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-AVM-A · ✅ AE-AVN-A · ✅ AE-ELVM-A · ✅ SCHED-AVM-A · ✅ T-AVM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A · ✅ DM-DDA-A · ✅ NM-CC-A · ✅ AR-AVM-A | +| **B-XAWS** Extended Advanced Operator Workstation | planned | ✅ union of B-AWS + B-AACWS + B-ALWS + B-AEWS | +| **B-LSAP** Life Safety Annunciator Panel | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-LSV-A · ✅ AE-N-A · ✅ AE-LS-A · ✅ AE-ACK-A · ✅ AE-LSVN-A | +| **B-LSWS** Life Safety Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-LSV-A · ✅ DS-LSM-A · ✅ AE-N-A · ✅ AE-LS-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-LSVM-A · ✅ AE-LSAVN-A · ✅ AE-ELV-A · ✅ SCHED-VM-A · ✅ T-V-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A | +| **B-ALSWS** Advanced Life Safety Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-LSAV-A · ✅ DS-LSAM-A · ✅ AE-N-A · ✅ AE-LS-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-LSAVM-A · ✅ AE-LSAVN-A · ✅ AE-ELVM-A · ✅ SCHED-AVM-A · ✅ T-AVM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A · ✅ AR-AVM-A | +| **B-ACSD** Access Control Security Display | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-ACV-A · ✅ DS-ACM-A · ✅ AE-N-A · ✅ AE-AC-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-ACAVN-A · ✅ AE-ELV-A · ✅ SCHED-VM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-MTS-A | +| **B-ACWS** Access Control Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-ACAV-A · ✅ DS-ACM-A · ✅ DS-ACUC-A · ✅ AE-N-A · ✅ AE-AC-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-ACVM-A · ✅ AE-ACAVN-A · ✅ AE-ELV-A · ✅ SCHED-VM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A | +| **B-AACWS** Advanced Access Control Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-ACAV-A · ✅ DS-ACAM-A · ✅ DS-ACUC-A · ✅ DS-ACSC-A · ✅ AE-N-A · ✅ AE-AC-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-ACAVM-A · ✅ AE-ACAVN-A · ✅ AE-ELVM-A · ✅ SCHED-AVM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A · ✅ AR-AVM-A | +| **B-LOD** Lighting Operator Display | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-LV-A · ✅ DS-WG-A · ✅ DS-ALO-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-ALWS** Advanced Lighting Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-LAV-A · ✅ DS-LAM-A · ✅ DS-WG-A · ✅ DS-ALO-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-AVM-A · ✅ AE-AVN-A · ✅ AE-ELVM-A · ✅ SCHED-AVM-A · ✅ T-AVM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A | +| **B-LCS** Lighting Control Station | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-LO-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B | +| **B-ALCS** Advanced Lighting Control Station | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-WG-A · ✅ DS-ALO-A · ✅ SCHED-E-B · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B · ✅ DM-DCC-B · ✅ DM-TS-B / DM-UTC-B | +| **B-ED** Elevator Display | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-WP-A · ✅ DS-EV-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-EVN-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-DOB-B | +| **B-EWS** Elevator Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-COVM-A · ✅ DS-EV-A · ✅ DS-EM-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-EVM-A · ✅ AE-EAVN-A · ✅ SCHED-VM-A · ✅ T-V-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A | +| **B-AEWS** Advanced Elevator Workstation | planned | ✅ DS-RP-A · ✅ DS-RP-B · ✅ DS-RPM-A · ✅ DS-WP-A · ✅ DS-WPM-A · ✅ DS-COVM-A · ✅ DS-EAV-A · ✅ DS-EAM-A · ✅ AE-N-A · ✅ AE-ACK-A · ✅ AE-AS-A · ✅ AE-EAVM-A · ✅ AE-EAVN-A · ✅ AE-ELVM-A · ✅ SCHED-AVM-A · ✅ T-AVM-A · ✅ DM-DDB-A · ✅ DM-DDB-B · ✅ DM-ANM-A · ✅ DM-ADM-A · ✅ DM-DOB-B · ✅ DM-DCC-A · ✅ DM-MTS-A · ✅ DM-OCD-A · ✅ DM-RD-A · ✅ DM-BR-A | + +Profile definitions: ANSI/ASHRAE 135-2024 Annex L. BIBB definitions: Annex K. Get the stack: . + + +## Objects and properties + + +Every object this example creates, and every REQUIRED property of each (per ANSI/ASHRAE 135-2024 clause 12 and the stack's `docs/property-profile-reference.md`), plus the optional properties the example turns on. **Served by** says who answers a ReadProperty: the **stack** generates it, or the **app** serves it from a `GetProperty*` callback in `main.cpp`. A ⚠ row is a required property the app does not serve and the stack would fill with a default - that is a defect, not a feature. + +### Device 389018 "Rainbow" - vendor 389 (Chipkin Automation Systems); instance configurable with --deviceID. The properties in 'accepted' are not served from a GetProperty callback because the stack itself is the source of truth for them - Protocol_Revision/Protocol_Version are stack build constants, Protocol_Services_Supported/Protocol_Object_Types_Supported are computed from the BACnetStack_SetServiceEnabled/AddObject calls this example already makes, Object_List and Device_Address_Binding are live stack-maintained tables, System_Status/Database_Revision/Max_APDU_Length_Accepted/Segmentation_Supported/APDU_Timeout/Number_Of_APDU_Retries are the stack's own configuration defaults for a device this example does not override + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| System_Status | BACnetDeviceStatus | stack default, accepted (Generic Enumerated default: `0`) | no | +| Vendor_Name | CharacterString | app | no | +| Vendor_Identifier | Unsigned16 | app | no | +| Model_Name | CharacterString | app | no | +| Firmware_Revision | CharacterString | app | no | +| Application_Software_Version | CharacterString | app | no | +| Description *(optional, enabled)* | CharacterString | app | no | +| Protocol_Version | Unsigned | stack default, accepted (`BACNET_PROTOCOL_VERSION`) | no | +| Protocol_Revision | Unsigned | stack default, accepted (`BACNET_PROTOCOL_REVISION`) | no | +| Protocol_Services_Supported | BACnetServicesSupported | stack default, accepted (computed from which services are enabled) | no | +| Protocol_Object_Types_Supported | BACnetObjectTypesSupported | stack default, accepted (computed from which object types are supported) | no | +| Object_List | BACnetARRAY[N] of BACnetObjectIdentifier | stack default, accepted (None known - a read fails with `unknown-property` or an empt) | no | +| Max_APDU_Length_Accepted | Unsigned | stack default, accepted (`CAS_BACNET_DEVICE_DEFAULT_MAX_APDU_LENGTH_ACCEPTED`) | no | +| Segmentation_Supported | BACnetSegmentation | stack default, accepted (`BACnetSegmentation::noSegmentation`) | no | +| APDU_Timeout | Unsigned | stack default, accepted (`CAS_BACNET_DEVICE_DEFAULT_APDU_TIMEOUT`) | no | +| Number_Of_APDU_Retries | Unsigned | stack default, accepted (`CAS_BACNET_DEVICE_DEFAULT_NUMBER_OF_APDU_RETRIES`) | no | +| Device_Address_Binding | BACnetLIST of BACnetAddressBinding | stack default, accepted (the live Device_Address_Binding (DAB) table) | no | +| Database_Revision | Unsigned | stack default, accepted (Generic UnsignedInteger default: `0`) | no | + +### Analog Input 1 "Bronze" - REAL, degrees Celsius; starts at 21.5; read-only + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | Real | app | no | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Units | BACnetEngineeringUnits | app | no | + +### Binary Input 1 "Emerald" - starts inactive; read-only + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | BACnetBinaryPV | app | no | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Polarity | BACnetPolarity | app | no | + +### Multi-state Input 1 "Hot Pink" - state 1 of 3: On, Off, Auto; read-only + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | Unsigned | app | no | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Number_Of_States | Unsigned | app | no | +| State_Text *(optional, enabled)* | BACnetARRAY[N] of CharacterString | app | no | + +### Analog Output 1 "Chartreuse" - REAL setpoint, default 20.0 C; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyReal/GetPropertyBool + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | Real | stack | yes | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Units | BACnetEngineeringUnits | app | no | +| Priority_Array | BACnetARRAY[16] of BACnetOptionalReal | stack | no | +| Relinquish_Default | Real | app | no | +| Current_Command_Priority | BACnetOptionalUnsigned | stack | no | + +### Binary Output 1 "Fuchsia" - active/inactive, default inactive; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyEnumerated/GetPropertyBool + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | BACnetBinaryPV | stack | yes | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Polarity | BACnetPolarity | app | no | +| Priority_Array | BACnetARRAY[16] of BACnetOptionalBinaryPV | stack | no | +| Relinquish_Default | BACnetBinaryPV | app | no | +| Current_Command_Priority | BACnetOptionalUnsigned | stack | no | + +### Multi-state Output 1 "Indigo" - state 1 of 3, default state 1; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyUnsignedInteger/GetPropertyBool + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Present_Value | Unsigned | stack | yes | +| Status_Flags | BACnetStatusFlags | stack | no | +| Event_State | BACnetEventState | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Number_Of_States | Unsigned | app | no | +| Priority_Array | BACnetARRAY[16] of BACnetOptionalUnsigned | stack | no | +| Relinquish_Default | Unsigned | app | no | +| Current_Command_Priority | BACnetOptionalUnsigned | stack | no | + +### Network Port 1 "Vermilion" - BACnet/IP, network 1, configured (F-MULTIPORT/F-ROUTER port A). Network_Type/Protocol_Level are set from BACnetStack_AddNetworkPortObject()'s arguments at start-up (as is Network_Number = 1 / Network_Number_Quality = configured, verified readable on the wire; not listed as a separate row here because the stack's own property-profile-reference.md does not carry those two properties for the type-agnostic 'Network Port' heading this generator reads). Changes_Pending is likewise computed and answered natively. Reliability has no fault condition this example detects, so it is accepted at the generic default (normal). Routing_Table is deliberately NOT enabled - verified on the wire that the pinned stack cannot serve it outside a test-tool build (TODO.md, stack issue #2038). + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Status_Flags | BACnetStatusFlags | stack | no | +| Reliability | BACnetReliability | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Network_Type | BACnetNetworkType | app | no | +| Protocol_Level | BACnetProtocolLevel | app | no | +| Changes_Pending | Boolean | app | no | + +### Network Port 2 "Vermilion 2" - BACnet/IP, network 2, configured (F-MULTIPORT/F-ROUTER port B). Same notes as Network Port 1 (Vermilion) apply, with Network_Number = 2. Physically routes to network 1 via BACnetStack_AddRouterPort/SetRouterEnabled, but see TODO.md and stack issue #2037: this stack pin cannot forward NPDUs between two ports of the same network type, so cross-network traffic is not verified end-to-end even though both ports individually answer ReadProperty/WriteProperty correctly. + +| Property | Datatype | Served by | Writable | +|---|---|---|:---:| +| Object_Identifier | BACnetObjectIdentifier | stack | no | +| Object_Name | CharacterString | app | no | +| Object_Type | BACnetObjectType | stack | no | +| Status_Flags | BACnetStatusFlags | stack | no | +| Reliability | BACnetReliability | stack default, accepted (Generic Enumerated default: `0`) | no | +| Out_Of_Service | Boolean | app | no | +| Network_Type | BACnetNetworkType | app | no | +| Protocol_Level | BACnetProtocolLevel | app | no | +| Changes_Pending | Boolean | app | no | + + + +## Footprint + +| Metric | Value | +|---|---| +| Binary size | not yet released | +| SHA-256 (prefix) | not yet released | +| Start-up time to `ready` | not yet released | +| Stack commit | not yet released | +| Link mode | not yet released | +| Compiler | not yet released | + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5bb8b25 --- /dev/null +++ b/TODO.md @@ -0,0 +1,86 @@ +# TODO / known gaps + +## DM-LM-B (AddListElement / RemoveListElement, services 8 / 9) — NOT implemented + +The B-RTR profile card calls for services 8 (AddListElement) and 9 (RemoveListElement) +against a list-valued property. The CAS BACnet Stack genuinely implements both services +end-to-end (`source/BACnetAddListElementProcessor.h`, `BACnetRemoveListElementProcessor.h`, +`BACnetBusinessLogic::AddListElement`/`RemoveListElement`) and natively serves them against +a Group object's `List_Of_Group_Members` (`BACnetStack_AddGroupObject`/`AddGroupMember`/ +`RemoveGroupMember`, `source/CASBACnetStackDLL.h`) — **but only when the stack is compiled +with `STACK_OPTION_DM_LM_LIST_MANIPULATION` defined**. + +Verified against the pinned stack commit `abd4cee1c7f28ca8e1af4720849c4081082bbe82` +(`source/CASBACnetStackOptions.h`, `source/CIBuildSettings.h`, +`projects/msvs/CASBACnetStack/CASBACnetStack.vcxproj`): + +- `STACK_OPTION_DM_LM_LIST_MANIPULATION` is commented out by default and is auto-defined + only when `BACNET_STACK_TESTTOOL` is set (forbidden for this series — customer-facing + interface only). +- It is **not** included in the `STACK_OPTION_ENABLE_ALL_BIBBS` group that + `STACK_OPTION_TARGET_FULL` pulls in, so `CIBuildSettings.h` at this pin does not enable it. +- The `ReleaseLib|x64` MSVC configuration (the one `tools/build-stack-static.sh` builds, and + the only STATIC configuration this series ships) does not define it either — only + `DebugTestTool|x64` gets `BACNET_STACK_TESTTOOL`. + +So the customer-facing STATIC library this series links — built the one way every example +in the series is required to build it (`runbook-update-examples.md` §1: one pin, one build +process) — does not compile in AddListElement/RemoveListElement support. This example +cannot fake that support, so it does not claim services 8/9 or DM-LM-B, and does not add a +Group object. + +Filed: , asking either for +`STACK_OPTION_DM_LM_LIST_MANIPULATION` to join `STACK_OPTION_ENABLE_ALL_BIBBS` (so a +`STACK_OPTION_TARGET_FULL` customer build gets it, matching that preset's own "every object +type and BIBB" documentation), or for a supported way to opt a customer-facing STATIC build +in without hand-editing the vendored `CIBuildSettings.h` per example (which would break the +series' "one pin, one build" rule). + +If a future series-wide stack bump compiles this in, re-verify with +`grep -c "STACK_OPTION_DM_LM_LIST_MANIPULATION" submodules/cas-bacnet-stack/source/CIBuildSettings.h` +and, if present, add a Group object and the two services here. + +## F-ROUTER: inter-network NPDU forwarding between the two BACnet/IP ports does NOT work + +`BACnetStack_AddRouterPort`, `BACnetStack_AddRouterRoute`, and `BACnetStack_SetRouterEnabled` +all succeed (return `true`) for both Network Port 1 (Vermilion) and Network Port 2 +(Vermilion 2), and the `Routing_Table` property on both ports reads back the configured +ports/routes correctly. `BACnetStack_SendIAmRouterToNetwork` / `SendWhoIsRouterToNetwork` / +`SendNetworkNumberIs` all send successfully on both ports too. + +**But the stack does not actually forward NPDUs between the two ports**, because both are +`BACnetPacket::NetworkType_IP` and the pinned stack holds exactly ONE internal BACnet/IP +datalink instance regardless of how many router ports of that type exist +(`source/BACnetDataLinkLayer.cpp`, `BindRouterPort`/`FindPortByNetworkType`, hardcoded +`this->m_dataLinkIpv4[0]`). Observed at start-up and on every inbound packet: + +``` +BindRouterPort() - Error: Router port=[2] is the second port of networkType=[0]; the datalink is +bound to port=[1] and only one instance exists, so ingress attribution is disabled for both. +Inbound routing between them needs the per-type datalink instancing of issue #304. + +FindPortByNetworkType() - Error: Cannot attribute an incoming NPDU: [2] routed ports share +networkType=[0]. An explicit ingress port id is needed to route between two ports of the same type. +``` + +Issue #304 ("Routing: ingress port attribution + more than one datalink per network type") was +closed, but scoped to and implemented for BACnet/SC only (one SC datalink per Network Port +object's `Network_Number`, per its own closing comment); `NetworkType_IP` and `NetworkType_MSTP` +were left exactly where they started — single hardcoded instance, ingress attribution refused. + +**Consequence for this example:** a client whose ReadProperty targets a device reachable only +through the OTHER Network Port is NOT correctly routed — the request is not forwarded. This is +the runbook's own wire-verification checklist item for B-RTR ("ReadProperty sent to a B-SS +instance reachable only via net B is correctly routed") and it cannot be demonstrated as working +at this pin. Do not claim end-to-end routing/forwarding for this example; it demonstrates router +CONFIGURATION and ANNOUNCEMENT (NM-RC-B's Who-Is-Router-To-Network/I-Am-Router-To-Network/ +Network-Number-Is, plus `Routing_Table`) genuinely, and does not claim inter-network packet +forwarding. + +Filed: , asking for the same +per-Network-Port-object multi-instance approach #304 shipped for BACnet/SC to be extended to +BACnet/IP and MS/TP. + +Re-verify once fixed: run two B-SS-CPP instances, one per network, with this router between +them, and confirm a ReadProperty to the far instance succeeds (README "Verification" documents +the exact non-working steps as of this release). diff --git a/common/CASBACnetStackExampleConstants.h b/common/CASBACnetStackExampleConstants.h new file mode 100644 index 0000000..cc22c9c --- /dev/null +++ b/common/CASBACnetStackExampleConstants.h @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see ../LICENSE. +#ifndef CAS_BACNET_STACK_EXAMPLE_CONSTANTS_H +#define CAS_BACNET_STACK_EXAMPLE_CONSTANTS_H + +// CASBACnetStackExampleConstants.h +// ============================================================================= +// A small, self-contained set of the BACnet enumeration values that the example +// projects need. The CAS BACnet Stack defines the FULL enumerations internally, +// in the stack header noted above each section below. We mirror only the handful +// of values used here so each example is easy to read and copy into a customer +// project - open the referenced stack header to see every available value. +// +// Every value matches the BACnet standard (ANSI/ASHRAE 135) and the CAS BACnet +// Stack enumerations. Add more as your own project needs them. +// ============================================================================= + +#include + +namespace CASBACnetStackExampleConstants { + +// -- BACnet object types (Object_Type enumeration) ------------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetObjectType.h +static const uint16_t OBJECT_TYPE_ANALOG_INPUT = 0; +static const uint16_t OBJECT_TYPE_ANALOG_OUTPUT = 1; +static const uint16_t OBJECT_TYPE_ANALOG_VALUE = 2; +static const uint16_t OBJECT_TYPE_BINARY_INPUT = 3; +static const uint16_t OBJECT_TYPE_BINARY_OUTPUT = 4; +static const uint16_t OBJECT_TYPE_DEVICE = 8; +static const uint16_t OBJECT_TYPE_MULTI_STATE_INPUT = 13; +static const uint16_t OBJECT_TYPE_MULTI_STATE_OUTPUT = 14; +static const uint16_t OBJECT_TYPE_NOTIFICATION_CLASS = 15; +static const uint16_t OBJECT_TYPE_NETWORK_PORT = 56; + +// -- BACnet property identifiers (Property_Identifier enumeration) ---------- +// Full list: submodules/cas-bacnet-stack/source/BACnetPropertyIdentifier.h +static const uint32_t PROPERTY_IDENTIFIER_APPLICATION_SOFTWARE_VERSION = 12; +static const uint32_t PROPERTY_IDENTIFIER_APDU_LENGTH = 399; +static const uint32_t PROPERTY_IDENTIFIER_DESCRIPTION = 28; +static const uint32_t PROPERTY_IDENTIFIER_FIRMWARE_REVISION = 44; +static const uint32_t PROPERTY_IDENTIFIER_LOCATION = 58; +static const uint32_t PROPERTY_IDENTIFIER_MODEL_NAME = 70; +static const uint32_t PROPERTY_IDENTIFIER_IP_ADDRESS = 400; +static const uint32_t PROPERTY_IDENTIFIER_IP_DEFAULT_GATEWAY = 401; +static const uint32_t PROPERTY_IDENTIFIER_BACNET_IP_MODE = 408; +static const uint32_t PROPERTY_IDENTIFIER_IP_SUBNET_MASK = 411; +static const uint32_t PROPERTY_IDENTIFIER_BACNET_IP_UDP_PORT = 412; +static const uint32_t PROPERTY_IDENTIFIER_MAC_ADDRESS = 423; +static const uint32_t PROPERTY_IDENTIFIER_NUMBER_OF_STATES = 74; +static const uint32_t PROPERTY_IDENTIFIER_OBJECT_IDENTIFIER = 75; +static const uint32_t PROPERTY_IDENTIFIER_OBJECT_NAME = 77; +static const uint32_t PROPERTY_IDENTIFIER_OBJECT_TYPE = 79; +static const uint32_t PROPERTY_IDENTIFIER_OUT_OF_SERVICE = 81; +static const uint32_t PROPERTY_IDENTIFIER_POLARITY = 84; +static const uint32_t PROPERTY_IDENTIFIER_PRESENT_VALUE = 85; +static const uint32_t PROPERTY_IDENTIFIER_PRIORITY_ARRAY = 87; +static const uint32_t PROPERTY_IDENTIFIER_RELINQUISH_DEFAULT = 104; +static const uint32_t PROPERTY_IDENTIFIER_CURRENT_COMMAND_PRIORITY = 431; +static const uint32_t PROPERTY_IDENTIFIER_REFERENCE_PORT = 483; +static const uint32_t PROPERTY_IDENTIFIER_ROUTING_TABLE = 428; // Network Port; optional, SetPropertyEnabled first +static const uint32_t PROPERTY_IDENTIFIER_STATE_TEXT = 110; +static const uint32_t PROPERTY_IDENTIFIER_STATUS_FLAGS = 111; +static const uint32_t PROPERTY_IDENTIFIER_SYSTEM_STATUS = 112; +static const uint32_t PROPERTY_IDENTIFIER_UNITS = 117; +static const uint32_t PROPERTY_IDENTIFIER_VENDOR_IDENTIFIER = 120; +static const uint32_t PROPERTY_IDENTIFIER_VENDOR_NAME = 121; + +// -- BACnet engineering units (Engineering_Units enumeration) --------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetEngineeringUnits.h +static const uint32_t ENGINEERING_UNITS_DEGREES_CELSIUS = 62; +static const uint32_t ENGINEERING_UNITS_PERCENT = 98; + +// -- BACnet polarity (Polarity enumeration, for Binary objects) ------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetPolarity.h +static const uint32_t POLARITY_NORMAL = 0; + +// -- BACnet/IP mode (BACnetIPMode enumeration, for the Network Port) --------- +// Full list: submodules/cas-bacnet-stack/source/BACnetIPMode.h +static const uint32_t BACNET_IP_MODE_NORMAL = 0; + +// -- BACnet services (Services_Supported enumeration) ----------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetServicesSupported.h +// Used with BACnetStack_SetServiceEnabled() to turn individual services on/off. +// Enable ONLY the services your profile requires - omitting the rest is the +// whole point of a profile example. +static const uint32_t SERVICE_ACKNOWLEDGE_ALARM = 0; +static const uint32_t SERVICE_CONFIRMED_EVENT_NOTIFICATION = 2; +static const uint32_t SERVICE_READ_PROPERTY = 12; +static const uint32_t SERVICE_READ_PROPERTY_MULTIPLE = 14; +static const uint32_t SERVICE_WRITE_PROPERTY = 15; +static const uint32_t SERVICE_WRITE_PROPERTY_MULTIPLE = 16; +static const uint32_t SERVICE_DEVICE_COMMUNICATION_CONTROL = 17; +static const uint32_t SERVICE_REINITIALIZE_DEVICE = 20; +static const uint32_t SERVICE_I_AM = 26; +static const uint32_t SERVICE_I_HAVE = 27; +static const uint32_t SERVICE_UNCONFIRMED_EVENT_NOTIFICATION = 29; +static const uint32_t SERVICE_TIME_SYNCHRONIZATION = 32; +static const uint32_t SERVICE_WHO_HAS = 33; +static const uint32_t SERVICE_WHO_IS = 34; +static const uint32_t SERVICE_UTC_TIME_SYNCHRONIZATION = 36; +static const uint32_t SERVICE_GET_EVENT_INFORMATION = 39; + +// -- Confirmed-service choice, for the A-side Send* calls ------------------- +// Same numbers as the Services_Supported values above; kept under the names +// the client-side BACnetStack_Send* helpers use so a client example reads +// naturally. (READ_PROPERTY_SERVICE_TYPE == SERVICE_READ_PROPERTY == 12.) +static const uint8_t READ_PROPERTY_SERVICE_TYPE = 12; // ReadProperty +static const uint8_t READ_PROPERTY_MULTIPLE_SERVICE_TYPE = 14; // ReadPropertyMultiple +static const uint8_t WRITE_PROPERTY_SERVICE_TYPE = 15; // WriteProperty + +// -- BACnet application datatype tags --------------------------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetStackDatatypes.h +static const uint8_t BACNET_DATATYPE_REAL = 4; + +// -- DeviceCommunicationControl enable/disable (DM-DCC-B) ------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetEnableDisable.h +// NOTE: at Protocol_Revision >= 20 the plain `disable` (1) is DEPRECATED - +// the stack answers it service-request-denied. Only `enable` (0) and +// `disable-initiation` (2) actually take effect. +static const uint8_t DCC_ENABLE = 0; // resume all communication +static const uint8_t DCC_DISABLE = 1; // stop initiating AND responding +static const uint8_t DCC_DISABLE_INITIATION = 2; // keep responding, stop initiating + +// -- ReinitializeDevice states (DM-RD-B) ------------------------------------ +// Full list: submodules/cas-bacnet-stack/source/BACnetReinitializedStateOfDevice.h +static const uint32_t REINITIALIZE_STATE_COLDSTART = 0; +static const uint32_t REINITIALIZE_STATE_WARMSTART = 1; + +// -- Notification type (for alarm/event objects) ---------------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetNotifyType.h +static const uint8_t NOTIFY_TYPE_ALARM = 0; +static const uint8_t NOTIFY_TYPE_EVENT = 1; + +// -- BACnet error codes (Error_Code enumeration) ---------------------------- +// Full list: submodules/cas-bacnet-stack/source/BACnetErrorCode.h +// A SetProperty* callback writes one of these to its errorCode out-parameter +// and returns false to reject a write with that BACnet Error-PDU. Since the +// per-property error work (CAS BACnet Stack issue #974) the GetProperty* +// callbacks have the same out-parameter and the same contract - see the +// commentary above the Get callbacks in main.cpp for when to use it. +static const uint32_t ERROR_CODE_READ_ACCESS_DENIED = 27; +static const uint32_t ERROR_CODE_PASSWORD_FAILURE = 26; +static const uint32_t ERROR_CODE_UNKNOWN_PROPERTY = 32; +static const uint32_t ERROR_CODE_VALUE_OUT_OF_RANGE = 37; +static const uint32_t ERROR_CODE_INVALID_ARRAY_INDEX = 42; +static const uint32_t ERROR_CODE_OPTIONAL_FUNCTIONALITY_NOT_SUPPORTED = 45; +// success (84) is what errorCode is PRESET to before a Get/Set callback runs. +// Leaving it at success on a false return means "I decline, but I am not +// naming an error" - see main.cpp. +static const uint32_t ERROR_CODE_SUCCESS = 84; + +// -- Transport network type (BACnetPacket::NetworkType, for the send/receive +// callbacks and SendIAm). +// Full list: submodules/cas-bacnet-stack/source/BACnetPacket.h +// NOTE: this is a DIFFERENT enumeration from the Network Port object's +// network type below (see the GOTCHA in CASBACnetStackDLL.h). +static const uint8_t NETWORK_TYPE_IP = 0; + +// -- Network Port object network type (BACnetNetworkType enumeration, used by +// BACnetStack_AddNetworkPortObject). +// Full list: submodules/cas-bacnet-stack/source/BACnetNetworkType.h +static const uint8_t NETWORK_PORT_NETWORK_TYPE_IPV4 = 5; + +// -- Network Port object protocol level (BACnetProtocolLevel enumeration, used +// by BACnetStack_AddNetworkPortObjectWithNetworkNumber). +// Full list: submodules/cas-bacnet-stack/source/BACnetProtocolLevel.h +static const uint8_t NETWORK_PORT_PROTOCOL_LEVEL_BACNET_APPLICATION = 2; +// The lowest protocol layer references this sentinel instead of another port. +static const uint32_t NETWORK_PORT_REFERENCE_PORT_NONE = 4194303; + +// -- Network_Number_Quality (BACnetNetworkNumberQuality, cl. 12.56.11). Says how +// the port learned its Network_Number. A port that has not been told and has +// not learned one reports "unknown" with Network_Number = 0. +static const uint8_t NETWORK_NUMBER_QUALITY_UNKNOWN = 0; +static const uint8_t NETWORK_NUMBER_QUALITY_LEARNED = 1; +static const uint8_t NETWORK_NUMBER_QUALITY_LEARNED_CONFIGURED = 2; +static const uint8_t NETWORK_NUMBER_QUALITY_CONFIGURED = 3; + +// -- Character string encoding (the encoding byte returned by the character- +// string Get callback). 0 = UTF-8. The BACnet character-set values are +// defined by ANSI/ASHRAE 135 Clause 20.2.9; see the stack's handling in +// submodules/cas-bacnet-stack/source/BACnetPrimitiveCharSTRING.h +static const uint8_t CHARACTER_STRING_ENCODING_UTF8 = 0; + +} // namespace CASBACnetStackExampleConstants + +#endif // CAS_BACNET_STACK_EXAMPLE_CONSTANTS_H diff --git a/common/CASExampleHelper.cpp b/common/CASExampleHelper.cpp new file mode 100644 index 0000000..f0baad3 --- /dev/null +++ b/common/CASExampleHelper.cpp @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see ../LICENSE. +// CASExampleHelper.cpp +// ============================================================================= +// Implementation of the shared example boilerplate. See CASExampleHelper.h. +// ============================================================================= + +#include "CASExampleHelper.h" +#include "SimpleUDP.h" +#include "CASBACnetStackExampleConstants.h" + +// The CAS BACnet Stack C API, via the adapter: BACnetStack_* is called directly here, +// the same call in every link mode (source/static/DLL) - see CASBACnetStackAdapter.h. +// The caller's main() must have already called LoadBACnetFunctions() successfully. +#include "CASBACnetStackAdapter.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#include +#include +// ws2_32 + iphlpapi are linked by CMakeLists.txt. If you copy this file into a +// non-CMake MSVC project, add: +// #pragma comment(lib, "ws2_32.lib") +// #pragma comment(lib, "iphlpapi.lib") +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace { + +// --------------------------------------------------------------------------- +// Multi-port UDP support (common/ 2.3.0). +// +// The stack calls the transport callbacks as plain C function pointers (no +// user-data argument), so the socket(s) live here as file-local state rather +// than being passed in. Originally that state was exactly one SimpleUDP - one +// example, one BACnet/IP link. A routing example (B-RTR, the first canonical +// user of this) owns TWO Network Port objects, each with its own UDP socket +// and port number, and the stack must be told - on every receive and every +// send - WHICH Network Port instance the datagram belongs to. This small +// table generalizes "the one socket" to "up to MAX_UDP_BINDINGS sockets, each +// keyed by the Network Port instance it belongs to". +// +// WHY THIS IS SAFE FOR SINGLE-PORT EXAMPLES. +// Every example that only ever calls the single-argument SetupUDP(port) gets +// exactly one entry in g_udpBindings, keyed to whatever SetNetworkPortInstance +// set (instance 1 if it was never called - unchanged default). With one +// entry, HelperReceiveMessage's round-robin loop below always starts at and +// checks that one entry (nothing to rotate to), and HelperSendMessage's +// lookup always finds that one entry for the one networkPortInstance the +// stack will ever name. The code path is byte-for-byte the same work the +// old single-g_udp implementation did; only the storage changed from "a bare +// SimpleUDP" to "a SimpleUDP inside a one-element table". No single-port +// example's on-the-wire behaviour changes. +struct UdpBinding { + SimpleUDP udp; + uint16_t port = 0; + uint32_t networkPortInstance = 0; + bool bound = false; +}; + +// Generous headroom over B-RTR's two ports; a small fixed table avoids a heap +// allocation in example code that otherwise has none. +const size_t MAX_UDP_BINDINGS = 4; +UdpBinding g_udpBindings[MAX_UDP_BINDINGS]; +size_t g_udpBindingCount = 0; + +UdpBinding* FindUdpBindingByInstance(uint32_t networkPortInstance) { + for (size_t i = 0; i < g_udpBindingCount; ++i) { + if (g_udpBindings[i].networkPortInstance == networkPortInstance) { + return &g_udpBindings[i]; + } + } + return NULL; +} + +// The BACnet/IP port of the CURRENT Network Port instance (used to address +// broadcast I-Am for the single-argument SendIAm()). +uint16_t g_port = 47808; + +// The instance of the Network Port object that the single-argument +// SetupUDP(port) / SendIAm(deviceInstance) overloads act on. The stack +// identifies a link by its Network Port object INSTANCE, not by a transport +// network type, so the transport callbacks and SendIAm all have to name it. +// The example's main() tells us which one is "current" via +// SetNetworkPortInstance(); the default matches the series convention of a +// single Network Port at instance 1. +uint32_t g_networkPortInstance = 1; + +// --------------------------------------------------------------------------- +// Transport callback: the stack calls this to RECEIVE a BACnet message. +// +// We pull one datagram off the UDP socket. If one arrived, we copy it into the +// stack's message buffer and report who it came from via the "connection +// string" - for BACnet/IP that is 6 bytes: 4 IP octets + 2 port bytes +// (big-endian). Returning 0 means "nothing to process this tick". +// --------------------------------------------------------------------------- +uint16_t HelperReceiveMessage(uint8_t* message, const uint16_t maxMessageLength, + uint8_t* sourceConnectionString, + uint8_t* sourceConnectionStringLength, + uint8_t* destinationConnectionString, + uint8_t* destinationConnectionStringLength, + const uint8_t maxConnectionStringLength, + uint32_t* networkPortInstance) { + (void)destinationConnectionString; + (void)destinationConnectionStringLength; + if (maxConnectionStringLength < 6 || g_udpBindingCount == 0) { + return 0; + } + + // Poll the bound sockets in round-robin order, starting from the one after + // whichever was serviced (or checked) last call. For a single-port example + // (g_udpBindingCount == 1) this always checks binding 0, same as the old + // single-g_udp implementation. For a multi-port example it means a socket + // with continuous traffic cannot starve the others: each call to this + // function looks at a different starting socket, and the stack calls it + // repeatedly per tick until it returns 0. + static size_t s_nextBindingToCheck = 0; + const size_t n = g_udpBindingCount; + uint16_t bytesRead = 0; + uint8_t fromIp[4] = { 0, 0, 0, 0 }; + uint16_t fromPort = 0; + uint32_t servicedInstance = 0; + + for (size_t k = 0; k < n; ++k) { + const size_t i = (s_nextBindingToCheck + k) % n; + UdpBinding& binding = g_udpBindings[i]; + if (!binding.bound) { + continue; + } + // Receive() copies at most maxMessageLength bytes; a datagram larger than + // the stack's buffer is truncated to that length (fine for UDP - the + // stack will simply fail to decode and ignore an over-length frame). + bytesRead = binding.udp.Receive(message, maxMessageLength, fromIp, &fromPort); + if (bytesRead > 0) { + servicedInstance = binding.networkPortInstance; + s_nextBindingToCheck = (i + 1) % n; + break; + } + } + if (bytesRead == 0) { + s_nextBindingToCheck = (s_nextBindingToCheck + 1) % n; // keep rotating even when idle + return 0; // no datagram waiting on any bound port + } + + printf("RX %u bytes from %u.%u.%u.%u:%u (Network Port %u)\n", (unsigned)bytesRead, + fromIp[0], fromIp[1], fromIp[2], fromIp[3], (unsigned)fromPort, + (unsigned)servicedInstance); + + // Fill the 6-byte BACnet/IP connection string: IP[4] + port[2] (big-endian). + sourceConnectionString[0] = fromIp[0]; + sourceConnectionString[1] = fromIp[1]; + sourceConnectionString[2] = fromIp[2]; + sourceConnectionString[3] = fromIp[3]; + sourceConnectionString[4] = (uint8_t)((fromPort >> 8) & 0xFF); + sourceConnectionString[5] = (uint8_t)(fromPort & 0xFF); + *sourceConnectionStringLength = 6; + + // Tell the stack WHICH Network Port object this datagram arrived on. The + // stack used to ask only for the transport's network TYPE here; since the + // per-port work (CAS BACnet Stack issue #822/#556) it wants the instance of + // the Network Port object that owns this link, so a multi-port device can + // answer on the port a request came in on. A single-port example reports + // its one bound instance every time, same as before common/ 2.3.0; a + // multi-port example reports whichever bound socket the datagram actually + // arrived on. + *networkPortInstance = servicedInstance; + return bytesRead; +} + +// --------------------------------------------------------------------------- +// Transport callback: the stack calls this to SEND a BACnet message. +// +// The stack hands us the destination as a connection string (IP[4] + port[2]). +// For broadcasts the stack fills it with the broadcast address, so we can send +// the same way in both cases (the socket has SO_BROADCAST enabled). +// --------------------------------------------------------------------------- +uint16_t HelperSendMessage(const uint8_t* message, const uint16_t messageLength, + const uint8_t* connectionString, + const uint8_t connectionStringLength, + const uint32_t networkPortInstance, const bool broadcast) { + // The stack names the Network Port object the message is to leave by (it + // used to name the transport's network type). Look up the socket bound to + // that instance; a single-port example has exactly one, so this always + // resolves to it - same effect as the old "!= g_networkPortInstance" check, + // just expressed as a table lookup instead of a single comparison. + UdpBinding* binding = FindUdpBindingByInstance(networkPortInstance); + if (binding == NULL || !binding->bound || connectionStringLength < 6) { + return 0; + } + + const uint8_t ip[4] = { connectionString[0], connectionString[1], + connectionString[2], connectionString[3] }; + const uint16_t port = (uint16_t)((connectionString[4] << 8) | connectionString[5]); + + const uint16_t sent = binding->udp.Send(ip, port, message, messageLength); + printf("TX %u bytes to %u.%u.%u.%u:%u%s (Network Port %u)\n", (unsigned)sent, + ip[0], ip[1], ip[2], ip[3], (unsigned)port, + broadcast ? " (broadcast)" : "", (unsigned)networkPortInstance); + return sent; +} + +// --------------------------------------------------------------------------- +// Time callback: the stack asks the application for the current system time. +// --------------------------------------------------------------------------- +// CASBACnetTime is the stack's own time type (int64_t seconds since the UNIX +// epoch). It replaced time_t, whose width differs between platforms and build +// settings - a 32-bit time_t on one side of the ABI and a 64-bit one on the +// other silently corrupted every timestamp. +CASBACnetTime HelperGetSystemTime() { + return (CASBACnetTime)time(0); +} + +// --------------------------------------------------------------------------- +// Find the primary IPv4 interface's address and subnet mask (each 4 octets in +// network/big-endian order, i.e. ip[0] is the first dotted octet). These feed +// the Network Port object (IP_Address, IP_Subnet_Mask) and the local broadcast. +// Returns true and fills ip[4]/mask[4] on success. +// --------------------------------------------------------------------------- +bool GetPrimaryIPv4(uint8_t ip[4], uint8_t mask[4]) { +#if defined(_WIN32) + IP_ADAPTER_INFO adapters[32]; + ULONG len = sizeof(adapters); + if (GetAdaptersInfo(adapters, &len) != ERROR_SUCCESS) { + return false; + } + for (const IP_ADAPTER_INFO* a = adapters; a != NULL; a = a->Next) { + struct in_addr ipAddr; + struct in_addr maskAddr; + maskAddr.s_addr = 0; // default if the mask string fails to parse (below) + if (inet_pton(AF_INET, a->IpAddressList.IpAddress.String, &ipAddr) != 1) { + continue; // not a valid IPv4 address + } + // The subnet mask is best-effort: if it does not parse, leave it 0.0.0.0, + // which makes the local broadcast fall back to 255.255.255.255 (the limited + // broadcast) - the same behaviour as the POSIX path with no netmask. Always + // check the return; never read an uninitialised in_addr. + if (inet_pton(AF_INET, a->IpAddressList.IpMask.String, &maskAddr) != 1) { + maskAddr.s_addr = 0; + } + const uint32_t ipN = ipAddr.s_addr; // network byte order + const uint32_t maskN = maskAddr.s_addr; + if (ipN == 0) { + continue; // interface has no address (0.0.0.0) + } + if ((ipN & 0xFF) == 127) { + continue; // skip loopback (127.x.x.x) + } + memcpy(ip, &ipN, 4); + memcpy(mask, &maskN, 4); + return true; + } + return false; +#else + struct ifaddrs* ifap = NULL; + if (getifaddrs(&ifap) != 0) { + return false; + } + bool found = false; + for (const struct ifaddrs* ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == NULL || ifa->ifa_addr->sa_family != AF_INET) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP)) { + continue; + } + const uint32_t ipN = ((const struct sockaddr_in*)ifa->ifa_addr)->sin_addr.s_addr; + const uint32_t maskN = ifa->ifa_netmask + ? ((const struct sockaddr_in*)ifa->ifa_netmask)->sin_addr.s_addr : 0; + memcpy(ip, &ipN, 4); + memcpy(mask, &maskN, 4); + found = true; + break; + } + freeifaddrs(ifap); + return found; +#endif +} + +#if !defined(_WIN32) +// POSIX terminal raw-mode handling for non-blocking single-key reads. +struct termios g_origTermios; +bool g_rawActive = false; // true only if we put a REAL tty into raw mode +bool g_stdinConfigured = false; // true once we have tried, tty or not + +void EnableRawInput() { + if (g_stdinConfigured) { + return; + } + g_stdinConfigured = true; + + // Set O_NONBLOCK FIRST, and UNCONDITIONALLY - before the tty check. + // + // This ordering is load-bearing. PollKey() read()s stdin on every tick of the + // main loop. If stdin is NOT a tty (CI, `docker run -i` without -t, a pipe + // with no data yet) and O_NONBLOCK was never set, that read() BLOCKS FOREVER: + // BACnetStack_Tick() never runs again and the device goes deaf while still + // looking perfectly alive. Redirecting `< /dev/null` hides it, because EOF + // returns 0 immediately - which is why it survives most smoke tests. + const int flags = fcntl(STDIN_FILENO, F_GETFL, 0); + if (flags != -1) { + fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); + } + + // Raw mode only means anything for a real terminal. Not having one is fine - + // we simply do not get single-key input, and the non-blocking read above + // keeps the tick loop healthy either way. + if (tcgetattr(STDIN_FILENO, &g_origTermios) != 0) { + return; // not a tty (e.g. piped or no terminal) - leave termios alone + } + struct termios raw = g_origTermios; + raw.c_lflag &= ~(unsigned)(ICANON | ECHO); // no line buffering, no echo + raw.c_cc[VMIN] = 0; // non-blocking read + raw.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSANOW, &raw); + g_rawActive = true; // only now do we own the terminal state +} +#endif + +// --------------------------------------------------------------------------- +// Deferred-restart state (see the DM-RD-B block in CASExampleHelper.h). +// +// The clock here must be MONOTONIC, not wall-clock: a device that supports +// TimeSynchronization (DM-TS-B) can have its wall clock stepped - possibly +// backwards - by a management station at any moment, including during the +// restart delay. time() would then either fire the restart early or park it in +// the future indefinitely. A monotonic source cannot be stepped. +// --------------------------------------------------------------------------- +bool g_restartPending = false; +uint64_t g_restartDueAtMs = 0; +CASExampleHelper::RestartKind g_restartKind = CASExampleHelper::RestartKind::Warm; + +uint64_t MonotonicMilliseconds() { +#if defined(_WIN32) + // GetTickCount64 (not GetTickCount): the 32-bit version wraps to zero after + // ~49.7 days of uptime, which would make a pending deadline unreachable. + return (uint64_t)GetTickCount64(); +#else + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)(ts.tv_nsec / 1000000); +#endif +} + +} // namespace + +namespace CASExampleHelper { + +void RequestRestart(const RestartKind kind, const uint32_t delayMilliseconds) { + const uint64_t dueAt = MonotonicMilliseconds() + (uint64_t)delayMilliseconds; + + if (g_restartPending) { + // Never postpone a restart already promised to an earlier client: keep + // the earliest deadline, and let a Cold request upgrade a pending Warm. + if (dueAt < g_restartDueAtMs) { + g_restartDueAtMs = dueAt; + } + if (kind == RestartKind::Cold) { + g_restartKind = RestartKind::Cold; + } + return; + } + + g_restartPending = true; + g_restartDueAtMs = dueAt; + g_restartKind = kind; +} + +bool RestartDue(RestartKind* const outKind) { + if (!g_restartPending || MonotonicMilliseconds() < g_restartDueAtMs) { + return false; + } + // Clear BEFORE returning true so this fires exactly once. + g_restartPending = false; + if (outKind != NULL) { + *outKind = g_restartKind; + } + return true; +} + +void PrintVersion(const char* appName, const char* appVersion) { + printf("%s v%s\n", appName, appVersion); + printf("CAS BACnet Stack version: %u.%u.%u.%u\n", + BACnetStack_GetAPIMajorVersion(), BACnetStack_GetAPIMinorVersion(), + BACnetStack_GetAPIPatchVersion(), BACnetStack_GetAPIBuildVersion()); + // The vendored common/ helper has its own version (see common/CHANGELOG.md) + // so it is easy to tell whether this example's copy is stale. + printf("Common helper (common/) version: %s\n", COMMON_VERSION); +} + +// Just the interactive key list, with no version banner - so the two callers that already +// printed one (PrintHelp for the 'h' key, and the --help handler) do not print it twice. +static void PrintInteractiveCommands() { + printf("Commands:\n"); + printf(" h - show this help (version + commands)\n"); + printf(" q - quit\n"); + printf(" up - increase Analog Input 1 by 1.1\n"); + printf(" down - decrease Analog Input 1 by 1.1\n"); +} + +void PrintHelp(const char* appName, const char* appVersion) { + PrintVersion(appName, appVersion); + PrintInteractiveCommands(); +} + +bool HandleHelpAndVersionArgs(const int argc, char** argv, const char* appName, const char* appVersion) { + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0 || + strcmp(argv[i], "/?") == 0) { + PrintVersion(appName, appVersion); + printf("\n"); + printf("Usage: [options]\n"); + printf("\n"); + printf("Options:\n"); + printf(" --help, -h Show this help and exit.\n"); + printf(" --version Show version information and exit.\n"); + printf(" --deviceID BACnet device instance (0..4194302). Each example in\n"); + printf(" this series has its own default so several can run on\n"); + printf(" one subnet at once.\n"); + printf(" --port BACnet/IP UDP port (1..65535). Default 47808 (0xBAC0).\n"); + printf(" Use a non-default port to avoid clashing with another\n"); + printf(" BACnet device already on 47808 on this host.\n"); + printf("\n"); + PrintInteractiveCommands(); // version banner already printed above + return true; + } + if (strcmp(argv[i], "--version") == 0) { + PrintVersion(appName, appVersion); + return true; + } + } + return false; +} + +// Parse a whole-number argument value. Returns true only if the ENTIRE token is +// a valid non-negative integer. This exists because atoi()/atol() silently +// return 0 on garbage - so `--deviceID abc` would parse as device 0, a valid +// instance, and the operator would ship a device answering at the wrong address +// with no diagnostic. strtol + an end-pointer check is the difference between +// "rejected your typo" and "silently obeyed a different command than you gave". +static bool ParseWholeNumber(const char* text, long* out) { + if (text == NULL || *text == '\0') { + return false; + } + char* end = NULL; + errno = 0; + const long value = strtol(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || value < 0) { + return false; // non-numeric, trailing junk, empty, negative, or overflow + } + *out = value; + return true; +} + +uint16_t ParsePortArg(const int argc, char** argv, const uint16_t defaultPort) { + for (int i = 1; i + 1 < argc; ++i) { + if (strcmp(argv[i], "--port") == 0) { + long p = 0; + if (ParseWholeNumber(argv[i + 1], &p) && p > 0 && p <= 65535) { + return (uint16_t)p; + } + printf("Warning: ignoring invalid --port \"%s\" (want 1..65535); using %u.\n", + argv[i + 1], (unsigned)defaultPort); + } + } + return defaultPort; +} + +uint32_t ParseDeviceIdArg(const int argc, char** argv, const uint32_t defaultDeviceId) { + for (int i = 1; i + 1 < argc; ++i) { + if (strcmp(argv[i], "--deviceID") == 0) { + // 0 .. 4194302 is the valid BACnet device instance range (4194303 is + // the "unconfigured" wildcard and is not a usable instance). + long id = 0; + if (ParseWholeNumber(argv[i + 1], &id) && id < 4194303) { + return (uint32_t)id; + } + printf("Warning: ignoring invalid --deviceID \"%s\" (want 0..4194302); using %u.\n", + argv[i + 1], (unsigned)defaultDeviceId); + } + } + return defaultDeviceId; +} + +bool SetupUDP(const uint16_t port, const uint32_t networkPortInstance) { + UdpBinding* binding = FindUdpBindingByInstance(networkPortInstance); + if (binding == NULL) { + if (g_udpBindingCount >= MAX_UDP_BINDINGS) { + printf("Error: SetupUDP: MAX_UDP_BINDINGS (%u) already bound; " + "raise it in CASExampleHelper.cpp if you need more ports.\n", + (unsigned)MAX_UDP_BINDINGS); + return false; + } + binding = &g_udpBindings[g_udpBindingCount++]; + binding->networkPortInstance = networkPortInstance; + } + if (!binding->udp.Connect(port)) { + printf("Error: Failed to bind UDP port %u (Network Port %u).\n", + (unsigned)port, (unsigned)networkPortInstance); + return false; + } + binding->port = port; + binding->bound = true; + if (networkPortInstance == g_networkPortInstance) { + // Keep g_port in step for the single-argument SendIAm(deviceInstance). + g_port = port; + } + printf("FYI: Listening for BACnet/IP on UDP port %u (Network Port %u).\n", + (unsigned)port, (unsigned)networkPortInstance); + return true; +} + +bool SetupUDP(const uint16_t port) { + return SetupUDP(port, g_networkPortInstance); +} + +void ShutdownUDP() { + for (size_t i = 0; i < g_udpBindingCount; ++i) { + if (g_udpBindings[i].bound) { + g_udpBindings[i].udp.Disconnect(); + g_udpBindings[i].bound = false; + } + } +} + +void SetNetworkPortInstance(const uint32_t networkPortInstance) { + g_networkPortInstance = networkPortInstance; +} + +void RegisterCommonCallbacks() { + BACnetStack_RegisterCallbackReceiveMessageForPort(HelperReceiveMessage); + BACnetStack_RegisterCallbackSendMessageForPort(HelperSendMessage); + BACnetStack_RegisterCallbackGetSystemTime(HelperGetSystemTime); +} + +bool GetLocalIPv4(uint8_t ipAddress[4], uint8_t subnetMask[4]) { + return GetPrimaryIPv4(ipAddress, subnetMask); +} + +void SendIAm(const uint32_t deviceInstance, const uint32_t networkPortInstance) { + // Target the LOCAL subnet broadcast (the device's own network) rather than + // the global 255.255.255.255 / network 0xFFFF. The broadcast is ip | ~mask + // of the primary IPv4 interface - the same network the Network Port object + // represents - falling back to the limited broadcast if it can't be found. + // + // NOTE for multi-port examples: every Network Port in this series' examples + // runs on the SAME host interface (different UDP ports simulate different + // BACnet/IP networks), so the primary-interface broadcast address is the + // right target for every instance; only the port differs, taken from that + // instance's own binding below. + uint8_t bcast[4] = { 255, 255, 255, 255 }; + uint8_t ip[4], mask[4]; + if (GetPrimaryIPv4(ip, mask)) { + bcast[0] = (uint8_t)(ip[0] | ~mask[0]); + bcast[1] = (uint8_t)(ip[1] | ~mask[1]); + bcast[2] = (uint8_t)(ip[2] | ~mask[2]); + bcast[3] = (uint8_t)(ip[3] | ~mask[3]); + } + + const UdpBinding* binding = FindUdpBindingByInstance(networkPortInstance); + const uint16_t port = (binding != NULL && binding->bound) ? binding->port : g_port; + + const uint8_t connectionString[6] = { + bcast[0], bcast[1], bcast[2], bcast[3], + (uint8_t)((port >> 8) & 0xFF), (uint8_t)(port & 0xFF) + }; + // destinationNetwork 0 = the local network only (not the global 0xFFFF). + BACnetStack_SendIAm(deviceInstance, connectionString, 6, + networkPortInstance, + true /*broadcast*/, 0 /*local network*/, NULL, 0); +} + +void SendIAm(const uint32_t deviceInstance) { + SendIAm(deviceInstance, g_networkPortInstance); +} + +KeyCommand PollKey() { +#if defined(_WIN32) + if (!_kbhit()) { + return KeyCommand::None; + } + const int c = _getch(); + if (c == 0 || c == 0xE0) { // arrow / function key prefix + const int c2 = _getch(); + if (c2 == 72) return KeyCommand::ArrowUp; + if (c2 == 80) return KeyCommand::ArrowDown; + return KeyCommand::None; + } + if (c == 'h' || c == 'H') return KeyCommand::Help; + if (c == 'q' || c == 'Q') return KeyCommand::Quit; + if (c == 's' || c == 'S') return KeyCommand::DemoAdvance; + if (c == 'w' || c == 'W') return KeyCommand::WriteGroupDemo; + if (c == 'd' || c == 'D') return KeyCommand::DiscoverRemote; + if (c == 'r' || c == 'R') return KeyCommand::RouterAnnounce; + return KeyCommand::None; +#else + EnableRawInput(); + unsigned char buf[3]; + const int n = (int)read(STDIN_FILENO, buf, sizeof(buf)); + if (n <= 0) { + return KeyCommand::None; + } + if (n >= 3 && buf[0] == 27 && buf[1] == '[') { // ESC [ A/B = arrow keys + if (buf[2] == 'A') return KeyCommand::ArrowUp; + if (buf[2] == 'B') return KeyCommand::ArrowDown; + return KeyCommand::None; + } + if (buf[0] == 'h' || buf[0] == 'H') return KeyCommand::Help; + if (buf[0] == 'q' || buf[0] == 'Q') return KeyCommand::Quit; + if (buf[0] == 's' || buf[0] == 'S') return KeyCommand::DemoAdvance; + if (buf[0] == 'w' || buf[0] == 'W') return KeyCommand::WriteGroupDemo; + if (buf[0] == 'd' || buf[0] == 'D') return KeyCommand::DiscoverRemote; + if (buf[0] == 'r' || buf[0] == 'R') return KeyCommand::RouterAnnounce; + return KeyCommand::None; +#endif +} + +void RestoreInput() { +#if !defined(_WIN32) + if (g_rawActive) { + tcsetattr(STDIN_FILENO, TCSANOW, &g_origTermios); + g_rawActive = false; + } +#endif +} + +} // namespace CASExampleHelper diff --git a/common/CASExampleHelper.h b/common/CASExampleHelper.h new file mode 100644 index 0000000..546f375 --- /dev/null +++ b/common/CASExampleHelper.h @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see ../LICENSE. +#ifndef CAS_EXAMPLE_HELPER_H +#define CAS_EXAMPLE_HELPER_H + +// CASExampleHelper.h +// ============================================================================= +// Shared boilerplate for every CAS BACnet Stack example project. +// +// The goal of these examples is that each profile's main.cpp reads like a +// tutorial: device setup, the objects it exposes, and the property callbacks - +// nothing else. All the "plumbing" that is identical for every example lives +// here: +// +// * The UDP socket on the BACnet/IP port and the three transport/time +// callbacks the stack always needs (receive, send, get-system-time). +// * Printing version information (the example's version + the stack's). +// * Parsing the common command-line options (e.g. --port). +// * Reading the common keyboard commands (h / q / arrow up / arrow down). +// * Sending an unsolicited I-Am on start-up. +// +// This folder is generic and is copied verbatim into each example repository. +// It carries its own version (COMMON_VERSION below) and its own changelog +// (common/CHANGELOG.md) so an example can tell whether its copy is stale. +// To change it: edit it, bump COMMON_VERSION, record the change in +// common/CHANGELOG.md, then re-copy common/ into EVERY example in the series. +// +// The CAS BACnet Stack is linked via the C++ adapter (CASBACnetStackAdapter.h) - +// BACnetStack_* is called directly, the same call whether the stack is compiled from +// source, linked as a static lib, or loaded from a DLL/.so. The example's main() must +// call LoadBACnetFunctions() once, before any BACnetStack_* call, in every mode. +// ============================================================================= + +#include + +namespace CASExampleHelper { + +// --- Version / help -------------------------------------------------------- +// The version of the vendored common/ helper itself (NOT the example's +// version). Bump it whenever anything in common/ changes, and record the +// change in common/CHANGELOG.md - every example in the series must then be +// re-synced to the same common/ version. +static const char* COMMON_VERSION = "2.5.0"; + +// Print the example's name + version, the linked CAS BACnet Stack version, +// and the common/ helper version. +void PrintVersion(const char* appName, const char* appVersion); + +// Print the version information plus the list of interactive key commands. +// (This is what the 'h' key shows.) +void PrintHelp(const char* appName, const char* appVersion); + +// --- Command line ---------------------------------------------------------- +// Handle the two options that exit instead of running: --help and --version. +// Call this FIRST in main(), before binding a socket or touching the stack: +// +// if (CASExampleHelper::HandleHelpAndVersionArgs(argc, argv, APP_NAME, APP_VERSION)) { +// return 0; +// } +// +// Returns true if it printed something and the caller should exit(0); false to +// carry on starting up. Every example in the series supports --help/--version, +// so this lives here rather than in each main.cpp. +bool HandleHelpAndVersionArgs(int argc, char** argv, const char* appName, const char* appVersion); + +// Return the UDP port to use: the value after "--port" if present, else +// defaultPort. Common to every example. +uint16_t ParsePortArg(int argc, char** argv, uint16_t defaultPort); + +// Return the device instance to use: the value after "--deviceID" if present, +// else defaultDeviceId. BACnet requires a device's instance to be configurable. +// Common to every example. +uint32_t ParseDeviceIdArg(int argc, char** argv, uint32_t defaultDeviceId); + +// --- Networking ------------------------------------------------------------ +// Bind the UDP socket used by the CURRENT Network Port instance (the one last +// named by SetNetworkPortInstance(), or instance 1 if that was never called - +// the series convention for a single-port example). This is the entry point +// every single-port example has always used; its behaviour is unchanged by +// the multi-port support below: it binds exactly one socket, keyed to exactly +// one Network Port instance, exactly as before 2.3.0. +bool SetupUDP(uint16_t port); + +// Bind an ADDITIONAL UDP socket for a SPECIFIC Network Port object instance - +// added in common/ 2.3.0 for examples that own more than one BACnet/IP link +// (e.g. a router example with a Network Port per side). Call once per port, +// each with a different networkPortInstance. Internally this is the same +// per-instance binding SetupUDP(port) uses for the "current" instance; the +// two overloads share one small table (CASExampleHelper.cpp), so mixing them +// is safe - SetupUDP(port) is exactly SetupUDP(port, ). +// +// The receive callback polls every bound socket in round-robin order each +// tick (so one busy port cannot starve another) and reports the instance the +// datagram arrived on; the send callback looks up the socket whose instance +// matches the one the stack names. A single-port example has exactly one +// entry in that table, so both callbacks collapse back to "the one socket" - +// see the "WHY THIS IS SAFE FOR SINGLE-PORT EXAMPLES" comment in +// CASExampleHelper.cpp. +bool SetupUDP(uint16_t port, uint32_t networkPortInstance); + +// Close every UDP socket bound by SetupUDP() (all instances). +void ShutdownUDP(); + +// Tell the helper which Network Port object instance is "current" - the one +// the single-argument SetupUDP(port) and SendIAm(deviceInstance) overloads +// act on. +// +// The stack identifies a link by the INSTANCE of its Network Port object, not +// by a transport network type: the receive callback reports the instance a +// datagram arrived on, the send callback is told the instance to send from, +// and SendIAm is told which port to announce on. +// +// Call this once, after BACnetStack_AddNetworkPortObject() and before +// RegisterCommonCallbacks(). It defaults to 1 - the series convention - so an +// example that uses instance 1 need not call it, but calling it explicitly +// keeps main.cpp's Network Port instance the single source of truth. A +// multi-port example (SetupUDP(port, instance) for two or more instances) +// still calls this once, for whichever instance should be "current" for the +// single-argument SetupUDP()/SendIAm() overloads - typically its first port. +void SetNetworkPortInstance(uint32_t networkPortInstance); + +// Register the transport + system-time callbacks (backed by the UDP socket +// created in SetupUDP, so call SetupUDP first). +void RegisterCommonCallbacks(); + +// Broadcast an unsolicited I-Am for the given device on the CURRENT Network +// Port instance (see SetNetworkPortInstance). ANSI/ASHRAE 135 requires a +// device to announce itself with I-Am on start-up. Unchanged since before +// 2.3.0 for a single-port example. +void SendIAm(uint32_t deviceInstance); + +// Broadcast an unsolicited I-Am for the given device on a SPECIFIC Network +// Port instance - added in common/ 2.3.0 for a multi-port example that must +// announce itself once per port/network it serves. SendIAm(deviceInstance) is +// exactly SendIAm(deviceInstance, ). +void SendIAm(uint32_t deviceInstance, uint32_t networkPortInstance); + +// Get the primary IPv4 interface's address and subnet mask (each as 4 octets). +// These are the values a BACnet/IP Network Port object reports (IP_Address, +// IP_Subnet_Mask), and the broadcast (ip | ~mask) is the I-Am target. Returns +// true on success; on failure the buffers are left untouched. +bool GetLocalIPv4(uint8_t ipAddress[4], uint8_t subnetMask[4]); + +// --- Deferred device restart (DM-RD-B) ------------------------------------- +// Only for examples whose profile includes DM-RD-B (i.e. that register a +// ReinitializeDevice callback). Most profiles in this series do not. +// +// WHY THIS EXISTS. A ReinitializeDevice callback must NOT restart the device +// inside the callback. Returning true only tells the stack the request was +// accepted - the SimpleACK is encoded now but does not reach the wire until a +// later BACnetStack_Tick(). Restart (or exit, or reset) before that tick and +// the ACK is never transmitted: the client sees a timeout and reports the +// device as unresponsive, even though it did exactly what it was told. That is +// the classic DM-RD-B interop bug, and it fails BTL. +// +// The fix is to defer: the callback records "a restart is due at time T" and +// returns immediately; the main loop performs the restart once T has passed, by +// which point the ACK has been sent. The delay also gives the client's own +// request timer a chance to complete cleanly. +// +// Usage - in the ReinitializeDevice callback, after it decides to accept: +// +// CASExampleHelper::RequestRestart(CASExampleHelper::RestartKind::Cold, +// CASExampleHelper::RESTART_DELAY_MS); +// return true; // let the stack ACK first +// +// ...and once per tick in the main loop, after BACnetStack_Tick(): +// +// CASExampleHelper::RestartKind kind; +// if (CASExampleHelper::RestartDue(&kind)) { +// // a real device reboots here; see main.cpp for what this example does +// } + +enum class RestartKind { + Cold, // REINITIALIZE_STATE_COLDSTART - full power-on restart + Warm // REINITIALIZE_STATE_WARMSTART - re-initialize, keep what survives +}; + +// A restart delay that is comfortably longer than one tick of the main loop, +// so the SimpleACK is on the wire before anything is torn down. One second is +// the conventional choice: long enough to be safe on a busy or slow link, +// short enough that the operator sees the device go down promptly. +static const uint32_t RESTART_DELAY_MS = 1000; + +// Record that a restart of the given kind is due delayMilliseconds from now. +// Safe to call more than once: the EARLIEST pending deadline wins, so a second +// ReinitializeDevice arriving during the delay window cannot postpone a restart +// that was already promised to the first client. A Cold request also upgrades a +// pending Warm one (cold is the stronger reset); the reverse does not downgrade. +void RequestRestart(RestartKind kind, uint32_t delayMilliseconds); + +// Call once per tick of the main loop. Returns true EXACTLY ONCE - on the first +// call after the deadline has passed - and writes the requested kind to +// *outKind. Returns false when no restart is pending or the delay has not yet +// elapsed. Clearing the request before returning true means a caller that +// handles the restart in-process (rather than actually rebooting) does not get +// a second one on the next tick. +bool RestartDue(RestartKind* outKind); + +// --- Keyboard input (common to every example) ------------------------------ +enum class KeyCommand { + None, // nothing pressed + Help, // 'h' - show version + commands + Quit, // 'q' - exit + ArrowUp, // up arrow + ArrowDown, // down arrow + DemoAdvance, // 's' - advance a demo (e.g. a Schedule) to its next step now, + // bypassing whatever wall-clock wait it would otherwise need + WriteGroupDemo,// 'w' - manually fire a demo WriteGroup (or other outbound + // SendWriteProperty) instead of waiting for whatever + // triggers it normally + DiscoverRemote, // 'd' - send a demo SendWhoIs to discover a remote device + // this example writes to or reads from + RouterAnnounce // 'r' - manually (re-)send I-Am-Router-To-Network now, instead + // of waiting for the one sent at start-up, so routing + // can be demonstrated on demand +}; + +// Non-blocking: returns a pending key command, or None if nothing was pressed. +// Call once per tick of the main loop. +KeyCommand PollKey(); + +// Restore the terminal to its normal mode (POSIX); no-op on Windows. Call once +// before the program exits. +void RestoreInput(); + +} // namespace CASExampleHelper + +#endif // CAS_EXAMPLE_HELPER_H diff --git a/common/CHANGELOG.md b/common/CHANGELOG.md new file mode 100644 index 0000000..bb76c17 --- /dev/null +++ b/common/CHANGELOG.md @@ -0,0 +1,321 @@ +# Changelog — `common/` (the vendored shared helper) + +This is the changelog of the **`common/` folder itself**, separate from the +example's own `CHANGELOG.md`. The folder is vendored (copied) into every +example repository in the BACnet profile example series; this file plus the +`COMMON_VERSION` constant in `CASExampleHelper.h` tell you whether a given +example's copy is stale. + +**Rule:** any change to anything in `common/` bumps `COMMON_VERSION`, gets an +entry here, and must then be re-copied into **every** example in the series. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the folder adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.5.0] - 2026-09-15 + +### Added + +- `PROPERTY_IDENTIFIER_ROUTING_TABLE = 428` in `CASBACnetStackExampleConstants.h` + (Network Port's `Routing_Table`, optional - `BACnetStack_SetPropertyEnabled` + first). Matches `BACnetPropertyIdentifier.h`'s `routingTable = 428`. First + consumer: `BACnetProfileExample-B-RTR-CPP` (F-ROUTER, Wave 2), which enables + it on both its Network Port objects so a client can read back the routing + table it configures with `AddRouterPort`/`AddRouterRoute`. + +## [2.4.0] - 2026-09-15 + +### Added + +- `KeyCommand::RouterAnnounce` (key `r` / `R`), wired in `PollKey()` on both the + Windows (`_getch`) and POSIX (raw-terminal `read`) code paths. Non-breaking, + purely additive: existing `switch (CASExampleHelper::PollKey())` call sites + with a `default:` case (every current example) build unchanged. +- Claimed in the series-wide `docs/menu-keys.md`: manually (re-)send + I-Am-Router-To-Network now, instead of waiting for the one sent at start-up, + so routing can be demonstrated on demand. First consumer: + `BACnetProfileExample-B-RTR-CPP`'s F-ROUTER demo (Wave 2); any later + routing/gateway example (e.g. B-GW) reuses this same key rather than + claiming a new one. + +## [2.3.0] - 2026-09-15 + +### Added + +- Multi-port UDP support: `SetupUDP(uint16_t port, uint32_t networkPortInstance)` + and `SendIAm(uint32_t deviceInstance, uint32_t networkPortInstance)` overloads, + backed by a small internal table of UDP socket bindings keyed by Network Port + instance (`CASExampleHelper.cpp`, `UdpBinding` / `g_udpBindings`), replacing the + single file-local `SimpleUDP g_udp`. `HelperReceiveMessage` now polls every + bound socket in round-robin order and reports the instance a datagram actually + arrived on; `HelperSendMessage` looks up the socket bound to the instance the + stack names instead of comparing against one "the" instance. +- First consumer: `BACnetProfileExample-B-RTR-CPP` (Wave 2, F-ROUTER / + F-MULTIPORT canonical example), which owns two Network Port objects ("Vermilion" + and "Vermilion 2") on two UDP ports and needs the receive/send callbacks to + dispatch on `networkPortInstance` per ANSI/ASHRAE 135 routing (`AddRouterPort`, + `AddRouterRoute`, `SendIAmRouterToNetwork`). + +### Non-breaking for every single-port example + +- `SetupUDP(uint16_t port)` and `SendIAm(uint32_t deviceInstance)` keep their + existing signatures and now simply forward to the two-argument overloads with + `networkPortInstance` = whatever `SetNetworkPortInstance()` last set (instance 1 + by default, unchanged). A single-port example therefore ends up with exactly one + entry in the internal binding table, and every codepath (receive round-robin + over one entry, send lookup against one entry) collapses back to the same work + the old single-`SimpleUDP` implementation did. See the "WHY THIS IS SAFE FOR + SINGLE-PORT EXAMPLES" comment above `struct UdpBinding` in + `CASExampleHelper.cpp`. +- Verified directly against `BACnetProfileExample-B-SS-CPP` itself (single Network + Port, instance 1): rebuilt STATIC against the pinned stack, smoke-tested on a + non-default port — startup, `Listening for BACnet/IP on UDP port ...`, the + broadcast I-Am, and a Who-Is/I-Am/ReadProperty exchange with an external client + all matched the pre-change baseline byte-for-byte in content (only the RX/TX log + lines gained a trailing `(Network Port N)` annotation). +- The `RX`/`TX` console log lines gained a trailing `(Network Port N)` annotation; + no gate script or CI step matches those lines verbatim (checked: + `tools/check-series.sh`, `tools/*.sh` contain no `RX %u bytes` / `TX %u bytes` + pattern), and the existing substring checks in the runbook's smoke-test step + (`Listening for BACnet/IP on UDP port ...`, `... (broadcast)`) still match as + prefixes of the new lines. + +## [2.2.0] - 2026-09-15 + +### Added + +- `KeyCommand::WriteGroupDemo` (key `w` / `W`) and `KeyCommand::DiscoverRemote` + (key `d` / `D`), wired in `PollKey()` on both the Windows (`_getch`) and + POSIX (raw-terminal `read`) code paths. Non-breaking, purely additive: + existing `switch (CASExampleHelper::PollKey())` call sites with a `default:` + case (every current example) build unchanged. +- Claimed in the series-wide `docs/menu-keys.md`: `WriteGroupDemo` manually + fires a demo WriteGroup (or other outbound `SendWriteProperty`) instead of + waiting for whatever normally triggers it; `DiscoverRemote` sends a demo + `SendWhoIs` to discover a remote device this example writes to or reads + from. First consumer: `BACnetProfileExample-B-LS-CPP`'s F-CHANNEL / + F-EXTWRITE / F-SCHED-E demo (Wave 2); any later example with a remote-write + or remote-discovery demo reuses these same keys rather than claiming new + ones. + +## [2.1.0] - 2026-09-15 + +### Added + +- `KeyCommand::DemoAdvance` (key `s` / `S`), wired in `PollKey()` on both the + Windows (`_getch`) and POSIX (raw-terminal `read`) code paths. Non-breaking, + purely additive: existing `switch (CASExampleHelper::PollKey())` call sites + with a `default:` case (every current example) build unchanged. +- Claimed in the series-wide `docs/menu-keys.md`: "Advance the demo Schedule to + its next `Weekly_Schedule` time-value immediately, bypassing the wall-clock + wait, so a Schedule-driven write can be demonstrated on demand." First + consumer: `BACnetProfileExample-B-AAC-CPP`'s SCHED-I-B demo (Wave 1); any + later example with a Schedule-driven demo reuses this same key rather than + claiming a new one. + +## [2.0.0] - 2026-09-09 + +### Changed — BREAKING, required by the CAS BACnet Stack interface update + +The stack's transport and time callbacks changed shape. Every example in the +series must take this version of `common/` together with the matching `main.cpp` +edits; an old `main.cpp` will not build against this helper and vice versa. + +- **A link is now identified by its Network Port object INSTANCE, not by a + transport network type** (stack issues #822/#556). Three call sites moved: + - `RegisterCallbackReceiveMessage` → **`RegisterCallbackReceiveMessageForPort`**; + the callback's trailing `uint8_t* networkType` became + `uint32_t* networkPortInstance`. + - `RegisterCallbackSendMessage` → **`RegisterCallbackSendMessageForPort`**; + the callback's `const uint8_t networkType` became + `const uint32_t networkPortInstance`. + - `BACnetStack_SendIAm()` takes the Network Port instance where it took the + network type. +- **New: `SetNetworkPortInstance()`.** The helper has to know which Network Port + object owns its socket. Call it after `BACnetStack_AddNetworkPortObject()` and + before `RegisterCommonCallbacks()`. It defaults to 1 (the series convention), + so an example using instance 1 keeps working without the call, but calling it + keeps `main.cpp`'s Network Port instance the single source of truth. +- **`GetSystemTime` returns `CASBACnetTime` (`int64_t`), not `time_t`.** `time_t` + is 32-bit in some toolchains and 64-bit in others; with the stack and the + application disagreeing, every timestamp crossing the ABI was corrupted. +- **New error-code constants** in `CASBACnetStackExampleConstants.h`: + `ERROR_CODE_READ_ACCESS_DENIED`, `ERROR_CODE_UNKNOWN_PROPERTY`, + `ERROR_CODE_INVALID_ARRAY_INDEX` and `ERROR_CODE_SUCCESS`, for the `errorCode` + out-parameter the `GetProperty*` callbacks gained (stack issue #974). + `NETWORK_TYPE_IP` is retained but is no longer used by the helper. + +## [1.5.1] - 2026-07-31 + +### Fixed + +- **`--help` printed the version banner twice.** The handler printed it, then called + `PrintHelp()` which printed it again. The interactive key list is now a separate + internal helper, so `--help` prints banner → usage → keys once each. `PrintHelp()` + (what the `h` key shows) is unchanged. +- **`common/README.md` was stale and taught a `main()` skeleton that no longer works.** + It still said the stack "is compiled from source… there is no DLL to load at runtime", + and its snippet omitted `LoadBACnetFunctions()` — which 1.5.0 made mandatory in every + link mode. Anyone using that snippet as their starting point wrote the exact bug 1.5.0 + was written to prevent (in DLL mode, a null-pointer call). The README now leads with the + load call and explains why it is not optional. + +## [1.5.0] - 2026-07-31 + +### Changed + +- **`CASExampleHelper.cpp` now includes `CASBACnetStackAdapter.h` instead of + `CASBACnetStackDLL.h`.** No functional change to `common/`'s own code — it still + calls `BACnetStack_*` directly, same as before. This is a **contract change for + every consuming example's `main()`**: it must call `LoadBACnetFunctions()` once, + before any `BACnetStack_*` call (including before `CASExampleHelper::PrintVersion` + / `HandleHelpAndVersionArgs`, which call `BACnetStack_GetAPIMajorVersion()` etc.), + in every link mode. In source/static-lib mode this was previously a no-op (the + functions are always callable — "linked directly, there is no load step," per the + old header comment); the adapter's DLL mode makes that no longer universally true, + so the load step is now mandatory everywhere for one call to work in all three + modes. Each example in the series adopts this as it re-syncs to 1.5.x, and its + `main()` must gain the `LoadBACnetFunctions()` call at that point. + +## [1.4.0] - 2026-07-18 + +### Added + +- **`RequestRestart()` / `RestartDue()` — the deferred-restart pattern for + `ReinitializeDevice` (DM-RD-B).** A `ReinitializeDevice` callback must not + restart the device inside the callback: returning `true` only *encodes* the + SimpleACK, which does not reach the wire until a later `BACnetStack_Tick()`. + Restarting (or exiting, or resetting) before that tick means the ACK is never + transmitted and the client reports a timeout against a device that did exactly + what it was asked — the classic DM-RD-B interop bug. The callback now records a + deadline and returns; the main loop restarts once the deadline passes. + Adopted by B-AAC (the only example in the series whose profile includes + DM-RD-B); the helper is inert in every example that never calls it. Adoption + checklist in `docs/deferred-restart-adoption.md` (B-AAC). + - The deadline uses a **monotonic** clock (`GetTickCount64` / `CLOCK_MONOTONIC`), + not `time()`: a device supporting DM-TS-B can have its wall clock stepped + backwards by a management station mid-delay, which would strand or prematurely + fire the pending restart. + - Repeat requests keep the **earliest** deadline (a second client cannot + postpone a restart already promised to the first) and let Cold upgrade a + pending Warm. + +### Fixed + +- **B-OD's `CASBACnetStackExampleConstants.h` was stale** — it never received the + `NETWORK_NUMBER_QUALITY_*` constants or the + `AddNetworkPortObjectWithNetworkNumber` comment update from the 2026-07-17 + item-16 migration, because B-OD was the one example that did not itself need the + migrated call. That silently broke the folder's byte-identical guarantee (the + rule this file states, and that parent-repo CI enforces) in a repo where nothing + failed to compile. Re-synced with this release's sweep. + +### Notes + +- The folder is verified **content-identical in all eight examples** as of this + release. A working-tree scan on Windows shows CRLF in six repos and LF in two, + which looks like drift but is not: every committed blob is LF in all eight, and + the difference is local `core.autocrlf` checkout behaviour. Compare with + `git show HEAD:common/`, or `diff --strip-trailing-cr`, when auditing this + — a naive working-tree `diff -r` reports eight files of phantom drift. + +## [1.3.0] - 2026-07-16 + +### Changed + +- **`ParsePortArg` / `ParseDeviceIdArg` now reject non-numeric input instead of + silently accepting it.** They used `atoi`/`atol`, which return `0` on garbage - + so `--deviceID abc` parsed as device **0** (a valid instance) and the operator + shipped a device answering at the wrong address with no diagnostic. Both now + validate the whole token with `strtol` + an end-pointer check (new + `ParseWholeNumber` helper) and print a `Warning:` naming the bad value and the + default they fell back to. Added `#include `. + +## [1.2.0] - 2026-07-15 + +The **reconciliation release**: this is the first version that is genuinely +byte-identical in every example, and the first that is enforced rather than +asserted. + +### Fixed + +- **`PollKey()` no longer blocks the tick loop forever when stdin is not a tty.** + `EnableRawInput()` called `tcgetattr` first and **returned early on failure**, + before setting `O_NONBLOCK`. Under CI, `docker run -i` without `-t`, or a pipe + with no data, the next `read(STDIN_FILENO, ...)` blocked indefinitely — + `BACnetStack_Tick()` never ran again and **the device went deaf while still + looking alive**. `O_NONBLOCK` is now set unconditionally, before the tty check; + raw mode is still only applied to a real terminal. (`< /dev/null` masked this, + which is why it survived the smoke tests.) +- **Windows now asks for `SO_EXCLUSIVEADDRUSE` instead of `SO_REUSEADDR`.** On + Windows `SO_REUSEADDR` lets *any* other local process bind the same port and + silently steal traffic — the exact failure the series runbook's gotcha 2 warns + about ("a stale instance can answer your requests and make a correct device look + broken"), shipped enabled in the file customers copy. POSIX keeps + `SO_REUSEADDR` (there it mainly means "rebind quickly after restart", which is + what makes multi-device testing work). The comment now states the hazard, not + just the convenience. + +### Added + +- **`HandleHelpAndVersionArgs()` — `--help` and `--version` now actually exist.** + The series conventions require every example to parse `--help`, `--version`, + `--deviceID` and `--port`, and every README documented all four, but the helper + only ever parsed `--port` and `--deviceID`: `--help` was the first thing a + newcomer typed and the app just booted. Also accepts `-h` and `/?`. +- `CASBACnetStackExampleConstants.h` is now the **true series-wide union**, not + just B-SS's set: DeviceCommunicationControl (`DCC_*`, + `SERVICE_DEVICE_COMMUNICATION_CONTROL`, `ERROR_CODE_PASSWORD_FAILURE`), + alarm/event (`SERVICE_ACKNOWLEDGE_ALARM`, `*_EVENT_NOTIFICATION`, + `SERVICE_GET_EVENT_INFORMATION`, `NOTIFY_TYPE_*`, + `OBJECT_TYPE_NOTIFICATION_CLASS`, `OBJECT_TYPE_ANALOG_VALUE`), + ReinitializeDevice, time-sync, RPM/WPM, the A-side `*_SERVICE_TYPE` aliases and + `BACNET_DATATYPE_REAL`, and `ENGINEERING_UNITS_PERCENT`. + +### Notes + +- **The 1.1.0 entry below claimed this file was already the "series-wide + superset". That was false**, and the claim did real damage: B-SS's copy never + had the `DCC_*` constants that B-ASC had carried since its first commit, so the + runbook's own instruction to seed a new example's `common/` from B-SS **would + not compile** for any DCC-bearing profile. 1.2.0 is the first version for which + the claim is actually true. +- Measured before this release: only **2 of 6** populated examples declared + `COMMON_VERSION` at all (B-SS, B-SA); B-ASC, B-AAC, B-OD and B-LD had neither it + nor this changelog — so the staleness mechanism did not lie, it **did not + exist** in those repos, and their `PrintVersion` could not print the helper + version line. Fixed by sweeping this folder to every example. +- Going forward this is checked, not trusted: the parent repo's CI diffs every + `examples/*/common` against B-SS's and fails on any delta. + +## [1.1.0] - 2026-07-14 + +### Added + +- `COMMON_VERSION` constant in `CASExampleHelper.h` and this changelog — the + folder now carries its own version so each example can tell whether its + vendored copy is stale. `PrintVersion` prints it alongside the app + stack + versions. +- `CASBACnetStackExampleConstants.h` gained the output object types, the + commandable-property identifiers (`Priority_Array`, `Relinquish_Default`), and + the error codes introduced for B-SA. (This entry originally claimed the file was + the "series-wide superset" — see the 1.2.0 notes; it was not.) + +## [1.0.1] - 2026-06-16 + +### Fixed + +- `GetPrimaryIPv4` (Windows): check the subnet-mask `inet_pton` return and + zero-initialize the `in_addr`, so a mask string that fails to parse can no + longer be read uninitialized (it falls back to the limited broadcast). + (Shipped in B-SS v1.0.2; the folder was unversioned at the time.) + +## [1.0.0] - 2026-06-16 + +### Added + +- Initial vendored helper: `CASExampleHelper` (UDP transport + time callbacks, + version/help printing, `--port` / `--deviceID` parsing, keyboard commands + h/q/up/down, local-IPv4 + broadcast lookup, unsolicited I-Am on start-up), + `SimpleUDP` (cross-platform UDP socket wrapper), and + `CASBACnetStackExampleConstants.h`. diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..6b9e31a --- /dev/null +++ b/common/README.md @@ -0,0 +1,65 @@ +# `common/` — shared example helper + +This folder holds the boilerplate that is identical for every example in the +BACnet profile example series, vendored into this repository so the example +builds on its own. It lets each profile's `main.cpp` stay short and read like a +tutorial, focused only on what makes that profile different. + +## Versioning + +`common/` carries its **own version**, separate from the example's version: +the `COMMON_VERSION` constant in `CASExampleHelper.h`, with history in +[`CHANGELOG.md`](CHANGELOG.md) (this folder's changelog, not the example's). +`PrintVersion` prints it at start-up. The folder must be **byte-identical** in +every example of the series — if this copy's version is older than the latest +`common/` changelog entry elsewhere in the series, the copy is stale. + +**Never edit `common/` for one example only.** To change it: edit, bump +`COMMON_VERSION`, add a changelog entry, then re-copy the folder into every +example repository. + +## What's here + +| File | Purpose | +|------|---------| +| `CASExampleHelper.h` / `.cpp` | UDP socket on the BACnet/IP port + the transport/time callbacks; version/help printing; `--port` / `--deviceID` parsing; keyboard commands (h/q/up/down); local-IPv4 / broadcast helpers; broadcast I-Am on start-up. | +| `SimpleUDP.h` / `.cpp` | A tiny cross-platform UDP socket wrapper (Winsock on Windows, BSD sockets on Linux/macOS). | +| `CASBACnetStackExampleConstants.h` | A self-contained, series-wide copy of the BACnet enumeration values the examples use (each section names the CAS BACnet Stack header that defines the full enumeration). Identical in every example — the union of what the series needs. | +| `CHANGELOG.md` | The changelog of this folder itself (see Versioning above). | + +## How `main.cpp` uses it + +```cpp +// FIRST, before any other BACnetStack_* call - including anything in this helper, since +// PrintVersion() asks the stack for its version. Mandatory in every link mode. +if (!LoadBACnetFunctions()) { + fprintf(stderr, "Error: failed to load the CAS BACnet Stack: %s\n", + CASBACnetStackAdapter_LastError()); + return 1; +} + +const uint16_t port = CASExampleHelper::ParsePortArg(argc, argv, 47808); +g_deviceInstance = CASExampleHelper::ParseDeviceIdArg(argc, argv, g_deviceInstance); +CASExampleHelper::PrintVersion(APP_NAME, APP_VERSION); // app + stack version +CASExampleHelper::SetupUDP(port); +CASExampleHelper::RegisterCommonCallbacks(); // receive / send / time +// ... register the example's own GetProperty callbacks, add device + objects ... +CASExampleHelper::SendIAm(g_deviceInstance); // announce on start-up + +while (running) { + BACnetStack_Tick(); + switch (CASExampleHelper::PollKey()) { /* h / q / up / down / s */ } +} +CASExampleHelper::RestoreInput(); +``` + +The CAS BACnet Stack (a git submodule of this repository) is reached through the C++ +adapter (`adapters/cpp/CASBACnetStackAdapter.h`), so its C API is called directly as +`BACnetStack_*` — the same call whether the stack is compiled from source, linked as a +static library, or loaded from a DLL/.so at runtime. Which of those happens is a build-time +choice (`CAS_BACNET_STACK_LINK`); the code above does not change. + +**That is why `LoadBACnetFunctions()` is not optional.** In DLL mode it is the call that +binds the symbols, so skipping it leaves every `BACnetStack_*` name a null pointer; in the +other modes it still runs the stack-version handshake. Call it once, at the top of `main()`, +from a single thread. See `common/CHANGELOG.md` 1.5.0. diff --git a/common/SimpleUDP.cpp b/common/SimpleUDP.cpp new file mode 100644 index 0000000..23ff698 --- /dev/null +++ b/common/SimpleUDP.cpp @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see ../LICENSE. +// SimpleUDP.cpp +// ============================================================================= +// Cross-platform UDP socket wrapper - see SimpleUDP.h for the interface. +// ============================================================================= + +#include "SimpleUDP.h" + +#include + +#if defined(_WIN32) +#include +#include +// ws2_32 is linked by CMakeLists.txt. If you copy this file into a non-CMake +// MSVC project, add: #pragma comment(lib, "ws2_32.lib") +typedef int socklen_t; +#define INVALID_SOCK INVALID_SOCKET +#else +#include +#include +#include +#include +#include +#include +#include +#define INVALID_SOCK (-1) +#endif + +namespace { +// One-time Winsock startup/teardown. No-op on POSIX. +struct PlatformInit { + PlatformInit() { +#if defined(_WIN32) + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); +#endif + } + ~PlatformInit() { +#if defined(_WIN32) + WSACleanup(); +#endif + } +}; +PlatformInit g_platformInit; +} // namespace + +SimpleUDP::SimpleUDP() : m_socket(INVALID_SOCK), m_connected(false) {} + +SimpleUDP::~SimpleUDP() { Disconnect(); } + +bool SimpleUDP::Connect(const uint16_t localPort) { + m_socket = (intptr_t)socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (m_socket == (intptr_t)INVALID_SOCK) { + return false; + } + + // Port-sharing policy. Read this before copying it into a product. + // + // BACnet/IP allows several devices to share UDP 47808 on one host, and that + // is genuinely useful while developing (run two examples at once). But it has + // a nasty failure mode: a STALE instance - or any other BACnet program - can + // answer requests meant for your device, and a perfectly correct device then + // looks broken. That is a classic day-one BACnet debugging trap. + // + // Windows and POSIX disagree about what SO_REUSEADDR means, so we do not use + // the same option on both: + // + // POSIX - SO_REUSEADDR here mainly lets us rebind quickly after a restart + // (skipping TIME_WAIT). We keep it: restarting the example is the + // common case, and it is what makes multi-device testing possible. + // Windows- SO_REUSEADDR is far more permissive: it lets ANY other local + // process bind the same port and silently steal our traffic. + // Microsoft's guidance is SO_EXCLUSIVEADDRUSE, so that is what we + // ask for - we would rather fail loudly at bind() than be hijacked. + // + // If you are running two of these examples on one Windows host on purpose, + // give one of them a different --port rather than relaxing this. +#if defined(_WIN32) + const int exclusive = 1; + setsockopt(m_socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + (const char*)&exclusive, sizeof(exclusive)); +#else + const int reuse = 1; + setsockopt((int)m_socket, SOL_SOCKET, SO_REUSEADDR, + (const char*)&reuse, sizeof(reuse)); +#endif + + // Allow sending to the broadcast address (needed for I-Am replies, etc.). + const int broadcast = 1; + setsockopt((int)m_socket, SOL_SOCKET, SO_BROADCAST, + (const char*)&broadcast, sizeof(broadcast)); + + // Short receive timeout so Receive() does not block the tick loop for long. + // 10 ms keeps the device responsive (the tick loop runs ~90x/sec when idle) + // while still yielding the CPU. Note: do NOT use 0 here - for SO_RCVTIMEO a + // value of 0 means "block forever", not "return immediately". +#if defined(_WIN32) + const DWORD timeoutMs = 10; + setsockopt((int)m_socket, SOL_SOCKET, SO_RCVTIMEO, + (const char*)&timeoutMs, sizeof(timeoutMs)); +#else + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 10 * 1000; // 10 ms + setsockopt((int)m_socket, SOL_SOCKET, SO_RCVTIMEO, + (const char*)&tv, sizeof(tv)); +#endif + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(localPort); + + if (bind((int)m_socket, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + Disconnect(); + return false; + } + + m_connected = true; + return true; +} + +void SimpleUDP::Disconnect() { + if (m_socket != (intptr_t)INVALID_SOCK) { +#if defined(_WIN32) + closesocket((SOCKET)m_socket); +#else + close((int)m_socket); +#endif + m_socket = (intptr_t)INVALID_SOCK; + } + m_connected = false; +} + +uint16_t SimpleUDP::Send(const uint8_t* ipAddress, const uint16_t port, + const uint8_t* data, const uint16_t length) { + if (!m_connected || ipAddress == NULL || data == NULL) { + return 0; + } + + struct sockaddr_in dest; + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = htons(port); + memcpy(&dest.sin_addr.s_addr, ipAddress, 4); // IPv4 octets, network order + + int sent = sendto((int)m_socket, (const char*)data, (int)length, 0, + (struct sockaddr*)&dest, sizeof(dest)); + return (sent > 0) ? (uint16_t)sent : 0; +} + +uint16_t SimpleUDP::Receive(uint8_t* buffer, const uint16_t maxLength, + uint8_t* fromIpAddress, uint16_t* fromPort) { + if (!m_connected || buffer == NULL) { + return 0; + } + + struct sockaddr_in from; + socklen_t fromLen = sizeof(from); + memset(&from, 0, sizeof(from)); + + int received = recvfrom((int)m_socket, (char*)buffer, (int)maxLength, 0, + (struct sockaddr*)&from, &fromLen); + if (received <= 0) { + return 0; // timeout or error - nothing to process this tick + } + + if (fromIpAddress != NULL) { + memcpy(fromIpAddress, &from.sin_addr.s_addr, 4); + } + if (fromPort != NULL) { + *fromPort = ntohs(from.sin_port); + } + return (uint16_t)received; +} diff --git a/common/SimpleUDP.h b/common/SimpleUDP.h new file mode 100644 index 0000000..cfea105 --- /dev/null +++ b/common/SimpleUDP.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see ../LICENSE. +#ifndef SIMPLE_UDP_H +#define SIMPLE_UDP_H + +// SimpleUDP.h +// ============================================================================= +// A minimal, cross-platform UDP socket wrapper used by the BACnet example +// projects. It is intentionally small: just enough to bind a port, send a +// datagram to an IPv4 address, and receive one (non-blocking with a short +// timeout). On Windows it uses Winsock2 (link ws2_32); on Linux/macOS it uses +// BSD sockets. +// +// This is plain, unrestricted example code - copy it into your own project. +// ============================================================================= + +#include +#include + +class SimpleUDP { +public: + SimpleUDP(); + ~SimpleUDP(); + + // Bind the UDP socket to localPort (BACnet/IP default is 47808 / 0xBAC0). + // Enables address reuse and broadcast. Returns true on success. + bool Connect(uint16_t const localPort); + + // Close the socket. + void Disconnect(); + + // Send 'length' bytes to ipAddress[4] (4 octets) : port. + // Returns the number of bytes sent, or 0 on failure. + // (Named Send, not SendMessage, to avoid the Win32 macro that + // #defines SendMessage to SendMessageA.) + uint16_t Send(const uint8_t* ipAddress, const uint16_t port, + const uint8_t* data, const uint16_t length); + + // Try to receive one datagram into buffer (up to maxLength bytes). + // Non-blocking-ish: waits up to the socket receive timeout. Returns the + // number of bytes received (0 if none / timeout). On success, fills + // fromIpAddress[4] and *fromPort with the sender's address. + // (Named Receive, not GetMessage, to avoid the Win32 GetMessage macro.) + uint16_t Receive(uint8_t* buffer, const uint16_t maxLength, + uint8_t* fromIpAddress, uint16_t* fromPort); + +private: + // Stored as an int to avoid leaking platform socket headers here. + // (SOCKET on Windows, file descriptor on POSIX.) + intptr_t m_socket; + bool m_connected; +}; + +#endif // SIMPLE_UDP_H diff --git a/docs/objects.json b/docs/objects.json new file mode 100644 index 0000000..9864982 --- /dev/null +++ b/docs/objects.json @@ -0,0 +1,99 @@ +{ + "objects": [ + { + "type": "Device", + "instance": 389018, + "name": "Rainbow", + "app": ["Object_Name", "Vendor_Name", "Vendor_Identifier", "Model_Name", + "Firmware_Revision", "Application_Software_Version", "Description"], + "enabled": ["Description"], + "writable": [], + "accepted": ["System_Status", "Protocol_Version", "Protocol_Revision", + "Protocol_Services_Supported", "Protocol_Object_Types_Supported", + "Object_List", "Max_APDU_Length_Accepted", "Segmentation_Supported", + "APDU_Timeout", "Number_Of_APDU_Retries", "Device_Address_Binding", + "Database_Revision"], + "note": "vendor 389 (Chipkin Automation Systems); instance configurable with --deviceID. The properties in 'accepted' are not served from a GetProperty callback because the stack itself is the source of truth for them - Protocol_Revision/Protocol_Version are stack build constants, Protocol_Services_Supported/Protocol_Object_Types_Supported are computed from the BACnetStack_SetServiceEnabled/AddObject calls this example already makes, Object_List and Device_Address_Binding are live stack-maintained tables, System_Status/Database_Revision/Max_APDU_Length_Accepted/Segmentation_Supported/APDU_Timeout/Number_Of_APDU_Retries are the stack's own configuration defaults for a device this example does not override" + }, + { + "type": "Analog Input", + "instance": 1, + "name": "Bronze", + "app": ["Present_Value", "Object_Name", "Units", "Out_Of_Service"], + "enabled": [], + "writable": [], + "accepted": ["Event_State"], + "note": "REAL, degrees Celsius; starts at 21.5; read-only" + }, + { + "type": "Binary Input", + "instance": 1, + "name": "Emerald", + "app": ["Present_Value", "Object_Name", "Polarity", "Out_Of_Service"], + "enabled": [], + "writable": [], + "accepted": ["Event_State"], + "note": "starts inactive; read-only" + }, + { + "type": "Multi-state Input", + "instance": 1, + "name": "Hot Pink", + "app": ["Present_Value", "Object_Name", "Number_Of_States", "Out_Of_Service", "State_Text"], + "enabled": ["State_Text"], + "writable": [], + "accepted": ["Event_State"], + "note": "state 1 of 3: On, Off, Auto; read-only" + }, + { + "type": "Analog Output", + "instance": 1, + "name": "Chartreuse", + "app": ["Object_Name", "Units", "Out_Of_Service", "Relinquish_Default"], + "enabled": [], + "writable": ["Present_Value"], + "accepted": ["Event_State"], + "note": "REAL setpoint, default 20.0 C; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyReal/GetPropertyBool" + }, + { + "type": "Binary Output", + "instance": 1, + "name": "Fuchsia", + "app": ["Object_Name", "Out_Of_Service", "Polarity", "Relinquish_Default"], + "enabled": [], + "writable": ["Present_Value"], + "accepted": ["Event_State"], + "note": "active/inactive, default inactive; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyEnumerated/GetPropertyBool" + }, + { + "type": "Multi-state Output", + "instance": 1, + "name": "Indigo", + "app": ["Object_Name", "Out_Of_Service", "Number_Of_States", "Relinquish_Default"], + "enabled": [], + "writable": ["Present_Value"], + "accepted": ["Event_State"], + "note": "state 1 of 3, default state 1; commandable via a 16-slot Priority_Array (WriteProperty, DS-WP-B) - Present_Value/Priority_Array/Current_Command_Priority are resolved by the stack from the slots the app serves through GetPropertyUnsignedInteger/GetPropertyBool" + }, + { + "type": "Network Port", + "instance": 1, + "name": "Vermilion", + "app": ["Object_Name", "Out_Of_Service", "Network_Type", "Protocol_Level", "Changes_Pending"], + "enabled": [], + "writable": [], + "accepted": ["Reliability"], + "note": "BACnet/IP, network 1, configured (F-MULTIPORT/F-ROUTER port A). Network_Type/Protocol_Level are set from BACnetStack_AddNetworkPortObject()'s arguments at start-up (as is Network_Number = 1 / Network_Number_Quality = configured, verified readable on the wire; not listed as a separate row here because the stack's own property-profile-reference.md does not carry those two properties for the type-agnostic 'Network Port' heading this generator reads). Changes_Pending is likewise computed and answered natively. Reliability has no fault condition this example detects, so it is accepted at the generic default (normal). Routing_Table is deliberately NOT enabled - verified on the wire that the pinned stack cannot serve it outside a test-tool build (TODO.md, stack issue #2038)." + }, + { + "type": "Network Port", + "instance": 2, + "name": "Vermilion 2", + "app": ["Object_Name", "Out_Of_Service", "Network_Type", "Protocol_Level", "Changes_Pending"], + "enabled": [], + "writable": [], + "accepted": ["Reliability"], + "note": "BACnet/IP, network 2, configured (F-MULTIPORT/F-ROUTER port B). Same notes as Network Port 1 (Vermilion) apply, with Network_Number = 2. Physically routes to network 1 via BACnetStack_AddRouterPort/SetRouterEnabled, but see TODO.md and stack issue #2037: this stack pin cannot forward NPDUs between two ports of the same network type, so cross-network traffic is not verified end-to-end even though both ports individually answer ReadProperty/WriteProperty correctly." + } + ] +} diff --git a/docs/plan.md b/docs/plan.md index 0638528..7624de2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,38 +1,5 @@ -# Plan (STUB): B-RTR (Router) — C++ example +# Plan -> **STATUS: STUB.** Seed facts below. Expand from -> [`bacnet-profile-plan-template.md`](../../bacnet-profile-plan-template.md) after the -> sample plans ([B-LD](../../BACnetProfileExample-B-LD-CPP/docs/plan.md), -> [B-BC](../../BACnetProfileExample-B-BC-CPP/docs/plan.md)) are reviewed. - -**Profile:** B-RTR · **Family:** Annex L.7 (Miscellaneous) · **Role:** B · -**Archetype:** Infrastructure · **Difficulty:** 4/5 · **Phase:** B (deferred — infrastructure) - -**Thesis:** a BACnet router — forwards messages between **two BACnet networks** -(e.g. two BACnet/IP networks, or BACnet/IP ↔ MS/TP). Requires **≥2 Network Port -objects** — the largest structural deviation from the single-port baseline. - -## Required BIBBs (profiles.md L.7) -`DS-RP-B, DS-WP-B; DM-DDB-A, DM-DOB-B, DM-LM-B, NM-RC-B`. - -## Services to enable -- ReadProperty (1), WriteProperty (15), + network-layer router services. - -## Objects (baseline + ) -- A **second Network Port** on a different network number. The router forwards - between the two and answers Who-Is-Router-To-Network / I-Am-Router-To-Network. - -## Shared features -- **DEFINE:** F-ROUTER (NM-RC-B router config + DM-LM-B list manipulation; - DM-DDB-A I-Am-Router-To-Network initiate). -- **REUSE:** F-OUTPUTS (B-SA writes). - -## Known stack gaps -- Confirm the standard DLL's **routing API** and how `AddNetworkPortObject` - composes with a second network. profiles.md: ✅ S65 (286 `*Router*:*Network*` - gtests cover every Clause-6 router-config / network-layer service). - -## Notes / open questions -- Master plan §7 risk 6: the baseline has one Network Port; routing needs two and a - routing table. **Spike the multi-port + routing API before writing the plan's - code section** — this is the most structurally different B-role example. +This repository is implemented. See [`../README.md`](../README.md) for the +design, [`../TODO.md`](../TODO.md) for known gaps, and +[`../CHANGELOG.md`](../CHANGELOG.md) for the release history. diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..7d9f5c7 --- /dev/null +++ b/main.cpp @@ -0,0 +1,1021 @@ +// SPDX-License-Identifier: CC0-1.0 +// Public-domain example code (CC0) - see LICENSE. The CAS BACnet Stack itself is +// a separate, commercially licensed product and is not covered by CC0. +// ============================================================================= +// BACnet Profile Example - B-RTR (BACnet Router) - C++ +// +// This example implements the BACnet "B-RTR" (Router) device profile with the +// CAS BACnet Stack. It is the B-ASC (Application Specific Controller) example +// minus DeviceCommunicationControl, plus TWO Network Port objects and physical +// inter-datalink routing between them (ANSI/ASHRAE 135 cl. 6.6, Annex L NM-RC-B). +// +// A B-RTR is a stand-alone BACnet router: a device that forwards NPDUs between +// two (or more) directly-connected BACnet networks and answers the network-layer +// messages that let other devices discover and use it as a router: +// +// DS-RP-B - respond to ReadProperty requests, +// DS-WP-B - allow other devices to WriteProperty its objects, +// DM-DDB-A - INITIATE Who-Is (SendWhoIs) as well as answer it - a router is +// expected to actively discover devices on the networks it joins, +// not just wait to be asked, +// DM-DOB-B - answer Who-Has, +// NM-RC-B - Network Management - Router Configuration: configure router +// ports/routes, answer Who-Is-Router-To-Network with +// I-Am-Router-To-Network, and announce Network-Number-Is. +// +// plus discovery (Who-Is/I-Am, Who-Has/I-Have). This example does NOT implement +// DM-LM-B (AddListElement/RemoveListElement, services 8/9): see TODO.md - the +// customer-facing STATIC build of the pinned stack does not compile that support +// in, so it is a documented gap rather than a faked claim. +// +// The device keeps the same base objects as B-ASC/B-SA - three read-only sensor +// inputs and three commandable outputs a client drives with WriteProperty - PLUS +// a SECOND Network Port object, one per side of the router: +// +// Device 389018 "Rainbow" (instance configurable with --deviceID) +// Analog Input 1 "Bronze" (REAL, degrees Celsius; read-only) +// Binary Input 1 "Emerald" (active / inactive; read-only) +// Multi-State Input 1 "Hot Pink" (state 1..3; read-only) +// Analog Output 1 "Chartreuse" (REAL setpoint; WRITABLE, commandable) +// Binary Output 1 "Fuchsia" (active / inactive; WRITABLE, commandable) +// Multi-State Output 1 "Indigo" (state 1..3; WRITABLE, commandable) +// Network Port 1 "Vermilion" (BACnet/IP port A - network 1) +// Network Port 2 "Vermilion 2" (BACnet/IP port B - network 2) +// +// F-MULTIPORT: the two Network Ports each own their own UDP socket, via the +// common/ 2.3.0+ multi-port SetupUDP(port, networkPortInstance) / +// SendIAm(deviceInstance, networkPortInstance) overloads - see +// common/CASExampleHelper.h/.cpp for how the shared transport callbacks +// dispatch on networkPortInstance. This is the FIRST example in the series to +// use more than one Network Port; everything below is written to be a clear +// pattern for a later multi-port example (e.g. B-GW) to copy. +// +// F-ROUTER: BACnetStack_AddRouterPort registers each Network Port's directly +// connected network with the stack's router logic; BACnetStack_SetRouterEnabled +// turns forwarding on. A third, ONE-HOP-AWAY network is configured with +// BACnetStack_AddRouterRoute purely to demonstrate that API - see the +// DEMO_REMOTE_NETWORK comment below for what is and is not verified about it. +// +// Output objects are COMMANDABLE: their Present_Value is driven by a 16-slot +// BACnet Priority_Array, exactly as in B-SA/B-ASC. See GetCommandable() etc. +// below - this part is unchanged from B-ASC. +// +// To be a conformant BACnet device (Protocol_Revision 24) each object must +// expose its full set of REQUIRED properties. Most are generated by the stack +// (Object_Identifier, Object_Type, Status_Flags, Object_List, Protocol_*). A +// Network Port additionally gets Network_Number, Network_Number_Quality, +// BACnet_IP_Mode and MAC_Address from the stack once AddNetworkPortObject is +// called with the right arguments - see the callbacks below for the handful +// this application still has to supply (IP_Address, IP_Subnet_Mask, ...). +// +// Interactive keys (handled by the shared helper): h = help, q = quit, +// up/down = nudge Analog Input 1 by +/-1.1, r = re-send I-Am-Router-To-Network +// on both ports now (common/ 2.4.0+). Command line: --port (Network Port 1 / +// Vermilion), --port2 (Network Port 2 / Vermilion 2, default --port + 1), +// --deviceID . +// +// All the UDP/stack plumbing lives in common/CASExampleHelper so this file can +// stay focused on the BACnet logic. +// ============================================================================= + +#include "CASExampleHelper.h" +#include "CASBACnetStackExampleConstants.h" +#include "CASBACnetStackAdapter.h" // the CAS BACnet Stack C API (BACnetStack_*); call + // LoadBACnetFunctions() before any BACnetStack_* call - + // see the top of main() below. + +#include +#include +#include +#include + +#if defined(_WIN32) +#include // Sleep() +#else +#include // usleep() +#endif + +using namespace CASBACnetStackExampleConstants; + +// ----------------------------------------------------------------------------- +// 1. Example + device configuration +// ----------------------------------------------------------------------------- +static const char* APP_NAME = "BACnet B-RTR (Router) Example - C++"; +static const char* APP_VERSION = "1.0.0"; + +// The device instance. BACnet requires this to be configurable, so it defaults +// to 389018 (docs/colour-table.md) and can be overridden with --deviceID. +static uint32_t g_deviceInstance = 389018; + +// ---- Device identity: CHANGE ALL OF THIS BEFORE YOU SHIP -------------------- +static const uint32_t VENDOR_IDENTIFIER = 389; +static const char* DEVICE_NAME = "Rainbow"; +static const char* DEVICE_DESCRIPTION = + "Chipkin CAS BACnet Stack example - B-RTR (Router) profile. Demonstrates " + "DS-RP-B + DS-WP-B + DM-DDB-A + DM-DOB-B + NM-RC-B: a stand-alone BACnet " + "router forwarding between two BACnet/IP networks, each with its own " + "Network Port object."; +static const char* VENDOR_NAME = "Chipkin Automation Systems"; +static const char* MODEL_NAME = "CAS BACnet Stack Example - B-RTR"; +static const char* FIRMWARE_REVISION = "1.0.0"; +static const char* APPLICATION_SOFTWARE_VERSION = "1.0.0"; + +// The sensor objects (all instance 1) and their colour names - unchanged from +// B-SA/B-ASC. +static const uint32_t ANALOG_INPUT_INSTANCE = 1; // "Bronze" +static const uint32_t BINARY_INPUT_INSTANCE = 1; // "Emerald" +static const uint32_t MULTI_STATE_INPUT_INSTANCE = 1; // "Hot Pink" +static const uint32_t MULTI_STATE_INPUT_NUMBER_OF_STATES = 3; + +// ----------------------------------------------------------------------------- +// F-MULTIPORT: TWO Network Port objects, one per routed network. +// +// Both ports share the SAME host network interface (this is one process on one +// box); they are distinguished by UDP PORT NUMBER, not IP address - exactly +// like running two independent BACnet/IP devices on one machine, except the +// stack now treats them as two sides of one router instead of two devices. +// common/CASExampleHelper's SendIAm(deviceInstance, networkPortInstance) +// documents this same assumption (see its "NOTE for multi-port examples"). +// ----------------------------------------------------------------------------- +static const uint32_t NETWORK_PORT_1_INSTANCE = 1; // "Vermilion" - network A +static const uint32_t NETWORK_PORT_2_INSTANCE = 2; // "Vermilion 2" - network B +static const uint32_t MAX_APDU_LENGTH = 1476; // BACnet/IP APDU length + +// The BACnet network NUMBERS the two ports directly connect (ASHRAE 135 cl. +// 6.2.2 numbers a network, distinct from the Network Port OBJECT instance). +// Configured (not learned), so Network_Number_Quality reports CONFIGURED. +static const uint16_t NETWORK_NUMBER_PORT_1 = 1; +static const uint16_t NETWORK_NUMBER_PORT_2 = 2; + +// F-ROUTER: the router-layer "port identifier" BACnetStack_AddRouterPort and +// BACnetStack_AddRouterRoute use, as reported in the Network Port's +// Routing_Table property. This is a DELIBERATELY SEPARATE id space from the +// Network Port OBJECT instance above (see AddRouterPort's own doc comment in +// CASBACnetStackDLL.h) - they happen to share the same small integers here +// (1 <-> Network Port 1, 2 <-> Network Port 2) purely for readability; do not +// assume the stack ties them together. +static const uint8_t ROUTER_PORT_ID_1 = 1; +static const uint8_t ROUTER_PORT_ID_2 = 2; + +// BACnetStack_AddRouterPort's networkType is the STACK'S OWN internal +// datalink enum (source/BACnetPacket.h, BACnetPacket::NetworkType: IP=0, +// MSTP=1, SC=2) - NOT the Network Port object's Network_Type property +// (NETWORK_PORT_NETWORK_TYPE_IPV4 = 5, ASHRAE 135's BACnetNetworkType, used +// above with AddNetworkPortObject). Passing the object-level value here fails +// with "unknown network type" - confirmed against the pinned stack while +// building this example. Not (yet) mirrored in +// common/CASBACnetStackExampleConstants.h because it is stack-internal rather +// than a 135 standard enumeration; a later routing example (e.g. B-GW) that +// also needs it should consider promoting it there. +static const uint8_t ROUTER_NETWORK_TYPE_IP = 0; + +// DEMONSTRATION ONLY: a network reachable one hop beyond port 2, configured +// with BACnetStack_AddRouterRoute (as opposed to BACnetStack_AddRouterPort, +// which is for a DIRECTLY connected network). Nothing in this example series +// actually sits on network 3 - no device answers there - so this entry exists +// to exercise the AddRouterRoute API, NOT to be a wire-verified route. See +// README "Verification" and TODO.md for exactly what IS and is not confirmed +// on the wire (including why Routing_Table itself cannot be read back - a +// separate stack gap, not specific to this demo entry). +static const uint16_t DEMO_REMOTE_NETWORK = 3; + +// BACnet/IP addressing the Network Port reports. Both ports report the SAME +// host IP/subnet (see the comment above NETWORK_PORT_1_INSTANCE) - only the +// UDP port differs, captured per-port below. +static uint8_t g_ipAddress[4] = { 0, 0, 0, 0 }; +static uint8_t g_ipSubnetMask[4] = { 0, 0, 0, 0 }; +static uint8_t g_ipDefaultGateway[4] = { 0, 0, 0, 0 }; +static uint16_t g_bacnetIpUdpPort1 = 47808; // Network Port 1 / Vermilion +static uint16_t g_bacnetIpUdpPort2 = 47809; // Network Port 2 / Vermilion 2 (default +1) + +// Analog Input 1's live present value (degrees Celsius). Starts at 21.5 and is +// nudged by the up/down arrow keys. +static float g_analogInput1Value = 21.5f; + +// The commandable OUTPUT objects (all instance 1) and their colour names - +// unchanged from B-SA/B-ASC. +static const uint32_t ANALOG_OUTPUT_INSTANCE = 1; // "Chartreuse" +static const uint32_t BINARY_OUTPUT_INSTANCE = 1; // "Fuchsia" +static const uint32_t MULTI_STATE_OUTPUT_INSTANCE = 1; // "Indigo" +static const uint32_t MULTI_STATE_OUTPUT_NUMBER_OF_STATES = 3; +static const uint32_t BACNET_PRIORITY_ARRAY_SIZE = 16; + +struct Commandable { + bool isSet[16]; + double value[16]; + double relinquishDefault; +}; + +static Commandable g_analogOutput = { { false }, { 0 }, 20.0 }; +static Commandable g_binaryOutput = { { false }, { 0 }, 0.0 }; +static Commandable g_multiStateOutput = { { false }, { 0 }, 1.0 }; + +static uint8_t EffectivePriority(uint8_t priority) { + return (priority >= 1 && priority <= BACNET_PRIORITY_ARRAY_SIZE) ? priority : 16; +} + +static void CommandWrite(Commandable* c, uint8_t priority, double value) { + const uint8_t p = EffectivePriority(priority); + c->isSet[p - 1] = true; + c->value[p - 1] = value; +} + +static void CommandRelinquish(Commandable* c, uint8_t priority) { + const uint8_t p = EffectivePriority(priority); + c->isSet[p - 1] = false; +} + +static Commandable* GetCommandable(uint16_t objectType, uint32_t objectInstance) { + if (objectType == OBJECT_TYPE_ANALOG_OUTPUT && objectInstance == ANALOG_OUTPUT_INSTANCE) { + return &g_analogOutput; + } + if (objectType == OBJECT_TYPE_BINARY_OUTPUT && objectInstance == BINARY_OUTPUT_INSTANCE) { + return &g_binaryOutput; + } + if (objectType == OBJECT_TYPE_MULTI_STATE_OUTPUT && objectInstance == MULTI_STATE_OUTPUT_INSTANCE) { + return &g_multiStateOutput; + } + return NULL; +} + +static bool ReadPrioritySlot(const Commandable* c, uint32_t propertyIdentifier, + bool useArrayIndex, uint32_t propertyArrayIndex, + bool* slotIsSet, double* slotValue) { + if (propertyIdentifier != PROPERTY_IDENTIFIER_PRIORITY_ARRAY || !useArrayIndex || + propertyArrayIndex < 1 || propertyArrayIndex > BACNET_PRIORITY_ARRAY_SIZE) { + return false; + } + *slotIsSet = c->isSet[propertyArrayIndex - 1]; + *slotValue = c->value[propertyArrayIndex - 1]; + return true; +} + +// ----------------------------------------------------------------------------- +// 2. Property "get" callbacks - see B-ASC's main.cpp for the full essay on the +// errorCode out-parameter and the decline-and-fabricate fallback; the same +// rules apply here unchanged. The only NEW material below is serving TWO +// Network Port objects instead of one. +// ----------------------------------------------------------------------------- + +bool GetPropertyReal(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + float* value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, uint32_t* errorCode) { + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + if (objectType == OBJECT_TYPE_ANALOG_INPUT && + objectInstance == ANALOG_INPUT_INSTANCE && + propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + *value = g_analogInput1Value; + return true; + } + const Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_ANALOG_OUTPUT) { + bool slotIsSet = false; + double slotValue = 0.0; + if (ReadPrioritySlot(c, propertyIdentifier, useArrayIndex, propertyArrayIndex, + &slotIsSet, &slotValue)) { + if (!slotIsSet) { + return false; + } + *value = (float)slotValue; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_RELINQUISH_DEFAULT) { + *value = (float)c->relinquishDefault; + return true; + } + } + return false; +} + +bool GetPropertyEnumerated(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + uint32_t* value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, uint32_t* errorCode) { + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + if (objectType == OBJECT_TYPE_BINARY_INPUT && + objectInstance == BINARY_INPUT_INSTANCE) { + if (propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + *value = 0; // inactive - the series-wide starting value + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_POLARITY) { + *value = POLARITY_NORMAL; + return true; + } + } + const Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_BINARY_OUTPUT) { + bool slotIsSet = false; + double slotValue = 0.0; + if (ReadPrioritySlot(c, propertyIdentifier, useArrayIndex, propertyArrayIndex, + &slotIsSet, &slotValue)) { + if (!slotIsSet) { + return false; + } + *value = (uint32_t)slotValue; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_RELINQUISH_DEFAULT) { + *value = (uint32_t)c->relinquishDefault; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_POLARITY) { + *value = POLARITY_NORMAL; + return true; + } + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_UNITS && + ((objectType == OBJECT_TYPE_ANALOG_INPUT && objectInstance == ANALOG_INPUT_INSTANCE) || + (objectType == OBJECT_TYPE_ANALOG_OUTPUT && objectInstance == ANALOG_OUTPUT_INSTANCE))) { + *value = ENGINEERING_UNITS_DEGREES_CELSIUS; + return true; + } + // BACnet_IP_Mode: NORMAL on BOTH Network Ports - neither is a foreign + // device or a BBMD. Checking objectType only (not instance) serves every + // Network Port this device has, present or future. + if (objectType == OBJECT_TYPE_NETWORK_PORT && + propertyIdentifier == PROPERTY_IDENTIFIER_BACNET_IP_MODE) { + *value = BACNET_IP_MODE_NORMAL; + return true; + } + return false; +} + +bool GetPropertyUnsignedInteger(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + uint32_t* value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, uint32_t* errorCode) { + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + if (objectType == OBJECT_TYPE_MULTI_STATE_INPUT && + objectInstance == MULTI_STATE_INPUT_INSTANCE) { + if (propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + *value = 1; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_NUMBER_OF_STATES) { + *value = MULTI_STATE_INPUT_NUMBER_OF_STATES; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_STATE_TEXT && + useArrayIndex && propertyArrayIndex == 0) { + *value = MULTI_STATE_INPUT_NUMBER_OF_STATES; + return true; + } + } + if (objectType == OBJECT_TYPE_DEVICE && objectInstance == g_deviceInstance && + propertyIdentifier == PROPERTY_IDENTIFIER_VENDOR_IDENTIFIER) { + *value = VENDOR_IDENTIFIER; + return true; + } + // Network Port properties that are the SAME for both ports (APDU_Length, + // Reference_Port) vs the ONE that differs (BACnet_IP_UDP_Port). + if (objectType == OBJECT_TYPE_NETWORK_PORT && + (objectInstance == NETWORK_PORT_1_INSTANCE || objectInstance == NETWORK_PORT_2_INSTANCE)) { + if (propertyIdentifier == PROPERTY_IDENTIFIER_APDU_LENGTH) { + *value = MAX_APDU_LENGTH; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_REFERENCE_PORT) { + *value = NETWORK_PORT_REFERENCE_PORT_NONE; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_BACNET_IP_UDP_PORT) { + *value = (objectInstance == NETWORK_PORT_1_INSTANCE) ? g_bacnetIpUdpPort1 : g_bacnetIpUdpPort2; + return true; + } + } + const Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_MULTI_STATE_OUTPUT) { + bool slotIsSet = false; + double slotValue = 0.0; + if (ReadPrioritySlot(c, propertyIdentifier, useArrayIndex, propertyArrayIndex, + &slotIsSet, &slotValue)) { + if (!slotIsSet) { + return false; + } + *value = (uint32_t)slotValue; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_RELINQUISH_DEFAULT) { + *value = (uint32_t)c->relinquishDefault; + return true; + } + if (propertyIdentifier == PROPERTY_IDENTIFIER_NUMBER_OF_STATES) { + *value = MULTI_STATE_OUTPUT_NUMBER_OF_STATES; + return true; + } + } + return false; +} + +bool GetPropertyBool(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + bool* value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, uint32_t* errorCode) { + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + const Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && propertyIdentifier == PROPERTY_IDENTIFIER_PRIORITY_ARRAY && + useArrayIndex && propertyArrayIndex >= 1 && + propertyArrayIndex <= BACNET_PRIORITY_ARRAY_SIZE) { + *value = !c->isSet[propertyArrayIndex - 1]; + return true; + } + // Out_Of_Service: false for every input/output object AND for BOTH Network + // Ports (objectType-only check, so it covers every Network Port instance). + if (propertyIdentifier == PROPERTY_IDENTIFIER_OUT_OF_SERVICE && + (objectType == OBJECT_TYPE_ANALOG_INPUT || + objectType == OBJECT_TYPE_BINARY_INPUT || + objectType == OBJECT_TYPE_MULTI_STATE_INPUT || + objectType == OBJECT_TYPE_ANALOG_OUTPUT || + objectType == OBJECT_TYPE_BINARY_OUTPUT || + objectType == OBJECT_TYPE_MULTI_STATE_OUTPUT || + objectType == OBJECT_TYPE_NETWORK_PORT)) { + *value = false; + return true; + } + return false; +} + +// OCTET STRING - BOTH Network Ports report the SAME host IP/subnet/gateway +// (see the comment above NETWORK_PORT_1_INSTANCE): one host interface, two +// UDP ports simulating two BACnet/IP networks. +bool GetPropertyOctetString(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + uint8_t* value, uint32_t* valueElementCount, + const uint32_t maxElementCount, const bool useArrayIndex, + const uint32_t propertyArrayIndex, uint32_t* errorCode) { + (void)useArrayIndex; + (void)propertyArrayIndex; + (void)errorCode; + if (deviceInstance != g_deviceInstance || + objectType != OBJECT_TYPE_NETWORK_PORT || + (objectInstance != NETWORK_PORT_1_INSTANCE && objectInstance != NETWORK_PORT_2_INSTANCE) || + maxElementCount < 4) { + return false; + } + const uint8_t* source = NULL; + switch (propertyIdentifier) { + case PROPERTY_IDENTIFIER_IP_ADDRESS: source = g_ipAddress; break; + case PROPERTY_IDENTIFIER_IP_SUBNET_MASK: source = g_ipSubnetMask; break; + case PROPERTY_IDENTIFIER_IP_DEFAULT_GATEWAY: source = g_ipDefaultGateway; break; + default: return false; + } + memcpy(value, source, 4); + *valueElementCount = 4; + return true; +} + +static bool ReturnCharacterString(const char* text, char* value, + uint32_t* valueElementCount, + const uint32_t maxElementCount, + uint8_t* encodingType) { + uint32_t length = (uint32_t)strlen(text); + if (length > maxElementCount) { + length = maxElementCount; + } + memcpy(value, text, length); + *valueElementCount = length; + *encodingType = CHARACTER_STRING_ENCODING_UTF8; + return true; +} + +bool GetPropertyCharString(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + char* value, uint32_t* valueElementCount, + const uint32_t maxElementCount, uint8_t* encodingType, + const bool useArrayIndex, const uint32_t propertyArrayIndex, + uint32_t* errorCode) { + if (deviceInstance != g_deviceInstance) { + return false; + } + + if (objectType == OBJECT_TYPE_MULTI_STATE_INPUT && + objectInstance == MULTI_STATE_INPUT_INSTANCE && + propertyIdentifier == PROPERTY_IDENTIFIER_STATE_TEXT && useArrayIndex) { + static const char* const stateText[] = { "On", "Off", "Auto" }; + if (propertyArrayIndex >= 1 && propertyArrayIndex <= MULTI_STATE_INPUT_NUMBER_OF_STATES) { + return ReturnCharacterString(stateText[propertyArrayIndex - 1], value, + valueElementCount, maxElementCount, encodingType); + } + *errorCode = ERROR_CODE_INVALID_ARRAY_INDEX; + return false; + } + + if (propertyIdentifier == PROPERTY_IDENTIFIER_OBJECT_NAME) { + if (objectType == OBJECT_TYPE_DEVICE && objectInstance == g_deviceInstance) { + return ReturnCharacterString(DEVICE_NAME, value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_ANALOG_INPUT && objectInstance == ANALOG_INPUT_INSTANCE) { + return ReturnCharacterString("Bronze", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_BINARY_INPUT && objectInstance == BINARY_INPUT_INSTANCE) { + return ReturnCharacterString("Emerald", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_MULTI_STATE_INPUT && objectInstance == MULTI_STATE_INPUT_INSTANCE) { + return ReturnCharacterString("Hot Pink", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_ANALOG_OUTPUT && objectInstance == ANALOG_OUTPUT_INSTANCE) { + return ReturnCharacterString("Chartreuse", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_BINARY_OUTPUT && objectInstance == BINARY_OUTPUT_INSTANCE) { + return ReturnCharacterString("Fuchsia", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_MULTI_STATE_OUTPUT && objectInstance == MULTI_STATE_OUTPUT_INSTANCE) { + return ReturnCharacterString("Indigo", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_NETWORK_PORT && objectInstance == NETWORK_PORT_1_INSTANCE) { + return ReturnCharacterString("Vermilion", value, valueElementCount, maxElementCount, encodingType); + } + if (objectType == OBJECT_TYPE_NETWORK_PORT && objectInstance == NETWORK_PORT_2_INSTANCE) { + return ReturnCharacterString("Vermilion 2", value, valueElementCount, maxElementCount, encodingType); + } + } + + if (objectType == OBJECT_TYPE_DEVICE && objectInstance == g_deviceInstance) { + switch (propertyIdentifier) { + case PROPERTY_IDENTIFIER_DESCRIPTION: + return ReturnCharacterString(DEVICE_DESCRIPTION, value, valueElementCount, maxElementCount, encodingType); + case PROPERTY_IDENTIFIER_VENDOR_NAME: + return ReturnCharacterString(VENDOR_NAME, value, valueElementCount, maxElementCount, encodingType); + case PROPERTY_IDENTIFIER_MODEL_NAME: + return ReturnCharacterString(MODEL_NAME, value, valueElementCount, maxElementCount, encodingType); + case PROPERTY_IDENTIFIER_FIRMWARE_REVISION: + return ReturnCharacterString(FIRMWARE_REVISION, value, valueElementCount, maxElementCount, encodingType); + case PROPERTY_IDENTIFIER_APPLICATION_SOFTWARE_VERSION: + return ReturnCharacterString(APPLICATION_SOFTWARE_VERSION, value, valueElementCount, maxElementCount, encodingType); + default: + break; + } + } + + return false; +} + +// ----------------------------------------------------------------------------- +// 2b. Property "set" callbacks - DS-WP-B (identical to B-SA/B-ASC). +// ----------------------------------------------------------------------------- + +bool SetPropertyReal(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + const float value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, const uint8_t priority, + uint32_t* errorCode) { + (void)useArrayIndex; + (void)propertyArrayIndex; + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_ANALOG_OUTPUT && + propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + CommandWrite(c, priority, (double)value); + printf("WriteProperty: Analog Output %u (Chartreuse) <- %.2f @ priority %u\n", + objectInstance, value, EffectivePriority(priority)); + return true; + } + return false; +} + +bool SetPropertyEnumerated(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + const uint32_t value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, const uint8_t priority, + uint32_t* errorCode) { + (void)useArrayIndex; + (void)propertyArrayIndex; + if (deviceInstance != g_deviceInstance) { + return false; + } + Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_BINARY_OUTPUT && + propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + if (value > 1) { + *errorCode = ERROR_CODE_VALUE_OUT_OF_RANGE; + return false; + } + CommandWrite(c, priority, (double)value); + printf("WriteProperty: Binary Output %u (Fuchsia) <- %s @ priority %u\n", + objectInstance, value ? "active" : "inactive", EffectivePriority(priority)); + return true; + } + return false; +} + +bool SetPropertyUnsignedInteger(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + const uint32_t value, const bool useArrayIndex, + const uint32_t propertyArrayIndex, const uint8_t priority, + uint32_t* errorCode) { + (void)useArrayIndex; + (void)propertyArrayIndex; + if (deviceInstance != g_deviceInstance) { + return false; + } + Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && objectType == OBJECT_TYPE_MULTI_STATE_OUTPUT && + propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + if (value < 1 || value > MULTI_STATE_OUTPUT_NUMBER_OF_STATES) { + *errorCode = ERROR_CODE_VALUE_OUT_OF_RANGE; + return false; + } + CommandWrite(c, priority, (double)value); + printf("WriteProperty: Multi-State Output %u (Indigo) <- state %u @ priority %u\n", + objectInstance, value, EffectivePriority(priority)); + return true; + } + return false; +} + +bool SetPropertyNull(const uint32_t deviceInstance, const uint16_t objectType, + const uint32_t objectInstance, const uint32_t propertyIdentifier, + const bool useArrayIndex, const uint32_t propertyArrayIndex, + const uint8_t priority, uint32_t* errorCode) { + (void)useArrayIndex; + (void)propertyArrayIndex; + (void)errorCode; + if (deviceInstance != g_deviceInstance) { + return false; + } + Commandable* c = GetCommandable(objectType, objectInstance); + if (c != NULL && propertyIdentifier == PROPERTY_IDENTIFIER_PRESENT_VALUE) { + CommandRelinquish(c, priority); + printf("WriteProperty: relinquished %s %u @ priority %u\n", + objectType == OBJECT_TYPE_ANALOG_OUTPUT ? "Analog Output" : + objectType == OBJECT_TYPE_BINARY_OUTPUT ? "Binary Output" : + "Multi-State Output", + objectInstance, EffectivePriority(priority)); + return true; + } + return false; +} + +// ----------------------------------------------------------------------------- +// 2c. Router helpers (F-ROUTER) +// +// SendIAmRouterToNetwork/SendWhoIsRouterToNetwork/SendNetworkNumberIs all take +// a DESTINATION connection string (who to send TO), not a source. Broadcasting +// on one of our own ports means "the local subnet broadcast of THAT port's +// network" - the same address CASExampleHelper::SendIAm computes internally, +// rebuilt here because main.cpp does not have access to that private helper. +// ----------------------------------------------------------------------------- +static void BuildLocalBroadcastConnectionString(uint16_t port, uint8_t connectionString[6]) { + uint8_t bcast[4] = { 255, 255, 255, 255 }; + uint8_t ip[4], mask[4]; + if (CASExampleHelper::GetLocalIPv4(ip, mask)) { + bcast[0] = (uint8_t)(ip[0] | ~mask[0]); + bcast[1] = (uint8_t)(ip[1] | ~mask[1]); + bcast[2] = (uint8_t)(ip[2] | ~mask[2]); + bcast[3] = (uint8_t)(ip[3] | ~mask[3]); + } + connectionString[0] = bcast[0]; + connectionString[1] = bcast[1]; + connectionString[2] = bcast[2]; + connectionString[3] = bcast[3]; + connectionString[4] = (uint8_t)((port >> 8) & 0xFF); + connectionString[5] = (uint8_t)(port & 0xFF); +} + +// Broadcast I-Am-Router-To-Network on BOTH ports, each announcing this +// router's FULL configured routing table (networkNumbers = NULL, count = 0 - +// see BACnetStack_SendIAmRouterToNetwork's doc comment: since CAS BACnet Stack +// #1014 this works for a physical router too, and reports every enabled +// port's directly-connected network plus every BACnetStack_AddRouterRoute +// entry). Called once at start-up and again whenever the 'r' key is pressed. +static void AnnounceRouterToBothNetworks() { + uint8_t cs1[6]; + BuildLocalBroadcastConnectionString(g_bacnetIpUdpPort1, cs1); + const bool ok1 = BACnetStack_SendIAmRouterToNetwork( + NULL, 0, cs1, 6, NETWORK_PORT_1_INSTANCE, true, 0, NULL, 0); + + uint8_t cs2[6]; + BuildLocalBroadcastConnectionString(g_bacnetIpUdpPort2, cs2); + const bool ok2 = BACnetStack_SendIAmRouterToNetwork( + NULL, 0, cs2, 6, NETWORK_PORT_2_INSTANCE, true, 0, NULL, 0); + + printf("FYI: I-Am-Router-To-Network broadcast on Network Port 1 (Vermilion): %s; " + "on Network Port 2 (Vermilion 2): %s\n", + ok1 ? "sent" : "FAILED", ok2 ? "sent" : "FAILED"); +} + +// ----------------------------------------------------------------------------- +// 3. main() +// ----------------------------------------------------------------------------- +int main(int argc, char** argv) { + setvbuf(stdout, NULL, _IONBF, 0); + + if (!LoadBACnetFunctions()) { + fprintf(stderr, "Error: failed to load the CAS BACnet Stack: %s\n", + CASBACnetStackAdapter_LastError()); + return 1; + } + + if (CASExampleHelper::HandleHelpAndVersionArgs(argc, argv, APP_NAME, APP_VERSION)) { + return 0; + } + // --port sets Network Port 1 (Vermilion); --port2 sets Network Port 2 + // (Vermilion 2), defaulting to --port + 1 so the example runs out of the + // box with two DIFFERENT UDP ports and no extra flags. ParsePortArg (the + // shared helper) only knows the one "--port" flag, so --port2 is parsed + // locally with the same validation rule. + const uint16_t port1 = CASExampleHelper::ParsePortArg(argc, argv, 47808); + uint16_t port2 = (uint16_t)(port1 + 1); + for (int i = 1; i + 1 < argc; ++i) { + if (strcmp(argv[i], "--port2") == 0) { + char* end = NULL; + errno = 0; + const long p = strtol(argv[i + 1], &end, 10); + if (errno == 0 && end != argv[i + 1] && *end == '\0' && p > 0 && p <= 65535) { + port2 = (uint16_t)p; + } else { + printf("Warning: ignoring invalid --port2 \"%s\" (want 1..65535); using %u.\n", + argv[i + 1], (unsigned)port2); + } + } + } + if (port2 == port1) { + fprintf(stderr, "Error: --port2 (%u) must differ from --port (%u) - they are two " + "separate BACnet/IP networks on this router.\n", + (unsigned)port2, (unsigned)port1); + return 1; + } + g_deviceInstance = CASExampleHelper::ParseDeviceIdArg(argc, argv, g_deviceInstance); + CASExampleHelper::PrintVersion(APP_NAME, APP_VERSION); + + // --- Bind BOTH BACnet/IP sockets (F-MULTIPORT) -------------------------- + // SetNetworkPortInstance first, so the single-argument SetupUDP(port1) and + // (later) SendIAm(deviceInstance) act on Network Port 1 by default; port 2 + // is bound with the explicit two-argument overload. + CASExampleHelper::SetNetworkPortInstance(NETWORK_PORT_1_INSTANCE); + if (!CASExampleHelper::SetupUDP(port1)) { + return 1; + } + if (!CASExampleHelper::SetupUDP(port2, NETWORK_PORT_2_INSTANCE)) { + return 1; + } + g_bacnetIpUdpPort1 = port1; + g_bacnetIpUdpPort2 = port2; + if (!CASExampleHelper::GetLocalIPv4(g_ipAddress, g_ipSubnetMask)) { + printf("FYI: could not read a local IPv4 address; both Network Ports will report " + "IP_Address 0.0.0.0.\n"); + } + + // --- Register callbacks ------------------------------------------------- + // One set of transport/time callbacks serves BOTH ports - common/ 2.3.0+ + // dispatches on networkPortInstance internally (see CASExampleHelper.cpp). + CASExampleHelper::RegisterCommonCallbacks(); + BACnetStack_RegisterCallbackGetPropertyReal(GetPropertyReal); + BACnetStack_RegisterCallbackGetPropertyEnumerated(GetPropertyEnumerated); + BACnetStack_RegisterCallbackGetPropertyUnsignedInteger(GetPropertyUnsignedInteger); + BACnetStack_RegisterCallbackGetPropertyCharacterString(GetPropertyCharString); + BACnetStack_RegisterCallbackGetPropertyBool(GetPropertyBool); + BACnetStack_RegisterCallbackGetPropertyOctetString(GetPropertyOctetString); + BACnetStack_RegisterCallbackSetPropertyReal(SetPropertyReal); + BACnetStack_RegisterCallbackSetPropertyEnumerated(SetPropertyEnumerated); + BACnetStack_RegisterCallbackSetPropertyUnsignedInteger(SetPropertyUnsignedInteger); + BACnetStack_RegisterCallbackSetPropertyNull(SetPropertyNull); + // NOTE: no DeviceCommunicationControl callback - this profile (unlike + // B-ASC, its seed) does not include DM-DCC-B. + + // --- Create the device -------------------------------------------------- + if (!BACnetStack_AddDevice(g_deviceInstance)) { + printf("Error: Failed to add the Device %u.\n", g_deviceInstance); + return 1; + } + + // DS-RP-B, DS-WP-B: ReadProperty, WriteProperty. + if (!BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_READ_PROPERTY, true)) { + printf("Error: Failed to enable the ReadProperty service.\n"); + return 1; + } + if (!BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_WRITE_PROPERTY, true)) { + printf("Error: Failed to enable the WriteProperty service.\n"); + return 1; + } + // DM-DDB-A/B, DM-DOB-B: Who-Is/I-Am (both directions - DM-DDB-A means this + // device also INITIATES Who-Is, not just answers it) and Who-Has/I-Have. + // NM-RC-B's network-layer messages (Who-Is-Router-To-Network, + // I-Am-Router-To-Network, Network-Number-Is) are NOT gated by + // SetServiceEnabled - they are enabled purely by BACnetStack_AddRouterPort + // + BACnetStack_SetRouterEnabled below. + if (!BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_WHO_IS, true) || + !BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_I_AM, true) || + !BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_WHO_HAS, true) || + !BACnetStack_SetServiceEnabled(g_deviceInstance, SERVICE_I_HAVE, true)) { + printf("Error: Failed to enable the discovery services (Who-Is/I-Am, Who-Has/I-Have).\n"); + return 1; + } + + // --- Add the read-only sensor objects ----------------------------------- + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_ANALOG_INPUT, ANALOG_INPUT_INSTANCE)) { + printf("Error: Failed to add Analog Input %u (Bronze).\n", ANALOG_INPUT_INSTANCE); + return 1; + } + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_BINARY_INPUT, BINARY_INPUT_INSTANCE)) { + printf("Error: Failed to add Binary Input %u (Emerald).\n", BINARY_INPUT_INSTANCE); + return 1; + } + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_MULTI_STATE_INPUT, MULTI_STATE_INPUT_INSTANCE)) { + printf("Error: Failed to add Multi-State Input %u (Hot Pink).\n", MULTI_STATE_INPUT_INSTANCE); + return 1; + } + + // --- Add the commandable OUTPUT objects ---------------------------------- + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_ANALOG_OUTPUT, ANALOG_OUTPUT_INSTANCE)) { + printf("Error: Failed to add Analog Output %u (Chartreuse).\n", ANALOG_OUTPUT_INSTANCE); + return 1; + } + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_BINARY_OUTPUT, BINARY_OUTPUT_INSTANCE)) { + printf("Error: Failed to add Binary Output %u (Fuchsia).\n", BINARY_OUTPUT_INSTANCE); + return 1; + } + if (!BACnetStack_AddObject(g_deviceInstance, OBJECT_TYPE_MULTI_STATE_OUTPUT, MULTI_STATE_OUTPUT_INSTANCE)) { + printf("Error: Failed to add Multi-State Output %u (Indigo).\n", MULTI_STATE_OUTPUT_INSTANCE); + return 1; + } + + // --- Add BOTH Network Port objects (F-MULTIPORT) ------------------------- + // Each one is BACNET_APPLICATION level, so it gets a Network_Number and + // Network_Number_Quality; we pass CONFIGURED, since a router locally + // configures the number of each network it joins rather than learning it. + if (!BACnetStack_AddNetworkPortObject( + g_deviceInstance, NETWORK_PORT_1_INSTANCE, + NETWORK_PORT_NETWORK_TYPE_IPV4, + NETWORK_PORT_PROTOCOL_LEVEL_BACNET_APPLICATION, + NETWORK_NUMBER_PORT_1, + NETWORK_NUMBER_QUALITY_CONFIGURED, + NETWORK_PORT_REFERENCE_PORT_NONE)) { + printf("Error: Failed to add Network Port 1 (Vermilion).\n"); + return 1; + } + if (!BACnetStack_AddNetworkPortObject( + g_deviceInstance, NETWORK_PORT_2_INSTANCE, + NETWORK_PORT_NETWORK_TYPE_IPV4, + NETWORK_PORT_PROTOCOL_LEVEL_BACNET_APPLICATION, + NETWORK_NUMBER_PORT_2, + NETWORK_NUMBER_QUALITY_CONFIGURED, + NETWORK_PORT_REFERENCE_PORT_NONE)) { + printf("Error: Failed to add Network Port 2 (Vermilion 2).\n"); + return 1; + } + + // --- Enable the OPTIONAL properties we choose to expose ------------------ + if (!BACnetStack_SetPropertyEnabled(g_deviceInstance, OBJECT_TYPE_DEVICE, + g_deviceInstance, PROPERTY_IDENTIFIER_DESCRIPTION, true)) { + printf("Error: Failed to enable Description on the Device object.\n"); + return 1; + } + if (!BACnetStack_SetPropertyEnabled(g_deviceInstance, OBJECT_TYPE_MULTI_STATE_INPUT, + MULTI_STATE_INPUT_INSTANCE, PROPERTY_IDENTIFIER_STATE_TEXT, true)) { + printf("Error: Failed to enable State_Text on Multi-State Input 1 (Hot Pink).\n"); + return 1; + } + // Routing_Table (cl. 12.56.62) is INTENTIONALLY NOT enabled here. + // + // BACnetStack_AddNetworkPortObject's doc comment says it is served by the + // stack "if you enable them" - but verified against the pinned stack + // (abd4cee1) with a live client, enabling it and then reading it aborts + // the request: BACnetBusinessLogic::UseCallbackGetList has no case for + // Network Port/Routing_Table outside the forbidden A-side test-tool surface, so the + // read always fails to encode. Enabling a property that is guaranteed to + // abort on every read is worse than not claiming it at all - see TODO.md + // and https://github.com/chipkin/cas-bacnet-stack/issues/2038. + + // --- Make the output objects commandable -------------------------------- + struct CommandableObject { uint16_t type; uint32_t instance; }; + const CommandableObject outputs[] = { + { OBJECT_TYPE_ANALOG_OUTPUT, ANALOG_OUTPUT_INSTANCE }, + { OBJECT_TYPE_BINARY_OUTPUT, BINARY_OUTPUT_INSTANCE }, + { OBJECT_TYPE_MULTI_STATE_OUTPUT, MULTI_STATE_OUTPUT_INSTANCE }, + }; + for (size_t i = 0; i < sizeof(outputs) / sizeof(outputs[0]); ++i) { + if (!BACnetStack_SetPropertyEnabled(g_deviceInstance, outputs[i].type, outputs[i].instance, + PROPERTY_IDENTIFIER_PRIORITY_ARRAY, true) || + !BACnetStack_SetPropertyEnabled(g_deviceInstance, outputs[i].type, outputs[i].instance, + PROPERTY_IDENTIFIER_RELINQUISH_DEFAULT, true) || + !BACnetStack_SetPropertyWritable(g_deviceInstance, outputs[i].type, outputs[i].instance, + PROPERTY_IDENTIFIER_PRESENT_VALUE, true)) { + printf("Error: Failed to make object type %u instance %u commandable.\n", + outputs[i].type, outputs[i].instance); + return 1; + } + } + + // --- F-ROUTER: configure and enable physical routing --------------------- + // AddRouterPort registers each DIRECTLY connected network (one call per + // Network Port that participates - see its doc comment: "two or more + // ports make the device a router"). + if (!BACnetStack_AddRouterPort(g_deviceInstance, ROUTER_PORT_ID_1, + NETWORK_NUMBER_PORT_1, ROUTER_NETWORK_TYPE_IP)) { + printf("Error: Failed to register router port 1 (network %u, Vermilion).\n", + (unsigned)NETWORK_NUMBER_PORT_1); + return 1; + } + if (!BACnetStack_AddRouterPort(g_deviceInstance, ROUTER_PORT_ID_2, + NETWORK_NUMBER_PORT_2, ROUTER_NETWORK_TYPE_IP)) { + printf("Error: Failed to register router port 2 (network %u, Vermilion 2).\n", + (unsigned)NETWORK_NUMBER_PORT_2); + return 1; + } + // AddRouterRoute: a static route to DEMO_REMOTE_NETWORK, one hop beyond + // port 2, with no next-hop MAC configured (NULL/0 - "the relay falls back + // to the egress link broadcast" per the doc comment). DEMONSTRATION ONLY: + // see the DEMO_REMOTE_NETWORK comment above - nothing actually answers on + // this network in this example series, and Routing_Table cannot be read + // back at this pin (TODO.md), so this call demonstrates the AddRouterRoute + // API accepting the configuration, not an end-to-end verified route. + if (!BACnetStack_AddRouterRoute(g_deviceInstance, DEMO_REMOTE_NETWORK, + ROUTER_PORT_ID_2, NULL, 0)) { + printf("Error: Failed to configure the demonstration route to network %u.\n", + (unsigned)DEMO_REMOTE_NETWORK); + return 1; + } + // Routing is opt-in even with ports configured (SetRouterEnabled's doc + // comment: "configuring ports alone does not start forwarding"). + if (!BACnetStack_SetRouterEnabled(g_deviceInstance, true)) { + printf("Error: Failed to enable routing. (Built without STACK_OPTION_ROUTER_PHYSICAL?)\n"); + return 1; + } + + // Who-Is is answered automatically on both ports (DM-DDB-B). Broadcast an + // unsolicited I-Am on EACH network this device is on (DM-DDB-A/cl. 16.1 - + // one I-Am per network the device is directly reachable from). + CASExampleHelper::SendIAm(g_deviceInstance, NETWORK_PORT_1_INSTANCE); + CASExampleHelper::SendIAm(g_deviceInstance, NETWORK_PORT_2_INSTANCE); + + // NM-RC-B: announce this router to both of its networks (I-Am-Router-To- + // Network) and each network's own number (Network-Number-Is), and probe + // each network for any OTHER router already present (Who-Is-Router-To- + // Network, unrestricted query) - the polite router start-up sequence per + // cl. 6.6.2. No other router is expected to answer in this example. + AnnounceRouterToBothNetworks(); + { + uint8_t cs1[6], cs2[6]; + BuildLocalBroadcastConnectionString(g_bacnetIpUdpPort1, cs1); + BuildLocalBroadcastConnectionString(g_bacnetIpUdpPort2, cs2); + BACnetStack_SendNetworkNumberIs(NETWORK_NUMBER_PORT_1, NETWORK_NUMBER_QUALITY_CONFIGURED, + cs1, 6, NETWORK_PORT_1_INSTANCE, true, 0, NULL, 0); + BACnetStack_SendNetworkNumberIs(NETWORK_NUMBER_PORT_2, NETWORK_NUMBER_QUALITY_CONFIGURED, + cs2, 6, NETWORK_PORT_2_INSTANCE, true, 0, NULL, 0); + BACnetStack_SendWhoIsRouterToNetwork(0, false, cs1, 6, NETWORK_PORT_1_INSTANCE, true, 0, NULL, 0); + BACnetStack_SendWhoIsRouterToNetwork(0, false, cs2, 6, NETWORK_PORT_2_INSTANCE, true, 0, NULL, 0); + } + + printf("FYI: Device %u (\"%s\") ready. Vendor ID %u. Routing network %u (Vermilion, UDP %u) " + "<-> network %u (Vermilion 2, UDP %u). Press 'h' for help.\n", + g_deviceInstance, DEVICE_NAME, VENDOR_IDENTIFIER, + (unsigned)NETWORK_NUMBER_PORT_1, (unsigned)g_bacnetIpUdpPort1, + (unsigned)NETWORK_NUMBER_PORT_2, (unsigned)g_bacnetIpUdpPort2); + + // --- Run the stack ------------------------------------------------------ + bool running = true; + while (running) { + BACnetStack_Tick(); + + switch (CASExampleHelper::PollKey()) { + case CASExampleHelper::KeyCommand::Help: + CASExampleHelper::PrintHelp(APP_NAME, APP_VERSION); + break; + case CASExampleHelper::KeyCommand::Quit: + running = false; + break; + case CASExampleHelper::KeyCommand::ArrowUp: + g_analogInput1Value += 1.1f; + printf("Analog Input 1 (Bronze) = %.1f C\n", g_analogInput1Value); + break; + case CASExampleHelper::KeyCommand::ArrowDown: + g_analogInput1Value -= 1.1f; + printf("Analog Input 1 (Bronze) = %.1f C\n", g_analogInput1Value); + break; + case CASExampleHelper::KeyCommand::RouterAnnounce: + AnnounceRouterToBothNetworks(); + break; + case CASExampleHelper::KeyCommand::None: + default: + break; + } + +#if defined(_WIN32) + Sleep(1); +#else + usleep(1000); +#endif + } + + CASExampleHelper::RestoreInput(); + CASExampleHelper::ShutdownUDP(); + return 0; +} diff --git a/submodules/cas-bacnet-stack b/submodules/cas-bacnet-stack new file mode 160000 index 0000000..abd4cee --- /dev/null +++ b/submodules/cas-bacnet-stack @@ -0,0 +1 @@ +Subproject commit abd4cee1c7f28ca8e1af4720849c4081082bbe82