Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d259d8d
feat: add ACS OPC UA Edge Server integration
AlexGodbehere Feb 17, 2026
1b041b5
chore: add acs-opcua-server-edge to publish workflow
AlexGodbehere Feb 17, 2026
5f2dd7b
MVP
AlexGodbehere Feb 17, 2026
4017bdc
feat: enhance OPC UA server with security policies and data change ev…
AlexGodbehere Feb 17, 2026
c56d2f1
feat: enable dynamic topic handling in OPC UA server
AlexGodbehere Feb 17, 2026
9747047
feat: add TLS support for MQTT connections in OPC UA server
AlexGodbehere Feb 17, 2026
053e907
feat: add OPC UA Server role and Helm chart integration
AlexGodbehere Feb 17, 2026
3bedcec
docs: update OPC UA Server README with deployment and configuration d…
AlexGodbehere Feb 17, 2026
3c9cc49
feat: update OPC UA Server Helm chart with dynamic image configuration
AlexGodbehere Feb 17, 2026
cb78ad8
fix: ensure certificate managers use writable volumes in OPC UA server
AlexGodbehere Feb 17, 2026
382552a
chore: remove unnecessary `user: root` from Docker Compose configuration
AlexGodbehere Feb 17, 2026
63edbc8
feat: integrate ServiceClient for MQTT connection handling in OPC UA …
AlexGodbehere Feb 18, 2026
07b25ab
Switch to LocalSecret for OPC UA credentials
AlexGodbehere Feb 18, 2026
c7e6916
fix: update OPC UA server secrets and remove unused local MQTT config
AlexGodbehere Feb 24, 2026
9b1e232
fix: split OPC UA server secrets into separate username and password …
AlexGodbehere Feb 24, 2026
d83d2ee
docs: add CLAUDE.md to provide guidance for code contributions and pr…
AlexGodbehere Mar 3, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ jobs:
- historian-sparkplug
- historian-uns
- uns-ingester-sparkplug
- acs-opcua-server-edge
permissions:
contents: read
packages: write
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ config.mk
.vscode
.DS_Store
/edge-ads/node_modules
spirit-schemas/
spirit-schemas/
/acs-opcua-server-edge/test/
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the [Factory+](https://factoryplus.app.amrc.co.uk) framework for industrial connectivity and data management. It consists of central cluster services (MQTT broker, Directory, Auth, ConfigDB, Historians, Manager UI) and edge agents that collect data from industrial devices.

## Repository Structure

- `lib/` - Shared libraries (must build first)
- `js-service-client` - Main client library for Factory+ services
- `js-service-api` - Base classes for building service APIs
- `js-edge-driver` - Base driver class for edge translators
- `js-sparkplug-app` - Sparkplug B protocol utilities
- `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities
- `py-edge-driver` - Python edge driver base
- `java-service-client` - Java client library

- `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.)
- `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.)
- `historian-*`, `uns-ingester-*` - Data ingestion services
- `deploy/` - Helm chart for Kubernetes deployment
- `mk/` - Makefile fragments

## JavaScript Services

Most services are ES modules using `@amrc-factoryplus/service-client` for Factory+ integration. Services reference local libraries via `file:../lib/js-*` in package.json.

TypeScript services (like `acs-edge`) use:
```bash
npm run dev # Development with ts-node-dev
npm run build # Compile TypeScript
npm run test # Run Jest tests
```

## Key Patterns

- Services authenticate via Kerberos to the MQTT broker
- Configuration is stored in ConfigDB and accessed via the service client
- Edge agents publish Sparkplug B messages to the MQTT broker
- The `@amrc-factoryplus/service-client` library provides `ServiceClient` class for accessing Factory+ services

## Contributing

- Branch naming: `initials/branch-description` or `feature/xxx` for long-running branches
- Commit messages: imperative mood, explain the "why", reference issues
- Keep PRs focused on a single issue/feature
- Rebase onto `main` rather than merging
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ subdirs+= acs-krb-keys-operator
subdirs+= acs-krb-utils
subdirs+= acs-monitor
subdirs+= acs-mqtt
subdirs+= acs-opcua-server-edge
subdirs+= acs-service-setup
subdirs+= acs-visualiser
subdirs+= deploy
Expand Down
37 changes: 37 additions & 0 deletions acs-opcua-server-edge/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1

ARG base_version
ARG base_prefix=ghcr.io/amrc-factoryplus/acs-base

FROM ${base_prefix}-js-build:${base_version} AS build
ARG acs_npm=NO
ARG TARGETOS
ARG TARGETARCH

USER root
RUN <<'SHELL'
install -d -o node -g node /home/node/app
mkdir /home/node/lib
SHELL
COPY --from=lib . /home/node/lib/

WORKDIR /home/node/app
USER node
COPY package*.json ./
RUN <<'SHELL'
touch /home/node/.npmrc
npm install --save=false --omit=dev --install-links
SHELL
COPY --chown=node . .

FROM ${base_prefix}-js-run:${base_version} AS run
ARG revision=unknown
# Copy across from the build container.
WORKDIR /home/node/app
COPY --from=build --chown=root:root /home/node/app ./
# Do this last to not smash the build cache
RUN <<'SHELL'
echo "export const GIT_VERSION=\"$revision\";" > ./lib/git-version.js
SHELL
USER node
CMD node bin/opcua-server.js
6 changes: 6 additions & 0 deletions acs-opcua-server-edge/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
top=..
include ${top}/mk/acs.init.mk

repo?=acs-opcua-server-edge

include ${mk}/acs.js.mk
74 changes: 74 additions & 0 deletions acs-opcua-server-edge/bin/opcua-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) University of Sheffield AMRC 2026.
*/

/*
* ACS Edge OPC UA Server - Entry Point
*
* Reads configuration from a mounted ConfigMap file and credentials
* from environment variables, initialises the data store, MQTT client
* (via ServiceClient), and OPC UA server.
*/

import fs from "node:fs";

import { ServiceClient } from "@amrc-factoryplus/service-client";

import { DataStore } from "../lib/data-store.js";
import { MqttClient } from "../lib/mqtt-client.js";
import { Server } from "../lib/server.js";

/* Read configuration from files. */
const configFile = process.env.CONFIG_FILE ?? "/config/config.json";
const dataDir = process.env.DATA_DIR ?? "/data";

const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));

/* Read OPC UA credentials from environment variables. */
const opcuaUsername = process.env.OPCUA_USERNAME;
const opcuaPassword = process.env.OPCUA_PASSWORD;

if (!opcuaUsername || !opcuaPassword) {
console.error("OPCUA_USERNAME and OPCUA_PASSWORD must be set");
process.exit(1);
}

/* Build ServiceClient - reads SERVICE_USERNAME, SERVICE_PASSWORD,
* DIRECTORY_URL, VERBOSE from process.env. */
const fplus = await new ServiceClient({ env: process.env }).init();

/* Initialise components. */
const dataStore = new DataStore({ dataDir });
dataStore.start();

const mqttClient = new MqttClient({
fplus,
topics: config.topics,
dataStore,
});

const server = new Server({
port: config.opcua.port,
dataStore,
username: opcuaUsername,
password: opcuaPassword,
allowAnonymous: config.opcua.allowAnonymous ?? false,
});

/* Start everything. */
await mqttClient.start();
await server.start();

console.log(`OPC UA server ready. Subscribed to ${config.topics.length} MQTT topic pattern(s).`);

/* Graceful shutdown. */
const shutdown = async (signal) => {
console.log(`Received ${signal}, shutting down...`);
await server.stop();
await mqttClient.stop();
dataStore.stop();
process.exit(0);
};

process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
26 changes: 26 additions & 0 deletions acs-opcua-server-edge/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
services:
opcua-server:
image: acs-opcua-server-edge:local
platform: linux/amd64
user: root
ports:
- "4840:4840"
environment:
CONFIG_FILE: /config/config.json
DATA_DIR: /data
SERVICE_USERNAME: username
SERVICE_PASSWORD: password
DIRECTORY_URL: https://directory.baseUrl
OPCUA_USERNAME: opcua
OPCUA_PASSWORD: test123
volumes:
- ./test/config.json:/config/config.json:ro
- opcua-data:/data
networks:
- opcua-test

networks:
opcua-test:

volumes:
opcua-data:
89 changes: 89 additions & 0 deletions acs-opcua-server-edge/lib/data-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) University of Sheffield AMRC 2026.
*/

/*
* ACS Edge OPC UA Server - Data Store
*
* In-memory store for tag values with periodic flush to a persistent
* JSON file so values survive pod restarts.
*/

import { EventEmitter } from "node:events";
import fs from "node:fs";
import path from "node:path";

export class DataStore extends EventEmitter {
constructor(opts) {
super();
this.dataDir = opts.dataDir;
this.cacheFile = path.join(this.dataDir, "last-values.json");
this.flushInterval = opts.flushInterval ?? 5000;

this.values = new Map();
this.dirty = false;
this.timer = null;
}

load() {
try {
if (fs.existsSync(this.cacheFile)) {
const data = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8"));
for (const [topic, entry] of Object.entries(data)) {
this.values.set(topic, entry);
}
console.log(`Loaded ${this.values.size} cached values from ${this.cacheFile}`);
}
}
catch (err) {
console.error(`Error loading cache file: ${err.message}`);
}
}

start() {
this.load();
this.timer = setInterval(() => this.flush(), this.flushInterval);
}

stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.flush();
}

set(topic, value, timestamp) {
const entry = {
value,
timestamp: timestamp ?? new Date().toISOString(),
};
this.values.set(topic, entry);
this.dirty = true;
this.emit("change", topic, entry);
}

get(topic) {
const entry = this.values.get(topic);
return entry ?? null;
}

topics() {
return this.values.keys();
}

flush() {
if (!this.dirty) return;

try {
const data = Object.fromEntries(this.values);
const tmp = this.cacheFile + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, this.cacheFile);
this.dirty = false;
}
catch (err) {
console.error(`Error flushing cache: ${err.message}`);
}
}
}
Loading