tentacle-modbus-server

tentacle-modbus-server

Standalone Modbus TCP server that exposes PLC variable data to Modbus TCP clients. The reverse of tentacle-modbus — instead of reading from Modbus devices, it serves data to them.

Use Case

When external systems (HMIs, SCADA, DCS) need to read PLC data via Modbus TCP, this service creates virtual Modbus devices on-demand. Each virtual device maps PLC variables to Modbus registers/coils and serves them over TCP.

Key Architecture Decisions

  1. Stateless, on-demand: Zero devices at startup. Virtual devices are created when modbus-server.subscribe requests arrive via NATS.

  2. Bidirectional: Modbus clients can write to registers/coils. Writes are published back to NATS so the source PLC can act on them.

  3. Register mapping: Each tag is mapped to a specific Modbus address, function code, and datatype. Multi-register types (float32, int32, float64) are handled with configurable byte ordering.

  4. NATS log streaming: All logs published to service.logs.modbus-server.modbus-server for real-time viewing in the web dashboard.

Key Files

FilePurpose
main.tsEntry point, NATS connect, heartbeat, shutdown
src/nats/subscriber.tsServerManager — handles subscribe requests, creates virtual devices
src/server/tcp_server.tsModbus TCP server — accepts connections, handles all 8 function codes
src/server/register_store.tsIn-memory register storage with typed encoding/decoding
src/utils/logger.tsCentralized logger with NATS log streaming
src/types.tsType definitions for subscribe requests and tag configs

NATS Subjects

SubjectDirectionPurpose
modbus-server.subscriberequest/replyCreate a virtual Modbus device
modbus-server.shutdownsubscribeGraceful shutdown
plc.data.{sourceModuleId}.*subscribeReceive PLC variable updates
{sourceModuleId}/{variableId}publishWrite-back from Modbus client
service.logs.modbus-server.modbus-serverpublishLog streaming

Subscribe Request

type SubscribeRequest = {
  deviceId: string;           // Unique ID for this virtual device
  port?: number;              // TCP port to listen on (default: 5020)
  unitId?: number;            // Modbus unit ID (default: 1)
  sourceModuleId: string;     // PLC module to subscribe to for data
  tags: Array<{
    variableId: string;       // PLC variable to map
    address: number;          // Modbus register/coil address (0-based)
    functionCode: "coil" | "discrete" | "holding" | "input";
    datatype: "boolean" | "int16" | "uint16" | "int32" | "uint32" | "float32" | "float64";
    byteOrder?: "ABCD" | "BADC" | "CDAB" | "DCBA";
    writable?: boolean;       // Allow Modbus clients to write (default: false)
  }>;
};

Data Flow

PLC Runtime → NATS (plc.data.{moduleId}.*)
                    ↓
          modbus-server subscribes
                    ↓
          RegisterStore.updateFromVariable()
                    ↓
          Modbus TCP Client reads registers
                    ↓ (on write)
          onWrite → NATS ({moduleId}/{variableId})
                    ↓
          PLC Runtime receives write command

Supported Function Codes

FCNameAccess
01Read CoilsRead
02Read Discrete InputsRead
03Read Holding RegistersRead
04Read Input RegistersRead
05Write Single CoilWrite
06Write Single RegisterWrite
15Write Multiple CoilsWrite
16Write Multiple RegistersWrite

Environment Variables

NATS_SERVERS=localhost:4222          # NATS server URL(s)
SUBSCRIBE_SUBJECT=modbus-server.subscribe  # Subject to listen for subscribe requests

Running

cd tentacle-modbus-server
deno task dev

Testing

deno test --allow-net --allow-env tests/