diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f51d31..48c5c34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,8 +152,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 5dbddcc..c0de509 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ up/down nudge Analog Input 1. ## 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 62c5595..6d81b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ 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] - unreleased + +### Fixed + +- `Application_Software_Version` (12) and `Firmware_Revision` (44) were + hardcoded to the literal string `"1.0.0"` in `main.py` and never updated as + the example's real version advanced - the same class of bug found by + someone testing [BACnetProfileExample-B-SCHUB-CPP](https://github.com/chipkin/BACnetProfileExample-B-SCHUB-CPP) + against a real BACnet client (CAS BACnet Explorer), where the reported + version didn't match the running build. `Firmware_Revision` was never + meant to be this example's own version at all - it names the underlying + platform. Fixed: `Application_Software_Version` now reads `APP_VERSION` + directly (one source of truth, can't drift from `--version`'s own banner + again). `Firmware_Revision` is now built once at start-up, right after + `bacnet.bind(library)` succeeds in `main()`, from the CAS BACnet Stack's + own `BACnetStack_GetAPIMajorVersion()`/`GetAPIMinorVersion()`/ + `GetAPIPatchVersion()`/`GetAPIBuildVersion()` - the same 4 calls + `cas_example_helper.print_version()` already uses for the startup banner - + and stored in `g_firmware_revision` for every later ReadProperty. Verified + with a real ReadProperty (via `bacpypes3`) against the running device: + `Application_Software_Version` read back `"1.0.1"` (matching `APP_VERSION`, + this release) and `Firmware_Revision` read back `"6.0.21.0"` (matching the + stack's own `--version` banner). + ## [1.0.0] - unreleased ### Added diff --git a/README.md b/README.md index f2071c1..ae10ae0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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) @@ -83,7 +83,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 | @@ -210,7 +210,7 @@ Expected output: BACnet B-SS (Smart Sensor) Example - Python v1.0.0 CAS BACnet Stack v6.0.21.0 FYI: listening on :47808 (broadcast ) -FYI: Device 389001 ("Rainbow") ready. Vendor ID 389. Press 'h' for help. +FYI: Device 389001 ("Chipkin Example B-SS") ready. Vendor ID 389. Press 'h' for help. ``` The device listens on UDP **47808** (BACnet/IP) by default. Allow that port @@ -251,10 +251,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 282fdfc..d0494d9 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.py`. 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/main.py b/main.py index 172daed..d2892b0 100644 --- a/main.py +++ b/main.py @@ -45,7 +45,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 @@ # 1. Example + device configuration # ----------------------------------------------------------------------------- APP_NAME = "BACnet B-SS (Smart Sensor) Example - Python" -APP_VERSION = "1.0.0" +APP_VERSION = "1.0.2" # The device instance. BACnet requires this to be configurable, so it defaults # to 389001 and can be overridden on the command line with --deviceID. Keep it @@ -128,11 +128,11 @@ # whole BACnet internetwork, and here it is a COMPILE-TIME (well, module-load- # 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 +# still have BOTH 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. -DEVICE_NAME = "Rainbow" +DEVICE_NAME = "Chipkin Example B-SS" # The Device object's Description. Change it to what YOUR device actually is; # this string describes this tutorial. @@ -145,13 +145,19 @@ # VENDOR_NAME - your company name; it must match VENDOR_IDENTIFIER above. # MODEL_NAME - your model designation. This is what a building operator # reads to identify your device in a discovery tool. -# FIRMWARE_REVISION / APPLICATION_SOFTWARE_VERSION - your real versions. -# Wire them to your build rather than hard-coding a number -# that will go stale. +# Application_Software_Version (12) / Firmware_Revision (44) - read by +# clients via the get_property_character_string callback below. +# Application_Software_Version is this example's OWN version: it reads +# APP_VERSION directly, so it can never drift from what --version prints. +# Firmware_Revision names the PLATFORM underneath this app, not the app +# itself - it is built at runtime from the CAS BACnet Stack's own +# BACnetStack_GetAPI{Major,Minor,Patch,Build}Version() calls (the same 4 +# calls cas_example_helper.print_version() already uses for the startup +# banner) into g_firmware_revision, once, right after the stack loads +# successfully in main() - see "Load the CAS BACnet Stack" below. VENDOR_NAME = "Chipkin Automation Systems" MODEL_NAME = "CAS BACnet Stack Example - B-SS" -FIRMWARE_REVISION = "1.0.0" -APPLICATION_SOFTWARE_VERSION = "1.0.0" +g_firmware_revision = None # The sensor objects (all instance 1) and their colour names. ANALOG_INPUT_INSTANCE = 1 # "Bronze" @@ -457,10 +463,10 @@ def get_property_character_string(device_instance, object_type, object_instance, return _return_character_string(MODEL_NAME, value, value_element_count, max_element_count, encoding_type) if property_identifier == c.PROPERTY_IDENTIFIER_FIRMWARE_REVISION: - return _return_character_string(FIRMWARE_REVISION, value, value_element_count, + return _return_character_string(g_firmware_revision, value, value_element_count, max_element_count, encoding_type) if property_identifier == c.PROPERTY_IDENTIFIER_APPLICATION_SOFTWARE_VERSION: - return _return_character_string(APPLICATION_SOFTWARE_VERSION, value, value_element_count, + return _return_character_string(APP_VERSION, value, value_element_count, max_element_count, encoding_type) return False @@ -563,7 +569,7 @@ def _print_help(): # 4. main() # ----------------------------------------------------------------------------- def main(): - global g_device_instance, g_bacnet_ip_udp_port, g_analog_input_1_value + global g_device_instance, g_bacnet_ip_udp_port, g_analog_input_1_value, g_firmware_revision parser = ArgumentParser(prog="main.py", description=APP_NAME) parser.add_argument("--port", type=cas_example_helper.parse_port, default=47808, @@ -594,6 +600,17 @@ def main(): return 1 bacnet.bind(library) + # Firmware_Revision (44) names the CAS BACnet Stack itself, not this + # example app - build it once from the stack's own version API, right + # after the stack is bound, so every later ReadProperty of (Device, + # Firmware_Revision) just returns the string computed here. + g_firmware_revision = "{}.{}.{}.{}".format( + bacnet.BACnetStack_GetAPIMajorVersion(), + bacnet.BACnetStack_GetAPIMinorVersion(), + bacnet.BACnetStack_GetAPIPatchVersion(), + bacnet.BACnetStack_GetAPIBuildVersion(), + ) + if args.version: cas_example_helper.print_version(APP_NAME, APP_VERSION) return 0