diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f69984..40fe22b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,8 +160,26 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts + # Every platform artifact carries the same docs (README.md, LICENSE, ...). + # Uploading two assets with one name makes the release action fail part-way + # and leaves a draft release behind (a real tagged release hit this). Flatten the + # artifacts into one folder with a single copy of each file name, and stop + # if two files share a name but differ. + - name: Collect release assets (one copy of each file name) + shell: bash + run: | + mkdir -p release + find artifacts -type f | sort | while read -r f; do + name=$(basename "$f") + if [ -e "release/$name" ]; then + cmp -s "$f" "release/$name" || { echo "FAIL: two different files are both named $name ($f)"; exit 1; } + else + cp "$f" "release/$name" + fi + done + ls -l release - uses: softprops/action-gh-release@v2 with: name: ${{ github.ref_name }} generate_release_notes: true - files: artifacts/**/* + files: release/* diff --git a/AGENTS.md b/AGENTS.md index b98a043..327807b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ TUTORIAL.md "Interactive commands are a simplification"): `h` help, `q` quit, ## Conventions -- Device is named "Rainbow"; objects use the series' colour names; vendor id +- Device is named "Chipkin Example B-SS"; objects use the series' colour names; vendor id 389. - Implement **only** the services and objects the B-SS profile requires - but expose **every required property** of each object. diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd0554..7ff61bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ 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.2] - unreleased + +### Changed + +- **Device renamed from the series' colour placeholder "Rainbow" to "Chipkin + Example B-SS"** so devices from different examples in the series are + distinguishable from each other on the same BACnet network - every example + previously announced the identical Object_Name "Rainbow", which made two + examples on one subnet indistinguishable by name. Sub-object names are + unchanged. `docs/colour-table.md` (series root) updated to match. + APP_VERSION bumped 1.0.1 -> 1.0.2. + +## [1.0.1] - 2026-09-22 + +### Fixed + +- `Application_Software_Version` (12) and `Firmware_Revision` (44) were + hardcoded `"1.0.0"` constants that never tracked reality - the same class + of bug just found and fixed in the sibling C++ example + (BACnetProfileExample-B-SCHUB-CPP) by someone reading a real device with + CAS BACnet Explorer and noticing the reported version didn't match the + running build. `Application_Software_Version` now reads `APP_VERSION` + directly (the same constant `--version`'s own banner uses - one source of + truth, can't drift again). `Firmware_Revision` is now built once at + start-up, in `main()` right after the native library loads, from the CAS + BACnet Stack's own `BACnetStack_GetAPIMajorVersion`/`MinorVersion`/ + `PatchVersion`/`BuildVersion` - the same four calls + `cas_example_helper::print_version` already uses for the start-up banner - + and stored in `STATE.firmware_revision` (the existing + `once_cell`-backed `Mutex` pattern already used for + start-up-resolved fields like the Network Port's IP addressing). + `Firmware_Revision` was never meant to be this example's own version; it + names the platform underneath the app. + ## [1.0.0] - 2026-09-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5280226..794e18d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "bacnet-profile-example-b-ss-rust" -version = "1.0.0" +version = "1.0.2" dependencies = [ "ctrlc", "libloading", diff --git a/Cargo.toml b/Cargo.toml index e8a2137..598f8d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bacnet-profile-example-b-ss-rust" -version = "1.0.0" +version = "1.0.2" edition = "2021" description = "BACnet B-SS (Smart Sensor) profile example - Rust, built on the CAS BACnet Stack" license = "CC0-1.0" diff --git a/README.md b/README.md index e5d96e5..b1be8e0 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ and who answers it. ## The device this example creates ``` -Device 389001 "Rainbow" (Vendor 389 - Chipkin Automation Systems) +Device 389001 "Chipkin Example B-SS" (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 (read-only) @@ -88,7 +88,7 @@ which is the point of a profile example. | Object type | Instance | Name | |-------------|:--------:|------| -| Device | 389001 | Rainbow | +| Device | 389001 | Chipkin Example B-SS | | Analog Input | 1 | Bronze | | Binary Input | 1 | Emerald | | Multi-state Input | 1 | Hot Pink | @@ -232,7 +232,7 @@ Expected output: BACnet B-SS (Smart Sensor) Example - Rust v1.0.0 (common v1.0.0) CAS BACnet Stack v6.0.21.0 FYI: listening on :47808 (broadcast ) -FYI: Device 389001 ("Rainbow") ready. Vendor ID 389. Type 'h' + Enter for help. +FYI: Device 389001 ("Chipkin Example B-SS") ready. Vendor ID 389. Type 'h' + Enter for help. ``` The device listens on UDP **47808** (BACnet/IP) by default. Allow that port @@ -276,10 +276,10 @@ Use a BACnet client such as the instance **389001** (vendor **389**). It also broadcasts an I-Am at start-up. 2. **Browse the object model** - the device shows five objects: the Device - (`Rainbow`), the three sensors, and the Network Port (`Vermilion`). Reading + (`Chipkin Example B-SS`), the three sensors, and the Network Port (`Vermilion`). Reading the Device's `Object_List` returns all five. 3. **Read the Device** - ReadProperty `389001` -> `Object_Name` returns - `"Rainbow"`; `Protocol_Revision` returns `24`; `Description` returns the + `"Chipkin Example B-SS"`; `Protocol_Revision` returns `24`; `Description` returns the profile description string. 4. **Read a sensor** - ReadProperty Analog Input `1` -> `Present_Value` returns `21.5`; `Units` returns `degrees-Celsius`; `Out_Of_Service` returns diff --git a/docs/PICS.md b/docs/PICS.md index 6c4761c..6137876 100644 --- a/docs/PICS.md +++ b/docs/PICS.md @@ -82,7 +82,7 @@ is writable. | Object type | Instance | Object_Name | Optional properties supported | |---|:---:|---|---| -| Device | 389001 | Rainbow | Description | +| Device | 389001 | Chipkin Example B-SS | Description | | Analog Input | 1 | Bronze | - | | Binary Input | 1 | Emerald | - | | Multi-State Input | 1 | Hot Pink | State_Text | @@ -119,7 +119,7 @@ handle data in all character sets. 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 `get_property_*` callback in `main.rs`. 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 389001 "Rainbow" - the device itself; the instance is configurable with --deviceID. The stack rows are device-wide facts only the stack knows - the protocol version and revision it implements, the services and object types it was configured with, the live object list and address-binding table. The accepted rows are the stack's configured defaults for APDU limits, segmentation, system status and database revision; an application that answered them from its own constants could contradict the stack, so this example does not +### Device 389001 "Chipkin Example B-SS" - the device itself; the instance is configurable with --deviceID. The stack rows are device-wide facts only the stack knows - the protocol version and revision it implements, the services and object types it was configured with, the live object list and address-binding table. The accepted rows are the stack's configured defaults for APDU limits, segmentation, system status and database revision; an application that answered them from its own constants could contradict the stack, so this example does not | Property | Datatype | Served by | Writable | |---|---|---|:---:| diff --git a/docs/objects.json b/docs/objects.json index 75259ab..784a33e 100644 --- a/docs/objects.json +++ b/docs/objects.json @@ -3,7 +3,7 @@ { "type": "Device", "instance": 389001, - "name": "Rainbow", + "name": "Chipkin Example B-SS", "app": [ "Object_Name", "Description", diff --git a/src/common/device_state.rs b/src/common/device_state.rs index 630554a..39d8e48 100644 --- a/src/common/device_state.rs +++ b/src/common/device_state.rs @@ -53,10 +53,21 @@ pub struct DeviceState { pub ip_address: [u8; 4], pub ip_subnet_mask: [u8; 4], pub ip_default_gateway: [u8; 4], + + /// The Device object's Firmware_Revision (property 44) - the underlying + /// CAS BACnet Stack's own version, NOT this example's version. Built + /// once at start-up (in `main()`, right after the native library loads + /// successfully) from `BACnetStack_GetAPIMajorVersion`/`MinorVersion`/ + /// `PatchVersion`/`BuildVersion` - the same four calls + /// `cas_example_helper::print_version` already uses for the start-up + /// banner. Empty until then; a `Get*Property` callback should never + /// observe it empty in practice because `main()` sets it before + /// registering any callback or adding the Device object. + pub firmware_revision: String, } impl DeviceState { - const fn new() -> Self { + fn new() -> Self { DeviceState { device_instance: 389001, bacnet_ip_udp_port: 47808, @@ -64,6 +75,7 @@ impl DeviceState { ip_address: [0, 0, 0, 0], ip_subnet_mask: [0, 0, 0, 0], ip_default_gateway: [0, 0, 0, 0], + firmware_revision: String::new(), } } } diff --git a/src/main.rs b/src/main.rs index 3c630d3..f573328 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,7 +54,7 @@ // object that every BACnet device must have. Each object is given a colour name // (a convention shared across this example series): // -// Device 389001 "Rainbow" (instance configurable with --deviceID) +// Device 389001 "Chipkin Example B-SS" (instance configurable with --deviceID) // Analog Input 1 "Bronze" (REAL, degrees Celsius; starts at 21.5) // Binary Input 1 "Emerald" (active / inactive) // Multi-State Input 1 "Hot Pink" (an enumerated state, 1..3) @@ -98,7 +98,7 @@ use common::device_state::STATE; // 1. Example + device configuration // ----------------------------------------------------------------------------- const APP_NAME: &str = "BACnet B-SS (Smart Sensor) Example - Rust"; -const APP_VERSION: &str = "1.0.0"; +const APP_VERSION: &str = "1.0.2"; // ---- Device identity: CHANGE ALL OF THIS BEFORE YOU SHIP -------------------- // Everything in this block is read by clients and shown to the operator in @@ -120,11 +120,11 @@ const VENDOR_IDENTIFIER: u32 = 389; // whole BACnet internetwork, and here it is a COMPILE-TIME constant. The // device instance is runtime-configurable via --deviceID, so it is easy to // ship two units, configure their instances correctly, and still have BOTH -// announce Object_Name "Rainbow" - a spec violation, and a hard BTL failure. +// announce Object_Name "Chipkin Example B-SS" - a spec violation, and a hard BTL failure. // In a real product Object_Name must be per-unit configurable too: derive it // from a serial number, DIP switches, a config file, or add a --deviceName // argument. -const DEVICE_NAME: &str = "Rainbow"; +const DEVICE_NAME: &str = "Chipkin Example B-SS"; // The Device object's Description. Change it to what YOUR device actually is; // this string describes this tutorial. @@ -134,8 +134,16 @@ const DEVICE_DESCRIPTION: &str = "Chipkin CAS BACnet Stack example - B-SS (Smart // Device identity strings (read by clients, and used to populate I-Am). const VENDOR_NAME: &str = "Chipkin Automation Systems"; const MODEL_NAME: &str = "CAS BACnet Stack Example - B-SS"; -const FIRMWARE_REVISION: &str = "1.0.0"; -const APPLICATION_SOFTWARE_VERSION: &str = "1.0.0"; +// Firmware_Revision (44) and Application_Software_Version (12) are NOT +// consts: they must track reality, not a value someone typed once and +// forgot. Application_Software_Version is this example's own APP_VERSION +// (declared above) - the one source of truth also used by --version's +// banner. Firmware_Revision names the platform underneath this app, not +// the app itself: it is the CAS BACnet Stack's own version, computed once +// at start-up into STATE.firmware_revision (see device_state.rs) from the +// stack's BACnetStack_GetAPIMajorVersion/Minor/Patch/BuildVersion calls - +// the same four calls cas_example_helper::print_version already makes for +// the start-up banner. // The sensor objects (all instance 1) and their colour names. const ANALOG_INPUT_INSTANCE: u32 = 1; // "Bronze" @@ -532,9 +540,9 @@ extern "C" fn get_property_character_string( } else if property_identifier == c::PROPERTY_IDENTIFIER_MODEL_NAME { Some(MODEL_NAME) } else if property_identifier == c::PROPERTY_IDENTIFIER_FIRMWARE_REVISION { - Some(FIRMWARE_REVISION) + Some(state.firmware_revision.as_str()) } else if property_identifier == c::PROPERTY_IDENTIFIER_APPLICATION_SOFTWARE_VERSION { - Some(APPLICATION_SOFTWARE_VERSION) + Some(APP_VERSION) } else { None }; @@ -708,6 +716,23 @@ fn main() -> std::process::ExitCode { return std::process::ExitCode::SUCCESS; } + // Firmware_Revision (44) is the CAS BACnet Stack's own version, not this + // example's - see the comment above FIRMWARE_REVISION's old const + // (removed) near APP_VERSION. Compute it once here, now that the native + // library has definitely loaded (the print_version() call above forced + // that), using the same four API calls print_version() uses for the + // start-up banner. + { + let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.firmware_revision = format!( + "{}.{}.{}.{}", + bacnet::BACnetStack_GetAPIMajorVersion().unwrap_or(0), + bacnet::BACnetStack_GetAPIMinorVersion().unwrap_or(0), + bacnet::BACnetStack_GetAPIPatchVersion().unwrap_or(0), + bacnet::BACnetStack_GetAPIBuildVersion().unwrap_or(0), + ); + } + // --- Bind the BACnet/IP socket ------------------------------------------- if let Err(e) = cas_example_helper::setup_udp(port) { eprintln!("Error: could not bind UDP port {port}: {e}");