Skip to content
Open
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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
node_modules
.git
.github
.vscode
.idea
*.log
coverage
docs
examples
images
*.md
.dockerignore
.gitignore
134 changes: 134 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: CI

on:
push:
branches: [master]
Comment thread
ravz marked this conversation as resolved.
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: Node-RED + edge-bacnet vs bacnet-sim-ci
runs-on: ubuntu-latest
needs: smoke
# 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

- 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 "$SIM_IMAGE"
docker run -d \
--name bacnet-sim \
--network bacnet-test-net \
--ip 172.20.0.10 \
--cap-add=NET_ADMIN \
-e BACNET_DEVICE_ID=1001 \
-e BACNET_DEVICE_NAME=TestDevice \
"$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)"
docker exec bacnet-sim curl -s http://localhost:8099/api/devices
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: sim never became ready"
docker logs bacnet-sim
exit 1
fi
sleep 1
done

- name: Build Node-RED + edge-bacnet image
run: docker build -f tests/integration/Dockerfile -t nodered-edgebacnet:test .

- name: Start Node-RED
run: |
docker run -d \
--name nodered \
--network bacnet-test-net \
--ip 172.20.0.20 \
nodered-edgebacnet:test

# 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: |
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "@bitpoolos/edge-bacnet",
"version": "1.6.8",
"description": "A bacnet gateway for node-red",
"scripts": {
"test:unit": "node tests/unit/smoke.js"
},
"dependencies": {
"@plus4nodered/ts-node-bacnet": "^1.0.0-beta.2",
"@vue/server-renderer": "^3.5.13",
Expand Down
28 changes: 28 additions & 0 deletions tests/integration/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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 <tag> .`
# The build context is the whole repo so we can `npm install` edge-bacnet from local source.
# 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
# node_modules / .git / docs out of the layer).
COPY --chown=node-red:node-red . /tmp/edge-bacnet
# 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: <deviceId> - <ip>" 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. Don't change WORKDIR — the base image's entrypoint
# (./entrypoint.sh) is relative to /usr/src/node-red.
USER node-red
# --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
57 changes: 57 additions & 0 deletions tests/integration/flows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[
{
"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": [
{
"enabled": true,
"start": "0",
"end": "4194303"
}
],
"portRangeRegisters": [
{
"enabled": true,
"start": "47808",
"end": "47808"
}
],
"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": [[]]
}
]
55 changes: 55 additions & 0 deletions tests/unit/smoke.js
Original file line number Diff line number Diff line change
@@ -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);
Loading