Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}" <<EOF
{
"example": "BACnetExampleBRTR",
"app_version": "$appver",
"os": "${{ matrix.os }}",
"link_mode": "STATIC",
"stack_commit": "${{ steps.stack.outputs.sha }}",
"stack_version": "$stackver",
"binary_bytes": $bytes,
"binary_sha256_prefix": "$sha",
"startup_ms_to_ready": $ready_ms,
"compiler": "$compiler",
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"git_ref": "${{ github.ref_name }}"
}
EOF
cat "${{ matrix.metrics }}"
{ echo '### metrics (${{ matrix.os }})'; echo '```json'; cat "${{ matrix.metrics }}"; echo '```'; } >> "$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/**/*
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# CMake build output
build/

build-static/
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# AGENTS.md

Guidance for AI coding agents working in this repository. See
<https://agents.md/> 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.
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading