tentacle-plc

tentacle-plc

Lightweight PLC runtime library with task-based programming. Published to JSR as @joyautomation/tentacle-plc.

Overview

A library (not a standalone service) for creating soft PLC logic in TypeScript/Deno. Manages variables, scan tasks, NATS integration, and sourcing values from protocol scanner services.

Runs as tentacle-demo in the dev environment — the demo project is the reference implementation of a tentacle-plc application.

Quick Start

The fastest way to create a new PLC project:

deno run -A jsr:@joyautomation/create-tentacle-plc my-plc
cd my-plc
deno task dev

This scaffolds a project with example variables, tasks, and NATS integration.

Usage

import { createPlc } from "@joyautomation/tentacle-plc";

const plc = await createPlc({
  projectId: "my-project",
  nats: { servers: "nats://localhost:4222" },
  variables: {
    temperature: {
      id: "temperature",
      description: "Process temperature",
      datatype: "number",
      default: 0,
      source: eipTag(myPlc, "Temperature"),
    },
  },
  tasks: {
    main: {
      scanRate: 100,
      program: (plc) => {
        // Logic runs every 100ms
      },
    },
  },
});

Variable Sources

Variables can be sourced from any protocol scanner service. The PLC handles subscription management, reconnection, and retry automatically.

EtherNet/IP

import { eipTag } from "@joyautomation/tentacle-plc";
import { rtu45 } from "./generated/ethernetip.ts";

source: eipTag(rtu45, "Program:MainProgram.Motor_Speed")
// subscribes to: ethernetip.data.{deviceId}.{tag}

Codegen (requires running tentacle-ethernetip + live device):

import { generateEipTypes } from "@joyautomation/tentacle-plc/codegen";
await generateEipTypes({
  nats: { servers: "nats://localhost:4222" },
  devices: [{ id: "rtu45", host: "192.168.1.10" }],
  outputDir: "./generated",
});

OPC UA

import { opcuaTag } from "@joyautomation/tentacle-plc";
import { myServer } from "./generated/opcua.ts";

source: opcuaTag(myServer, "ns=2;s=Temperature")
// subscribes to: opcua.data.{deviceId}.{sanitizedNodeId}

Codegen (requires running tentacle-opcua-go + live server):

import { generateOpcuaTypes } from "@joyautomation/tentacle-plc/codegen";
await generateOpcuaTypes({
  nats: { servers: "nats://localhost:4222" },
  devices: [{ id: "ignition", endpointUrl: "opc.tcp://ignition:62541" }],
  outputDir: "./generated",
});

Modbus TCP

import { modbusTag } from "@joyautomation/tentacle-plc";
import { pumpSkid } from "./generated/modbus.ts";

source: modbusTag(pumpSkid, "pump_speed")
// subscribes to: modbus.data.{deviceId}  (filters by variableId in message body)

Codegen (no live connection needed — define the register map directly):

import { generateModbusTypes } from "@joyautomation/tentacle-plc/codegen";
await generateModbusTypes({
  devices: [{
    id: "pump-skid",
    host: "192.168.1.100",
    port: 502,
    unitId: 1,
    byteOrder: "ABCD",
    tags: [
      { id: "pump_speed",   address: 0, functionCode: "holding", datatype: "float32" },
      { id: "pump_running", address: 0, functionCode: "coil",    datatype: "boolean" },
      { id: "tank_level",   address: 2, functionCode: "holding", datatype: "uint16",
        byteOrder: "BADC" },  // tag-level byte order override
    ],
  }],
  outputDir: "./generated",
});

The generated file (generated/modbus.ts) has full addressing info per tag and is type-safe:

export const pump_skid = {
  id: "pump-skid", host: "192.168.1.100", port: 502, unitId: 1, byteOrder: "ABCD",
  tags: {
    "pump_speed":   { datatype: "number",  address: 0, functionCode: "holding", modbusDatatype: "float32", byteOrder: "ABCD" },
    "pump_running": { datatype: "boolean", address: 0, functionCode: "coil",    modbusDatatype: "boolean", byteOrder: "ABCD" },
    "tank_level":   { datatype: "number",  address: 2, functionCode: "holding", modbusDatatype: "uint16",  byteOrder: "BADC" },
  },
} as const;

modbusTag(pumpSkid, "pump_speed") — tag name is autocompleted and compile-checked against the generated constant.

Variable Datatypes

DatatypeTypeScriptSparkplug B
"number"numberFloat/Double/Int
"boolean"booleanBoolean
"string"stringString
"udt"Record<string, unknown>Template Instance

Sparkplug B UDT Templates

For variables that map to structured data types in Sparkplug B:

{
  id: "motor",
  datatype: "udt",
  default: { speed: 0, running: false },
  udtTemplate: {
    name: "Motor",
    version: "1",
    members: [
      { name: "speed",   datatype: "number" },
      { name: "running", datatype: "boolean" },
    ],
  },
}

When udtTemplate is set, tentacle-mqtt publishes this variable as a Sparkplug B Template Instance rather than a JSON string.

Source vs Sparkplug B Types

The comms layer (EIP/OPC-UA/Modbus sources) and the Sparkplug B layer are separate concerns:

  • Protocol sources expose what the downstream device publishes — these are raw inputs
  • tentacle-plc maps/transforms/composes them into variables
  • Variables are then optionally published upstream as Sparkplug B metrics or UDTs via tentacle-mqtt

Two common patterns:

  1. Mirror: Device structure maps closely to Sparkplug B → codegen output can serve as a starting point for UDT definitions
  2. Compose: Multiple sources + internal logic produce new types → Sparkplug B UDTs are defined independently

NATS Integration

  • Variables publish to {projectId}.data.{variableId} on change
  • State persisted to KV bucket plc-variables-{projectId}
  • Heartbeat published to service_heartbeats KV (10s interval, 60s TTL)
  • Listens for {projectId}.shutdown for graceful shutdown
  • Retries protocol scanner subscriptions every 10s if the scanner isn't available yet

Key Files

FilePurpose
nats.tsNATS connection, source subscriptions (EIP/OPC-UA/Modbus), variable publishing
codegen.tsgenerateEipTypes, generateOpcuaTypes, generateModbusTypes
ethernetip.tseipTag() helper + EipDevice type
opcua.tsopcuaTag() helper + OpcUaDevice type
modbus.tsmodbusTag() helper + ModbusDevice type
types/variables.tsAll variable config and runtime types