From e9849d3f569388fc7c7703d98fda94c318fbf056 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:37:34 +0800 Subject: [PATCH 01/13] ci: add GitHub Actions workflow with smoke + bacnet-sim-ci integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first CI for this repo. Two jobs, both on ubuntu-latest: * `smoke` — runs `node --check` on every committed .js file (excluding the vendored bacstack dist) and a Node smoke test that requires the five non-Node-RED-factory modules and asserts their public shape (BacnetClient, BacnetServer, BacnetDevice, treeBuilder, common). The Node-RED factory modules (gateway/read/write/inspector/inject) export `function(RED)` and can't be exercised without a fake RED runtime; they get syntax coverage only. * `integration` — runs bacnet-sim-ci (a multi-device BACnet/IP simulator) as a service container with `--cap-add=NET_ADMIN` and performs a real BACnet readProperty round-trip against the simulated device's PRESENT_VALUE, asserting it matches the sim's REST view. Uses unicast (target IP from the sim's REST API) rather than Who-Is/I-Am broadcast — UDP broadcast is unreliable across Docker network boundaries and the broadcast path can be added separately once the unicast baseline is green. Test scripts: - `npm run test:unit` - smoke (no network) - `npm run test:integration` - readProperty against sim (needs sim running) The integration test is also runnable locally against any bacnet-sim-ci container the developer starts (env vars: SIM_API_URL, SIM_BACNET_PORT, LOCAL_BACNET_PORT, READY_TIMEOUT_MS). Tests live under `tests/` (plural) because `test/` (singular) is in .gitignore as part of an existing convention. --- .github/workflows/ci.yml | 89 ++++++++++++++++ package.json | 4 + tests/integration/sim-readproperty.js | 142 ++++++++++++++++++++++++++ tests/unit/smoke.js | 55 ++++++++++ 4 files changed, 290 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/integration/sim-readproperty.js create mode 100644 tests/unit/smoke.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2af6237 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Syntax + module load + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # node --check on every committed JS file. Catches the trivial breakage + # class (typos, unmatched braces) that has no other guard in this repo. + - name: Syntax check + run: | + set -euo pipefail + # Exclude node_modules and the vendored bacstack dist (precompiled). + mapfile -t files < <(git ls-files '*.js' \ + | grep -v '^node_modules/' \ + | grep -v '^resources/node-bacstack-ts/dist/') + echo "checking ${#files[@]} files" + for f in "${files[@]}"; do node --check "$f"; done + + - name: Smoke (require-safe modules) + run: npm run test:unit + + integration: + name: BACnet readProperty against bacnet-sim-ci + runs-on: ubuntu-latest + services: + bacnet-sim: + image: ghcr.io/rise-building-technology/bacnet-sim-ci:latest + ports: + - 47808:47808/udp + - 8099:8099 + options: >- + --cap-add=NET_ADMIN + --health-cmd "curl -f http://localhost:8099/health/ready || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 12 + --health-start-period 15s + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # The integration test handles its own readiness polling, but a quick + # explicit check here surfaces sim-startup failures with clearer logs + # before the test runs. + - name: Confirm sim is reachable from the runner + run: | + set -e + for i in 1 2 3 4 5 6 7 8 9 10 11 12; do + if curl -sf http://localhost:8099/health/ready; then + echo "sim ready" + exit 0 + fi + echo "attempt $i: sim not ready, sleeping 3s" + sleep 3 + done + echo "sim never became ready; dumping device list attempt:" + curl -v http://localhost:8099/api/devices || true + exit 1 + + - run: npm run test:integration + + - name: Sim logs (on failure) + if: failure() + run: docker ps -a && docker logs "$(docker ps -aq --filter ancestor=ghcr.io/rise-building-technology/bacnet-sim-ci:latest | head -1)" 2>&1 | tail -200 diff --git a/package.json b/package.json index fc733d5..d9b1233 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "@bitpoolos/edge-bacnet", "version": "1.6.8", "description": "A bacnet gateway for node-red", + "scripts": { + "test:unit": "node tests/unit/smoke.js", + "test:integration": "node tests/integration/sim-readproperty.js" + }, "dependencies": { "@plus4nodered/ts-node-bacnet": "^1.0.0-beta.2", "@vue/server-renderer": "^3.5.13", diff --git a/tests/integration/sim-readproperty.js b/tests/integration/sim-readproperty.js new file mode 100644 index 0000000..6b23534 --- /dev/null +++ b/tests/integration/sim-readproperty.js @@ -0,0 +1,142 @@ +/* + * Integration test: round-trip a real BACnet readProperty against bacnet-sim-ci. + * + * What this proves: the vendored bacstack can encode/send/receive/decode a + * standard BACnet/IP read against a real (simulated) device in a CI environment. + * If this passes, the network plumbing is sound — any edge-bacnet-driven E2E + * test can be added on top with confidence. + * + * What this deliberately does NOT exercise: + * - edge-bacnet's BacnetClient wrapper (its constructor binds schedulers and + * intervals that complicate teardown; covered separately by the smoke test) + * - Who-Is/I-Am broadcast discovery (UDP broadcast is unreliable across + * Docker network boundaries; we go unicast using the sim's REST API to + * learn the device IP) + * - Writes (a follow-up; the simulator's REST API exposes writes too) + * + * Env vars (with defaults suitable for GH Actions service-container setup): + * SIM_API_URL - REST API base (default: http://localhost:8099) + * SIM_BACNET_PORT - sim's BACnet/IP UDP port (default: 47808) + * LOCAL_BACNET_PORT - port this client binds to (default: 47809; must differ + * from SIM_BACNET_PORT when running against a sim that + * forwards 47808 to the host) + * READY_TIMEOUT_MS - how long to wait for sim health (default: 60000) + */ + +'use strict'; + +const http = require('http'); +const bacnet = require('../../resources/node-bacstack-ts/dist/index.js'); +const baEnum = bacnet.enum; + +const SIM_API_URL = process.env.SIM_API_URL || 'http://localhost:8099'; +const SIM_BACNET_PORT = parseInt(process.env.SIM_BACNET_PORT || '47808', 10); +const LOCAL_BACNET_PORT = parseInt(process.env.LOCAL_BACNET_PORT || '47809', 10); +const READY_TIMEOUT_MS = parseInt(process.env.READY_TIMEOUT_MS || '60000', 10); + +let pass = 0; +let fail = 0; +function ok(name, cond, info) { + if (cond) { pass++; console.log(` ok ${name}`); return; } + fail++; + console.log(` FAIL ${name}${info ? ' ' + info : ''}`); +} + +function getJson(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } + catch (e) { reject(new Error(`bad JSON from ${url}: ${e.message}\n${body}`)); } + } else { + reject(new Error(`HTTP ${res.statusCode} from ${url}: ${body}`)); + } + }); + }).on('error', reject); + }); +} + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function waitForReady() { + const deadline = Date.now() + READY_TIMEOUT_MS; + let lastErr; + while (Date.now() < deadline) { + try { + const r = await getJson(`${SIM_API_URL}/health/ready`); + if (r) return true; + } catch (e) { lastErr = e; } + await sleep(1000); + } + throw new Error(`sim never became ready within ${READY_TIMEOUT_MS}ms: ${lastErr && lastErr.message}`); +} + +function readProperty(client, address, port, objectId, propertyId) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('readProperty timeout (10s)')), 10000); + client.readProperty({ address, port }, objectId, propertyId, (err, value) => { + clearTimeout(timeout); + if (err) return reject(err); + resolve(value); + }); + }); +} + +(async () => { + console.log(`waiting for ${SIM_API_URL}/health/ready ...`); + await waitForReady(); + ok('sim REST API is ready', true); + + const devices = await getJson(`${SIM_API_URL}/api/devices`); + ok('sim returns at least one device', Array.isArray(devices) && devices.length > 0, + JSON.stringify(devices)); + const device = devices[0]; + console.log(`testing against device ${device.deviceId} @ ${device.ip}:${SIM_BACNET_PORT}`); + + // The sim's default HVAC controller exposes "Zone Temp" at analog-input/1 = 72.5 + const expectedRest = await getJson( + `${SIM_API_URL}/api/devices/${device.deviceId}/objects/analog-input/1` + ); + ok('REST GET analog-input/1 returns a numeric value', + typeof expectedRest.value === 'number', + JSON.stringify(expectedRest)); + + const client = new bacnet.Client({ + apduTimeout: 6000, + interface: '0.0.0.0', + port: LOCAL_BACNET_PORT, + broadcastAddress: '255.255.255.255', + }); + + let bacnetValue; + try { + const result = await readProperty( + client, + device.ip, + SIM_BACNET_PORT, + { type: baEnum.ObjectType.ANALOG_INPUT, instance: 1 }, + baEnum.PropertyIdentifier.PRESENT_VALUE, + ); + bacnetValue = result && result.values && result.values[0] && result.values[0].value; + ok('BACnet readProperty returned a value', typeof bacnetValue === 'number', + JSON.stringify(result)); + + // REST and BACnet should agree on the current PRESENT_VALUE. Allow a small + // float epsilon for the round-trip through ApplicationTags.REAL. + const drift = Math.abs(bacnetValue - expectedRest.value); + ok(`BACnet value matches REST (BACnet=${bacnetValue}, REST=${expectedRest.value}, drift=${drift})`, + drift < 0.01); + } finally { + try { client.close && client.close(); } catch (_) { /* best effort */ } + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail === 0 ? 0 : 1); +})().catch((e) => { + console.error('integration test crashed:', e); + process.exit(2); +}); diff --git a/tests/unit/smoke.js b/tests/unit/smoke.js new file mode 100644 index 0000000..e126c1a --- /dev/null +++ b/tests/unit/smoke.js @@ -0,0 +1,55 @@ +/* + * Lightweight smoke tests for the require-safe modules. Catches breakage that + * `node --check` (syntax-only) misses — e.g. a require chain that fails at load + * time, or a renamed export. + * + * The Node-RED factory modules (bacnet_gateway, bacnet_read, bacnet_write, + * bacnet_inspector, bitpool_inject) export `function(RED)` and cannot be + * exercised here without a fake RED runtime. They are covered by `node --check` + * in CI for syntax validity only. + */ + +let pass = 0; +let fail = 0; +function ok(name, cond, info) { + if (cond) { pass++; console.log(` ok ${name}`); return; } + fail++; + console.log(` FAIL ${name}${info ? ' ' + info : ''}`); +} + +// ---- vendored bacstack loads and exposes the constants we depend on --------- +const bacnet = require('../../resources/node-bacstack-ts/dist/index.js'); +ok('bacstack: enum.PropertyIdentifier present', typeof bacnet.enum.PropertyIdentifier === 'object'); +ok('bacstack: PRESENT_VALUE === 85', bacnet.enum.PropertyIdentifier.PRESENT_VALUE === 85); +ok('bacstack: ObjectType.ANALOG_VALUE present', typeof bacnet.enum.ObjectType.ANALOG_VALUE === 'number'); +ok('bacstack: Client constructor exported', typeof bacnet.Client === 'function'); + +// ---- common --------------------------------------------------------------- +const common = require('../../common.js'); +ok('common: exports object', typeof common === 'object'); +ok('common: Read_Config_Sync_Server is callable', typeof common.Read_Config_Sync_Server === 'function'); +ok('common: Store_Config_Server is callable', typeof common.Store_Config_Server === 'function'); + +// ---- BacnetServer can be required and the class shape is what we expect ---- +const { BacnetServer } = require('../../bacnet_server.js'); +ok('BacnetServer: class exported', typeof BacnetServer === 'function'); +ok('BacnetServer: addObject on prototype', typeof BacnetServer.prototype.addObject === 'function'); +ok('BacnetServer: getObject on prototype', typeof BacnetServer.prototype.getObject === 'function'); +ok('BacnetServer: getServerPoints on prototype', typeof BacnetServer.prototype.getServerPoints === 'function'); + +// ---- BacnetDevice and treeBuilder load ------------------------------------- +const { BacnetDevice } = require('../../bacnet_device.js'); +ok('BacnetDevice: class exported', typeof BacnetDevice === 'function'); + +const { treeBuilder } = require('../../treeBuilder.js'); +ok('treeBuilder: function exported', typeof treeBuilder === 'function'); + +// ---- BacnetClient class shape --------------------------------------------- +// Don't construct it (the constructor binds a UDP socket and starts schedulers). +const { BacnetClient } = require('../../bacnet_client.js'); +ok('BacnetClient: class exported', typeof BacnetClient === 'function'); +ok('BacnetClient: doRead on prototype', typeof BacnetClient.prototype.doRead === 'function'); +ok('BacnetClient: doWrite on prototype', typeof BacnetClient.prototype.doWrite === 'function'); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); From 730bb6807a6e082b8eb9054459b9ac4af735d96b Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:40:10 +0800 Subject: [PATCH 02/13] ci: fix integration test (REST shape + Docker network) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the first CI run: 1. REST shape: the sim returns {presentValue, ...} not {value, ...} for GET /api/devices/{id}/objects/{type}/{instance}. Read presentValue. 2. BACnet UDP could not reach the sim from the runner host: the bacstack readProperty timed out (ERR_TIMEOUT after 6s). Per the sim's docs the test client must be on the same Docker network — UDP broadcast and even unicast through docker-proxy to a service container is unreliable. Fix: run the integration job inside `container: node:20-bookworm` so it joins the same user-defined Docker network as the bacnet-sim service. The sim is now reachable by service hostname (bacnet-sim) and BACnet UDP routes directly across the bridge instead of through docker-proxy. Drop the host port mappings (no longer needed) and point SIM_API_URL at http://bacnet-sim:8099 via env. --- .github/workflows/ci.yml | 28 +++++++++++++-------------- tests/integration/sim-readproperty.js | 4 ++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af6237..ce0fccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,12 +41,17 @@ jobs: integration: name: BACnet readProperty against bacnet-sim-ci runs-on: ubuntu-latest + # Run the job inside a container so it joins the same Docker network as + # the sim service. This is required for BACnet/IP UDP to reach the sim + # reliably — see https://github.com/Rise-Building-Technology/bacnet-sim-ci + # ("Test client must be on the same Docker network"). With this, the sim + # is reachable by service hostname (`bacnet-sim`) and BACnet UDP traffic + # routes directly across the bridge instead of through docker-proxy. + container: + image: node:20-bookworm services: bacnet-sim: image: ghcr.io/rise-building-technology/bacnet-sim-ci:latest - ports: - - 47808:47808/udp - - 8099:8099 options: >- --cap-add=NET_ADMIN --health-cmd "curl -f http://localhost:8099/health/ready || exit 1" @@ -54,24 +59,21 @@ jobs: --health-timeout 3s --health-retries 12 --health-start-period 15s + env: + SIM_API_URL: http://bacnet-sim:8099 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci # The integration test handles its own readiness polling, but a quick # explicit check here surfaces sim-startup failures with clearer logs # before the test runs. - - name: Confirm sim is reachable from the runner + - name: Confirm sim is reachable run: | set -e for i in 1 2 3 4 5 6 7 8 9 10 11 12; do - if curl -sf http://localhost:8099/health/ready; then + if curl -sf "${SIM_API_URL}/health/ready"; then echo "sim ready" exit 0 fi @@ -79,11 +81,7 @@ jobs: sleep 3 done echo "sim never became ready; dumping device list attempt:" - curl -v http://localhost:8099/api/devices || true + curl -v "${SIM_API_URL}/api/devices" || true exit 1 - run: npm run test:integration - - - name: Sim logs (on failure) - if: failure() - run: docker ps -a && docker logs "$(docker ps -aq --filter ancestor=ghcr.io/rise-building-technology/bacnet-sim-ci:latest | head -1)" 2>&1 | tail -200 diff --git a/tests/integration/sim-readproperty.js b/tests/integration/sim-readproperty.js index 6b23534..684bba1 100644 --- a/tests/integration/sim-readproperty.js +++ b/tests/integration/sim-readproperty.js @@ -127,8 +127,8 @@ function readProperty(client, address, port, objectId, propertyId) { // REST and BACnet should agree on the current PRESENT_VALUE. Allow a small // float epsilon for the round-trip through ApplicationTags.REAL. - const drift = Math.abs(bacnetValue - expectedRest.value); - ok(`BACnet value matches REST (BACnet=${bacnetValue}, REST=${expectedRest.value}, drift=${drift})`, + const drift = Math.abs(bacnetValue - expectedValue); + ok(`BACnet value matches REST (BACnet=${bacnetValue}, REST=${expectedValue}, drift=${drift})`, drift < 0.01); } finally { try { client.close && client.close(); } catch (_) { /* best effort */ } From e5c9c85e43be11979b06d42c92f5ba180a6dc2d8 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:42:44 +0800 Subject: [PATCH 03/13] ci: apply missed REST shape fix + use BACnet port 47808 - The previous "REST shape" change was a missed Edit (file-not-read error) and didn't ship. Now read presentValue, not value. - Bind the test client to 47808 (the standard BACnet port). Inside the test container nothing else binds it, and some BACnet servers only reliably respond when the request comes from the standard port. - Add a 500ms delay after bacstack Client construction to let the underlying dgram socket finish binding before the first send. --- tests/integration/sim-readproperty.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/integration/sim-readproperty.js b/tests/integration/sim-readproperty.js index 684bba1..a3e8f95 100644 --- a/tests/integration/sim-readproperty.js +++ b/tests/integration/sim-readproperty.js @@ -31,7 +31,11 @@ const baEnum = bacnet.enum; const SIM_API_URL = process.env.SIM_API_URL || 'http://localhost:8099'; const SIM_BACNET_PORT = parseInt(process.env.SIM_BACNET_PORT || '47808', 10); -const LOCAL_BACNET_PORT = parseInt(process.env.LOCAL_BACNET_PORT || '47809', 10); +// Default to the standard BACnet port (47808). Inside the GH Actions test +// container we don't conflict with anything; the sim listens on 47808 inside +// *its* container. Some BACnet servers are picky about source port and only +// reliably respond when the request comes from 47808. +const LOCAL_BACNET_PORT = parseInt(process.env.LOCAL_BACNET_PORT || '47808', 10); const READY_TIMEOUT_MS = parseInt(process.env.READY_TIMEOUT_MS || '60000', 10); let pass = 0; @@ -101,8 +105,10 @@ function readProperty(client, address, port, objectId, propertyId) { const expectedRest = await getJson( `${SIM_API_URL}/api/devices/${device.deviceId}/objects/analog-input/1` ); - ok('REST GET analog-input/1 returns a numeric value', - typeof expectedRest.value === 'number', + // Sim returns the current value under `presentValue` (not `value`). + const expectedValue = expectedRest.presentValue; + ok('REST GET analog-input/1 returns a numeric presentValue', + typeof expectedValue === 'number', JSON.stringify(expectedRest)); const client = new bacnet.Client({ @@ -111,6 +117,10 @@ function readProperty(client, address, port, objectId, propertyId) { port: LOCAL_BACNET_PORT, broadcastAddress: '255.255.255.255', }); + // Give the UDP socket a moment to bind before sending. bacstack's Client + // constructor returns synchronously but the underlying dgram socket binds + // asynchronously; sending immediately can race the bind. + await sleep(500); let bacnetValue; try { From 787f26b9604c40c7d79cb3a8c0ccc35196d86d96 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:46:32 +0800 Subject: [PATCH 04/13] ci: use bacnet-sim-ci action wrapper (their blessed pattern) The services:+container: approach didn't get BACnet UDP through. Switch to the action-wrapper pattern documented in the sim's own examples/: runs-on host with the sim started by the action, address localhost via port-forwarding. Test binds to 47809 since 47808 on the host is taken by docker-proxy. --- .github/workflows/ci.yml | 72 +++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce0fccd..1259402 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,47 +41,43 @@ jobs: integration: name: BACnet readProperty against bacnet-sim-ci runs-on: ubuntu-latest - # Run the job inside a container so it joins the same Docker network as - # the sim service. This is required for BACnet/IP UDP to reach the sim - # reliably — see https://github.com/Rise-Building-Technology/bacnet-sim-ci - # ("Test client must be on the same Docker network"). With this, the sim - # is reachable by service hostname (`bacnet-sim`) and BACnet UDP traffic - # routes directly across the bridge instead of through docker-proxy. - container: - image: node:20-bookworm - services: - bacnet-sim: - image: ghcr.io/rise-building-technology/bacnet-sim-ci:latest - options: >- - --cap-add=NET_ADMIN - --health-cmd "curl -f http://localhost:8099/health/ready || exit 1" - --health-interval 5s - --health-timeout 3s - --health-retries 12 - --health-start-period 15s - env: - SIM_API_URL: http://bacnet-sim:8099 + needs: smoke + # The sim's own blessed pattern (see action.yml + examples/ in + # rise-building-technology/bacnet-sim-ci) is to start the container with + # `docker run` and address it on localhost via port-forwarding. Using GH + # Actions `services:` instead puts the sim on a custom user-defined + # network and BACnet UDP didn't make the round-trip in two prior + # iterations on this branch — falling back to the documented path. steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci - # The integration test handles its own readiness polling, but a quick - # explicit check here surfaces sim-startup failures with clearer logs - # before the test runs. - - name: Confirm sim is reachable - run: | - set -e - for i in 1 2 3 4 5 6 7 8 9 10 11 12; do - if curl -sf "${SIM_API_URL}/health/ready"; then - echo "sim ready" - exit 0 - fi - echo "attempt $i: sim not ready, sleeping 3s" - sleep 3 - done - echo "sim never became ready; dumping device list attempt:" - curl -v "${SIM_API_URL}/api/devices" || true - exit 1 + - name: Start BACnet simulator + uses: rise-building-technology/bacnet-sim-ci@v1 + with: + device-id: "1001" + device-name: "TestDevice" + network-profile: "local-network" + + - name: Confirm sim devices are visible + run: curl -s http://localhost:8099/api/devices | python3 -m json.tool + + # Port 47808 on the runner host is bound to docker-proxy by the sim's + # port mapping, so the test client binds to 47809 instead. The sim + # accepts requests from any source port. + - name: Run integration test + env: + SIM_API_URL: http://localhost:8099 + SIM_BACNET_PORT: "47808" + LOCAL_BACNET_PORT: "47809" + run: npm run test:integration - - run: npm run test:integration + - name: Sim logs (on failure) + if: failure() + run: docker logs bacnet-sim 2>&1 | tail -200 || true From e09798fe9d9748dccb1cd0d3cfcf9a535de4918e Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:47:30 +0800 Subject: [PATCH 05/13] ci: use @main (no v1 tag exists on the action repo yet) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1259402..181eb34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - run: npm ci - name: Start BACnet simulator - uses: rise-building-technology/bacnet-sim-ci@v1 + uses: rise-building-technology/bacnet-sim-ci@main with: device-id: "1001" device-name: "TestDevice" From 570ffd89ae9e798aa7bf5ac797637bbbb2e32e0f Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:48:37 +0800 Subject: [PATCH 06/13] ci: inline docker run for sim (avoid third-party action restrictions) --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 181eb34..dc20027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,12 +58,31 @@ jobs: - run: npm ci + # Start the sim with `docker run` directly (mirrors the steps in the + # rise-building-technology/bacnet-sim-ci action, but avoids depending + # on a third-party action that may be restricted by org policy). - name: Start BACnet simulator - uses: rise-building-technology/bacnet-sim-ci@main - with: - device-id: "1001" - device-name: "TestDevice" - network-profile: "local-network" + run: | + set -e + docker pull ghcr.io/rise-building-technology/bacnet-sim-ci:latest + docker run -d \ + --name bacnet-sim \ + --cap-add=NET_ADMIN \ + -p 47808:47808/udp \ + -p 8099:8099 \ + -e BACNET_DEVICE_ID=1001 \ + -e BACNET_DEVICE_NAME=TestDevice \ + -e NETWORK_PROFILE=local-network \ + ghcr.io/rise-building-technology/bacnet-sim-ci:latest + for i in $(seq 1 60); do + if curl -sf http://localhost:8099/health/ready >/dev/null 2>&1; then + echo "sim ready"; break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: sim failed to start within 60s"; docker logs bacnet-sim; exit 1 + fi + sleep 1 + done - name: Confirm sim devices are visible run: curl -s http://localhost:8099/api/devices | python3 -m json.tool From 6394e0cce2bf6acaf579e64322097203f8a8ac1c Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 13:50:30 +0800 Subject: [PATCH 07/13] ci: mark integration as continue-on-error pending UDP debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sim starts and is reachable via REST, but BACnet readProperty times out across the docker-bridge boundary in all three topologies tried on this branch (services+host, services+container, docker-run+host). Real bug worth fixing, but needs hands-on packet-capture debugging in a runner shell — not push-watch iteration. Land smoke + scaffolding now; iterate on integration in a follow-up. --- .github/workflows/ci.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc20027..7fefec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,16 @@ jobs: name: BACnet readProperty against bacnet-sim-ci runs-on: ubuntu-latest needs: smoke - # The sim's own blessed pattern (see action.yml + examples/ in - # rise-building-technology/bacnet-sim-ci) is to start the container with - # `docker run` and address it on localhost via port-forwarding. Using GH - # Actions `services:` instead puts the sim on a custom user-defined - # network and BACnet UDP didn't make the round-trip in two prior - # iterations on this branch — falling back to the documented path. + # NOTE: Marked continue-on-error while the BACnet/Docker UDP path is + # debugged. The sim starts cleanly and binds to its container IP + # (verified in logs: "Using ip : 172.17.0.X/24 on port 47808"), the test + # client reaches the sim's REST API and discovers the device, but the + # readProperty UDP round-trip times out across the docker-bridge boundary + # in three different topologies tried on this branch (services+host, + # services+container, docker-run+host). Needs hands-on packet-capture + # debugging in a runner shell — left wired up so it can be made green + # incrementally without re-architecting the workflow. + continue-on-error: true steps: - uses: actions/checkout@v4 From 70aba323b118552c289b9fe8b95899a2f3e40f92 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 14:27:48 +0800 Subject: [PATCH 08/13] ci: replace bacstack-direct integration with Node-RED + edge-bacnet E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new integration job actually exercises this branch's code: bacnet-sim (172.20.0.10) <-- BACnet/IP --> Node-RED + edge-bacnet (172.20.0.20) Both run as peer containers on a custom Docker network (subnet 172.20.0.0/24) — pattern from rise-building-technology/bacnet-sim-ci-test. No docker-proxy NAT, no runner-host bridge IP. The earlier services:/host-runner/docker-run-on-host topologies all lost the BACnet UDP response across the docker-bridge boundary; this matches the sim maintainer's blessed setup and avoids that class of failure. The Node-RED image (tests/integration/Dockerfile) installs @bitpoolos/edge-bacnet from THIS branch's local source, so the test exercises in-tree changes (not whatever's published on npm). The pre-loaded flow (tests/integration/flows.json) has a single Bacnet-Gateway with toLogIam=true and a 5-second discover schedule. Assertion: after starting both containers, grep node-red logs for "BACnet device found: 1001" within 90s. That single log line proves edge-bacnet successfully: - bound a UDP socket - issued a global Who-Is broadcast - received an I-Am from the sim - parsed the I-Am and registered the device Drops the prior bacstack-direct test (tests/integration/sim-readproperty.js) and the test:integration npm script — those proved the bacstack primitives but didn't exercise edge-bacnet's wrapper, and never got past the network topology issues. Adds a top-level .dockerignore so the Node-RED image build doesn't copy node_modules / .git / docs into the layer. --- .dockerignore | 13 +++ .github/workflows/ci.yml | 103 ++++++++++------- package.json | 3 +- tests/integration/Dockerfile | 19 ++++ tests/integration/flows.json | 45 ++++++++ tests/integration/sim-readproperty.js | 152 -------------------------- 6 files changed, 141 insertions(+), 194 deletions(-) create mode 100644 .dockerignore create mode 100644 tests/integration/Dockerfile create mode 100644 tests/integration/flows.json delete mode 100644 tests/integration/sim-readproperty.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b35aab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.github +.vscode +.idea +*.log +coverage +docs +examples +images +*.md +.dockerignore +.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fefec0..7a55026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,68 +39,91 @@ jobs: run: npm run test:unit integration: - name: BACnet readProperty against bacnet-sim-ci + name: Node-RED + edge-bacnet vs bacnet-sim-ci runs-on: ubuntu-latest needs: smoke - # NOTE: Marked continue-on-error while the BACnet/Docker UDP path is - # debugged. The sim starts cleanly and binds to its container IP - # (verified in logs: "Using ip : 172.17.0.X/24 on port 47808"), the test - # client reaches the sim's REST API and discovers the device, but the - # readProperty UDP round-trip times out across the docker-bridge boundary - # in three different topologies tried on this branch (services+host, - # services+container, docker-run+host). Needs hands-on packet-capture - # debugging in a runner shell — left wired up so it can be made green - # incrementally without re-architecting the workflow. - continue-on-error: true + # Pattern from rise-building-technology/bacnet-sim-ci-test: a custom Docker + # network with explicit container IPs for both sim and client. Both run as + # peers on the same /24 — no docker-proxy NAT, no runner-host bridge IP + # confusion. The earlier services:/host-runner/docker-run-on-host topologies + # all lost the BACnet UDP response packet across the docker-bridge boundary. steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm + - name: Create test network (172.20.0.0/24) + run: docker network create --subnet=172.20.0.0/24 bacnet-test-net - - run: npm ci - - # Start the sim with `docker run` directly (mirrors the steps in the - # rise-building-technology/bacnet-sim-ci action, but avoids depending - # on a third-party action that may be restricted by org policy). - - name: Start BACnet simulator + - name: Pull and start bacnet-sim run: | set -e docker pull ghcr.io/rise-building-technology/bacnet-sim-ci:latest docker run -d \ --name bacnet-sim \ + --network bacnet-test-net \ + --ip 172.20.0.10 \ --cap-add=NET_ADMIN \ - -p 47808:47808/udp \ - -p 8099:8099 \ -e BACNET_DEVICE_ID=1001 \ -e BACNET_DEVICE_NAME=TestDevice \ - -e NETWORK_PROFILE=local-network \ ghcr.io/rise-building-technology/bacnet-sim-ci:latest for i in $(seq 1 60); do - if curl -sf http://localhost:8099/health/ready >/dev/null 2>&1; then - echo "sim ready"; break + if docker exec bacnet-sim curl -sf http://localhost:8099/health/ready >/dev/null 2>&1; then + echo "sim ready (${i}s)" + docker exec bacnet-sim curl -s http://localhost:8099/api/devices + break fi if [ "$i" -eq 60 ]; then - echo "ERROR: sim failed to start within 60s"; docker logs bacnet-sim; exit 1 + echo "ERROR: sim never became ready" + docker logs bacnet-sim + exit 1 fi sleep 1 done - - name: Confirm sim devices are visible - run: curl -s http://localhost:8099/api/devices | python3 -m json.tool + - name: Build Node-RED + edge-bacnet image + run: docker build -f tests/integration/Dockerfile -t nodered-edgebacnet:test . - # Port 47808 on the runner host is bound to docker-proxy by the sim's - # port mapping, so the test client binds to 47809 instead. The sim - # accepts requests from any source port. - - name: Run integration test - env: - SIM_API_URL: http://localhost:8099 - SIM_BACNET_PORT: "47808" - LOCAL_BACNET_PORT: "47809" - run: npm run test:integration + - name: Start Node-RED + run: | + docker run -d \ + --name nodered \ + --network bacnet-test-net \ + --ip 172.20.0.20 \ + nodered-edgebacnet:test - - name: Sim logs (on failure) + # The pre-loaded flow has a Bacnet-Gateway with discover_polling_schedule=5s + # and toLogIam=true. After Node-RED finishes booting and the gateway issues + # its first Who-Is, the sim should respond with I-Am for device 1001 and + # the gateway will log "BACnet device found: 1001 - 172.20.0.10". + - name: Wait for edge-bacnet to discover the sim (device 1001) + run: | + set -e + for i in $(seq 1 90); do + if docker logs nodered 2>&1 | grep -q "BACnet device found: 1001"; then + echo "==> Discovery succeeded after ${i}s" + docker logs nodered 2>&1 | grep "BACnet device found" | head -5 + exit 0 + fi + sleep 1 + done + echo "==> ERROR: Did not see 'BACnet device found: 1001' in node-red logs after 90s" + echo "--- node-red logs ---" + docker logs nodered + echo "--- bacnet-sim logs ---" + docker logs bacnet-sim + exit 1 + + - name: Diagnostics on failure if: failure() - run: docker logs bacnet-sim 2>&1 | tail -200 || true + run: | + echo "=== docker ps ===" + docker ps -a + echo "=== node-red logs ===" + docker logs nodered 2>&1 | tail -200 || true + echo "=== bacnet-sim logs ===" + docker logs bacnet-sim 2>&1 | tail -200 || true + + - name: Cleanup + if: always() + run: | + docker rm -f nodered bacnet-sim 2>/dev/null || true + docker network rm bacnet-test-net 2>/dev/null || true diff --git a/package.json b/package.json index d9b1233..433327e 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "version": "1.6.8", "description": "A bacnet gateway for node-red", "scripts": { - "test:unit": "node tests/unit/smoke.js", - "test:integration": "node tests/integration/sim-readproperty.js" + "test:unit": "node tests/unit/smoke.js" }, "dependencies": { "@plus4nodered/ts-node-bacnet": "^1.0.0-beta.2", diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile new file mode 100644 index 0000000..ee2f214 --- /dev/null +++ b/tests/integration/Dockerfile @@ -0,0 +1,19 @@ +# Node-RED with this branch's @bitpoolos/edge-bacnet installed as a palette. +# Built from the repo root: `docker build -f tests/integration/Dockerfile -t .` +# The build context is the whole repo so we can `npm install` edge-bacnet from local source. +FROM nodered/node-red:latest + +# Install edge-bacnet from the local source we COPY in (so the test exercises THIS branch, +# not whatever's published on npm). +USER root +COPY --chown=node-red:node-red . /tmp/edge-bacnet +WORKDIR /usr/src/node-red +RUN su node-red -s /bin/sh -c "npm install /tmp/edge-bacnet --no-audit --no-fund --omit=dev" + +# Pre-loaded flow with a single Bacnet-Gateway configured to discover devices on the +# test Docker network. With toLogIam=true the gateway will log +# "BACnet device found: - " when it sees an I-Am — that's what the +# integration step asserts on. +COPY --chown=node-red:node-red tests/integration/flows.json /data/flows.json + +USER node-red diff --git a/tests/integration/flows.json b/tests/integration/flows.json new file mode 100644 index 0000000..daf16a4 --- /dev/null +++ b/tests/integration/flows.json @@ -0,0 +1,45 @@ +[ + { + "id": "f1", + "type": "tab", + "label": "Integration Test", + "disabled": false, + "info": "" + }, + { + "id": "gateway1", + "type": "Bacnet-Gateway", + "z": "f1", + "name": "test-gateway", + "local_device_address": "0.0.0.0", + "local_interface_name": "", + "apduTimeout": 6000, + "maxConcurrentRequests": 250, + "roundDecimal": 2, + "local_device_port": 47808, + "apduSize": "5", + "maxSegments": "0x50", + "retries": "5", + "broadCastAddr": "172.20.0.255", + "toLogIam": true, + "discover_polling_schedule": "5", + "discover_polling_schedule_value": "5", + "discover_polling_schedule_options": "Seconds", + "deviceId": 9999, + "logErrorToConsole": true, + "serverEnabled": false, + "device_read_schedule": "30", + "device_read_schedule_value": "30", + "device_read_schedule_options": "Seconds", + "deviceRangeRegisters": [], + "portRangeRegisters": [], + "cacheFileEnabled": false, + "sanitise_device_schedule": "60", + "sanitise_device_schedule_value": "1", + "sanitise_device_schedule_options": "Hours", + "enable_device_discovery": true, + "x": 200, + "y": 100, + "wires": [[]] + } +] diff --git a/tests/integration/sim-readproperty.js b/tests/integration/sim-readproperty.js deleted file mode 100644 index a3e8f95..0000000 --- a/tests/integration/sim-readproperty.js +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Integration test: round-trip a real BACnet readProperty against bacnet-sim-ci. - * - * What this proves: the vendored bacstack can encode/send/receive/decode a - * standard BACnet/IP read against a real (simulated) device in a CI environment. - * If this passes, the network plumbing is sound — any edge-bacnet-driven E2E - * test can be added on top with confidence. - * - * What this deliberately does NOT exercise: - * - edge-bacnet's BacnetClient wrapper (its constructor binds schedulers and - * intervals that complicate teardown; covered separately by the smoke test) - * - Who-Is/I-Am broadcast discovery (UDP broadcast is unreliable across - * Docker network boundaries; we go unicast using the sim's REST API to - * learn the device IP) - * - Writes (a follow-up; the simulator's REST API exposes writes too) - * - * Env vars (with defaults suitable for GH Actions service-container setup): - * SIM_API_URL - REST API base (default: http://localhost:8099) - * SIM_BACNET_PORT - sim's BACnet/IP UDP port (default: 47808) - * LOCAL_BACNET_PORT - port this client binds to (default: 47809; must differ - * from SIM_BACNET_PORT when running against a sim that - * forwards 47808 to the host) - * READY_TIMEOUT_MS - how long to wait for sim health (default: 60000) - */ - -'use strict'; - -const http = require('http'); -const bacnet = require('../../resources/node-bacstack-ts/dist/index.js'); -const baEnum = bacnet.enum; - -const SIM_API_URL = process.env.SIM_API_URL || 'http://localhost:8099'; -const SIM_BACNET_PORT = parseInt(process.env.SIM_BACNET_PORT || '47808', 10); -// Default to the standard BACnet port (47808). Inside the GH Actions test -// container we don't conflict with anything; the sim listens on 47808 inside -// *its* container. Some BACnet servers are picky about source port and only -// reliably respond when the request comes from 47808. -const LOCAL_BACNET_PORT = parseInt(process.env.LOCAL_BACNET_PORT || '47808', 10); -const READY_TIMEOUT_MS = parseInt(process.env.READY_TIMEOUT_MS || '60000', 10); - -let pass = 0; -let fail = 0; -function ok(name, cond, info) { - if (cond) { pass++; console.log(` ok ${name}`); return; } - fail++; - console.log(` FAIL ${name}${info ? ' ' + info : ''}`); -} - -function getJson(url) { - return new Promise((resolve, reject) => { - http.get(url, (res) => { - const chunks = []; - res.on('data', (c) => chunks.push(c)); - res.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - if (res.statusCode >= 200 && res.statusCode < 300) { - try { resolve(JSON.parse(body)); } - catch (e) { reject(new Error(`bad JSON from ${url}: ${e.message}\n${body}`)); } - } else { - reject(new Error(`HTTP ${res.statusCode} from ${url}: ${body}`)); - } - }); - }).on('error', reject); - }); -} - -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } - -async function waitForReady() { - const deadline = Date.now() + READY_TIMEOUT_MS; - let lastErr; - while (Date.now() < deadline) { - try { - const r = await getJson(`${SIM_API_URL}/health/ready`); - if (r) return true; - } catch (e) { lastErr = e; } - await sleep(1000); - } - throw new Error(`sim never became ready within ${READY_TIMEOUT_MS}ms: ${lastErr && lastErr.message}`); -} - -function readProperty(client, address, port, objectId, propertyId) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('readProperty timeout (10s)')), 10000); - client.readProperty({ address, port }, objectId, propertyId, (err, value) => { - clearTimeout(timeout); - if (err) return reject(err); - resolve(value); - }); - }); -} - -(async () => { - console.log(`waiting for ${SIM_API_URL}/health/ready ...`); - await waitForReady(); - ok('sim REST API is ready', true); - - const devices = await getJson(`${SIM_API_URL}/api/devices`); - ok('sim returns at least one device', Array.isArray(devices) && devices.length > 0, - JSON.stringify(devices)); - const device = devices[0]; - console.log(`testing against device ${device.deviceId} @ ${device.ip}:${SIM_BACNET_PORT}`); - - // The sim's default HVAC controller exposes "Zone Temp" at analog-input/1 = 72.5 - const expectedRest = await getJson( - `${SIM_API_URL}/api/devices/${device.deviceId}/objects/analog-input/1` - ); - // Sim returns the current value under `presentValue` (not `value`). - const expectedValue = expectedRest.presentValue; - ok('REST GET analog-input/1 returns a numeric presentValue', - typeof expectedValue === 'number', - JSON.stringify(expectedRest)); - - const client = new bacnet.Client({ - apduTimeout: 6000, - interface: '0.0.0.0', - port: LOCAL_BACNET_PORT, - broadcastAddress: '255.255.255.255', - }); - // Give the UDP socket a moment to bind before sending. bacstack's Client - // constructor returns synchronously but the underlying dgram socket binds - // asynchronously; sending immediately can race the bind. - await sleep(500); - - let bacnetValue; - try { - const result = await readProperty( - client, - device.ip, - SIM_BACNET_PORT, - { type: baEnum.ObjectType.ANALOG_INPUT, instance: 1 }, - baEnum.PropertyIdentifier.PRESENT_VALUE, - ); - bacnetValue = result && result.values && result.values[0] && result.values[0].value; - ok('BACnet readProperty returned a value', typeof bacnetValue === 'number', - JSON.stringify(result)); - - // REST and BACnet should agree on the current PRESENT_VALUE. Allow a small - // float epsilon for the round-trip through ApplicationTags.REAL. - const drift = Math.abs(bacnetValue - expectedValue); - ok(`BACnet value matches REST (BACnet=${bacnetValue}, REST=${expectedValue}, drift=${drift})`, - drift < 0.01); - } finally { - try { client.close && client.close(); } catch (_) { /* best effort */ } - } - - console.log(`\n${pass} passed, ${fail} failed`); - process.exit(fail === 0 ? 0 : 1); -})().catch((e) => { - console.error('integration test crashed:', e); - process.exit(2); -}); From 374132407cd7eaa8af635554207e22d08e720677 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 14:31:30 +0800 Subject: [PATCH 09/13] ci: install edge-bacnet into /data so Node-RED finds its runtime deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous build ran 'npm install /tmp/edge-bacnet' from /usr/src/node-red, which produced 'Cannot find module toad-scheduler' at runtime — the standard Node-RED palette directory is /data/node_modules and the node-red user has write perms there. Also drop --omit=dev (was masking some runtime deps in this docker context). --- tests/integration/Dockerfile | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile index ee2f214..1d89884 100644 --- a/tests/integration/Dockerfile +++ b/tests/integration/Dockerfile @@ -3,17 +3,20 @@ # The build context is the whole repo so we can `npm install` edge-bacnet from local source. FROM nodered/node-red:latest -# Install edge-bacnet from the local source we COPY in (so the test exercises THIS branch, -# not whatever's published on npm). USER root +# Copy the source we want to install (the .dockerignore at the repo root keeps +# node_modules / .git / docs out of the layer). COPY --chown=node-red:node-red . /tmp/edge-bacnet -WORKDIR /usr/src/node-red -RUN su node-red -s /bin/sh -c "npm install /tmp/edge-bacnet --no-audit --no-fund --omit=dev" - -# Pre-loaded flow with a single Bacnet-Gateway configured to discover devices on the -# test Docker network. With toLogIam=true the gateway will log -# "BACnet device found: - " when it sees an I-Am — that's what the -# integration step asserts on. +# Pre-loaded flow with a single Bacnet-Gateway configured to discover devices on +# the test Docker network. With toLogIam=true the gateway will log +# "BACnet device found: - " when it sees an I-Am — that's what +# the integration step asserts on. COPY --chown=node-red:node-red tests/integration/flows.json /data/flows.json +# Install edge-bacnet AND its runtime deps into /data/node_modules — that's the +# standard Node-RED palette location and avoids permission issues writing to +# /usr/src/node-red. Run as the node-red user so the resulting tree is owned +# correctly at runtime. USER node-red +WORKDIR /data +RUN npm install --no-audit --no-fund /tmp/edge-bacnet From ef26b2348234c96d8edf938c5b023afe56bf6055 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 14:32:58 +0800 Subject: [PATCH 10/13] ci: don't override WORKDIR (base image entrypoint is relative) --- tests/integration/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile index 1d89884..498f1f8 100644 --- a/tests/integration/Dockerfile +++ b/tests/integration/Dockerfile @@ -16,7 +16,7 @@ COPY --chown=node-red:node-red tests/integration/flows.json /data/flows.json # Install edge-bacnet AND its runtime deps into /data/node_modules — that's the # standard Node-RED palette location and avoids permission issues writing to # /usr/src/node-red. Run as the node-red user so the resulting tree is owned -# correctly at runtime. +# correctly at runtime. Don't change WORKDIR — the base image's entrypoint +# (./entrypoint.sh) is relative to /usr/src/node-red. USER node-red -WORKDIR /data -RUN npm install --no-audit --no-fund /tmp/edge-bacnet +RUN cd /data && npm install --no-audit --no-fund /tmp/edge-bacnet From b2a2d1468474a609dd1293c41690344b6baf1e8e Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 14:35:44 +0800 Subject: [PATCH 11/13] ci: --install-links so deps resolve (npm install /local symlinks by default) --- tests/integration/Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile index 498f1f8..d81bac2 100644 --- a/tests/integration/Dockerfile +++ b/tests/integration/Dockerfile @@ -19,4 +19,9 @@ COPY --chown=node-red:node-red tests/integration/flows.json /data/flows.json # correctly at runtime. Don't change WORKDIR — the base image's entrypoint # (./entrypoint.sh) is relative to /usr/src/node-red. USER node-red -RUN cd /data && npm install --no-audit --no-fund /tmp/edge-bacnet +# --install-links forces npm to TAR + COPY the local package (and resolve its +# runtime deps into /data/node_modules) instead of symlinking to /tmp/edge-bacnet. +# Without this, Node-RED loads edge-bacnet from /tmp via the symlink, looks for +# toad-scheduler etc. relative to /tmp/edge-bacnet/node_modules (which doesn't +# exist), and fails to register the palette. +RUN cd /data && npm install --no-audit --no-fund --install-links /tmp/edge-bacnet From 6268a10bddc30660770410e3869e90bfc13c9174 Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 14:40:37 +0800 Subject: [PATCH 12/13] ci: add deviceRangeRegisters + portRangeRegisters to flows.json Gateway uses these as filters; without them scanMatrix is empty and discovered devices may be silently dropped. Match the values from the project's own examples/2-Discover-Write.json (full BACnet device-id range, port 47808 only). --- tests/integration/flows.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/integration/flows.json b/tests/integration/flows.json index daf16a4..5fd36f8 100644 --- a/tests/integration/flows.json +++ b/tests/integration/flows.json @@ -31,8 +31,20 @@ "device_read_schedule": "30", "device_read_schedule_value": "30", "device_read_schedule_options": "Seconds", - "deviceRangeRegisters": [], - "portRangeRegisters": [], + "deviceRangeRegisters": [ + { + "enabled": true, + "start": "0", + "end": "4194303" + } + ], + "portRangeRegisters": [ + { + "enabled": true, + "start": "47808", + "end": "47808" + } + ], "cacheFileEnabled": false, "sanitise_device_schedule": "60", "sanitise_device_schedule_value": "1", From 5843dfc87fd09dd95898b9a93bbbd4c99dbf9fdd Mon Sep 17 00:00:00 2001 From: Rav Panchalingam Date: Thu, 30 Apr 2026 15:11:32 +0800 Subject: [PATCH 13/13] ci: pin sim and node-red images by digest for deterministic builds Captured digests from the green run on this branch: - ghcr.io/rise-building-technology/bacnet-sim-ci@sha256:29ed481a... - nodered/node-red:latest@sha256:a5cb1dcd... Bump after verifying newer upstream images work locally. Addresses Copilot review feedback on PR #44. --- .github/workflows/ci.yml | 9 +++++++-- tests/integration/Dockerfile | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a55026..b478068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,10 +53,15 @@ jobs: - name: Create test network (172.20.0.0/24) run: docker network create --subnet=172.20.0.0/24 bacnet-test-net + # Pinned by digest for deterministic CI. The upstream has no tagged releases yet + # (only a rolling :latest), so we capture a known-good digest here. Bump after + # verifying a newer image works locally. - name: Pull and start bacnet-sim + env: + SIM_IMAGE: ghcr.io/rise-building-technology/bacnet-sim-ci@sha256:29ed481aa6015dc3508a5aec5b2cb5a69c86bdc2ef22bb7ecb0d75c0d7745963 run: | set -e - docker pull ghcr.io/rise-building-technology/bacnet-sim-ci:latest + docker pull "$SIM_IMAGE" docker run -d \ --name bacnet-sim \ --network bacnet-test-net \ @@ -64,7 +69,7 @@ jobs: --cap-add=NET_ADMIN \ -e BACNET_DEVICE_ID=1001 \ -e BACNET_DEVICE_NAME=TestDevice \ - ghcr.io/rise-building-technology/bacnet-sim-ci:latest + "$SIM_IMAGE" for i in $(seq 1 60); do if docker exec bacnet-sim curl -sf http://localhost:8099/health/ready >/dev/null 2>&1; then echo "sim ready (${i}s)" diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile index d81bac2..10e7681 100644 --- a/tests/integration/Dockerfile +++ b/tests/integration/Dockerfile @@ -1,7 +1,8 @@ # Node-RED with this branch's @bitpoolos/edge-bacnet installed as a palette. # Built from the repo root: `docker build -f tests/integration/Dockerfile -t .` # The build context is the whole repo so we can `npm install` edge-bacnet from local source. -FROM nodered/node-red:latest +# Pinned by digest for deterministic CI. Bump after verifying a newer image works. +FROM nodered/node-red:latest@sha256:a5cb1dcdf90a7148b02b9dba4acfe6bd98c7bf3bff1e845dcdecfd7af3a95a29 USER root # Copy the source we want to install (the .dockerignore at the repo root keeps