What is Tentacle?

A modern, open source, Soft PLC

Getting Started

Tentacle is a modern Soft PLC, programmed with Typescript, that:

  • Automatically publishes all variables to MQTT (using the Sparkplug B specification)
  • Is designed to run in container environments (Docker & Kubernetes)
  • Has a built in GraphQL API with Subscriptions
  • Works with all modern Typescript IDEs, Modern Version Control (like Git), and CI/CD systems (like Github Actions)
  • Allows you to mass generate external sources of data and variables through the use of functions

Why Tentacle?

Industrial automation has been dominated by proprietary hardware & software for decades and the companies that provide these products have focused their energy on locking customers into their ecosystems, complex licensing models to increase their profits, and making their products cheaper while selling them at a premium "industrial" price. As a result, industrial technology lags so far behind mainstream technology. Innovation energy is simply spent on increasing and ensuring profits instead of making the technology better.

The PLC (Programmable Logic Controller) might be the epitomal example of this. The PLC market is dominated by a few companies, and each of their offerings pulls you into their ecosystem further, locking you in with proprietary protocols that present a mirage of a well-functioning ecosystem. You only find out how the ecosystem really functions by buying in. Meanwhile each purchase is so expensive that the end user quickly suffers the emotional effect of the sunk cost fallacy and it's very hard to get out.

It doesn't need to be this way.

Programmable Logic Controllers are, frankly, nothing special. All they do is run code in an infinite loop. A thing you can do in any modern programming language, but the current proprietary technologies don't run modern programming languages (See the Why Not IEC 61131-3? section).

Tentacle allows you to run a PLC within any modern Linux environment. Our favorite place to run it is in a container in Docker or Kubernetes.

Why Not IEC 61131-3?

Industrial PLC vendors have "standardized" on a set of graphical programming languages (and a couple limited text based ones). These languages are codified by the IEC 61131-3 standard, but they are far from being an actual standard. PLC vendors have implemented their own proprietary versions of these programming languages into their own proprietary IDEs, that are typically also licensed and cost thousands of dollars per user. The storied history, as I understand it, is that LAD (ladder diagram) was the first of these languages and was designed to make it easier for electricians to program PLCs by emulating circuit logic, something they already understood well. From there, the standard added a text based programming language (structured text) for flexibility, FBD (function block diagram), SFC (Sequence Function Chart) which is better for state machine/batch processing, and more.

For Tentacle, we've decided to abandon the standard and embrace modern Javascript. So the natural follow up question is, why? Let me tell you 😃!

Let's start with another question: What is the point of a graphical programming language? The only reason one would need a graphical programming language is to make code easier to parse quickly. If a graphical programming language is to be worthwhile, I should be able to read it and understand it faster than it would take me to read and understand code written in any alternative format.

The other thing that needs to be taken into consideration, is that a graphical programming language would also need to have little to no negative consequences to using it when compared to alternatives. It shouldn't:

  • Take me longer to write
  • Be harder to write abstractions
  • Limit my ability to use modern programming innovations (like version control, CI/CD, AI, etc.)

We have written thousands of lines of Ladder Logic, FBD, and ST. We've also written thousands of lines of Javascript and Python. IEC61131-3 languages do not achieve their goal of making code easier to parse quickly, in fact for sufficiently complex applications they actually make it more difficult. Also IEC 61131-3 languages are more expensive to write, taking more time, harder to write quality code, and limiting the ability to use advanced tooling.

Moreover, IEC 61131-3 is not even a good standard. To illustrate this, let's compare it to web standards. You can use Chrome, Safari, Opera, Firefox or a plethora of other web browsers and they will all interpret the HTML, CSS, and Javascript of most web pages nearly identically. By developing applications in those standards you are guaranteed some standard of functionality no matter which client your users prefer. With IEC 61131-3, you are guaranteed nothing other than that your code will work with the specific manufacturer for which you developed that code. Moving your code to another platform is expensive and on the verge of cost/time prohibitive. On top of that, if you learn a certain vendor's version of IEC 61131-3, you can't even expect to be able to understand another vendor's implementation. They are typically so different, you have to enter a lengthy learning curve all over again. That makes IEC 61131-3 nearly worthless as a standard.

We don't need IEC 61131-3. It's been contaminated by greedy vendors and lost its purpose. Throw it out.

Source

https://github.com/joyautomation/tentacle

Installation

deno add jsr:@joyautomation/tentacle

Quick start

Install Deno per the instructions in Deno's docs.

Create a deno project:

deno init my-tentacle // replace my-tentacle with your project name

Navigate into the project directory and delete the main.test.ts file:

cd my-tentacle
rm main_test.ts

Edit the deno.json file to look like this:

{
	"tasks": {
		"dev": "deno run -A --watch main.ts"
	},
	"imports": {
		"@std/assert": "jsr:@std/assert@1"
	},
	"nodeModulesDir": "auto"
}

Note that we're adding -A to the dev task so it runs with full permissions when running in development mode, and we're adding the "nodeModulesDir": "auto" so deno uses a node_modules directory for compatibility with other tools.

Install dependencies:

deno add jsr:@joyautomation/tentacle
deno install --allow-scripts=npm:protobufjs //allows profobufjs to run it's build scripts

Replace the contents of main.ts with the following:

import {
  createTentacle,
  PlcMqtts,
  PlcSources,
  PlcVariableNumber,
} from "@joyautomation/tentacle";

type Mqtts = PlcMqtts;
type Sources = PlcSources;
type Variables = {
  count: PlcVariableNumber;
};

const main = await createTentacle<Mqtts, Sources, Variables>({
  tasks: {
    main: {
      name: "main",
      description: "The main task",
      scanRate: 1000,
      program: (variables, setVar) => {
        setVar("count", variables.count.value + 1);
      },
    },
  },
  mqtt: {},
  sources: {},
  variables: {
    count: {
      id: "count",
      description: "Keeps count of how many time the main task has run",
      datatype: "number",
      default: 0,
      decimals: 0,
    },
  },
});

main();

Now you can run:

deno run -A dev

and you should see something like:

Task dev deno run -A --watch main.ts
Watcher Process started.
2025-06-15 09:36:02 | tentacle         tentacle graphQL api is running on 0.0.0.0:4123

Now you can open http://localhost:4123/graphql in your browser to view the GraphQL API. It should look like this:

Tentacle GraphiQL

Paste this query into the left panel and press the play button:

subscription {
	plc {
		runtime {
			variables {
				id
				value
			}
		}
	}
}

You should see a stream of updates to the count variable in the right panel that look like this:

{
	"plc": {
		"runtime": {
			"variables": {
				"id": "count",
				"value": 1 // This will be continuously updating.
			}
		}
	}
}

Tentacle GraphiQL Subscription to Count Variable

Bonus: Tentacle UI

Tentacle UI is the best way to monitor Tentacle, and interact with Tentacle variables. The best way to run it is in docker.

So first install docker engine using the instructions in the Docker docs.

then I find it easiest to run with docker compose. So create this docker-compose.yaml file:

services:
  tentacle-ui:
    image: joyautomation/tentacle-ui:v0.0.4
    ports:
      - 3000:3000
    restart: always
    environment:
      - TENTACLE_HOST=localhost
      - TENTACLE_PORT=4123
      - TENTACLE_PROTOCOL=http
      - TENTACLE_URL=/graphql

then run:

docker compose up -d

You can now open http://localhost:3000 in your browser to view the Tentacle UI.

You can also run Tentacle UI with just the docker run command, if you want to do it in a more adhoc way:

docker run -d -p 3000:3000 -e TENTACLE_HOST=localhost -e TENTACLE_PORT=4123 -e TENTACLE_PROTOCOL=http -e TENTACLE_URL=/graphql joyautomation/tentacle-ui:v0.0.4

Whichever way you choose, it will look like this:

Tentacle UI

Variables

Tentacle variables are the main building blocks of Tentacle. They are analogous to tags in a PLC program. The best way to start programming a new Tentacle PLC is by defining your variables in the Variables generic type that you will pass to the createTentacle function, after which your development tooling will be able to give you autocomplete and suggestions while you program your tasks.

To do this you can start by creating a Variables type that will hold all your variable definitions. See Types for more information.

Here's an example of how to define a basic Variables type and the variables object:

import { PlcVariableNumber } from "@joyautomation/tentacle";

export type Variables = {
  count: PlcVariableNumber;
};

const variables: Variables = {
  count: {
    id: "count",
    description: "Keeps count of how many time the main task has run",
    datatype: "number",
    default: 0,
    decimals: 0,
  }
}

So we're defining a single internal variable called count of type PlcVariableNumber. There are four atomic types from which all of the other variable types are derived in tentacle:

TypeDescription
PlcVariableNumberA variable with a value that's a javascript number
PlcVariableBooleanA variable with a value that's a javascript boolean
PlcVariableStringA variable with a value that's a javascript string
PlcVariableUdtA variable with a value that's a javascript object (defined by a generic type)

All variables have the following properties:

PropertyDescription
idThe id of the variable
descriptionThe description of the variable
publishRateHow often the variable should be published to MQTT
datatypeThe datatype of the variable ('number', 'boolean', 'string')
defaultThe default value of the variable, will be restricted to the type defined by datatype

PlcVariableNumber variables have the following additional properties:

PropertyDescription
decimalsThe number of decimal places to display
deadband.valueThe value of the deadband, where if the value changes by more than this amount, the variable will be published
deadband.maxTimeThe maximum time between updates, even if the value hasn't changed beyond the deadband value

Each atomic variable type can be modified to have a source, See Sources for more information.

Sources

Sources are external sources of data that can be used to set the value of a variable or to have a variable value written to an external system. Tentacle supports the following sources:

TypeDescriptionStatus
MqttMQTT SourceAvailable
RestHTTP (REST) SourceAvailable
RedisRedis SourceAvailable
ModbusModbus SourceAvailable
OPCUAOPC UA SourceFuture
Ethernet/IPEthernet/IP SourceFuture

MQTT sources use one of the brokers defined by the Mqtts type (see Mqtt). Rest sources are defined on a per variable basis Rest. All other sources are defined in the sources property that is passed to the createTentacle function.

You start by creating a Sources type that will hold all your source definitions and that you can pass as the Sources generic to the Variables type. Then you can define the actual sources and variables object that will be passed to createTentacle:

import { PlcModbus, PlcVariableNumberWithModbusSource, PlcSources } from "@joyautomation/tentacle";

export type Sources = {
  myModbusDevice: PlcModbus;
};

export type Variables<S extends PlcSources> = {
  myModbusVariable: PlcVariableNumberWithModbusSource<S>;
};

const sources: Sources = {
  myModbusDevice: {
    id: "myModbusDevice",
    type: "modbus",
    host: "localhost", //also obviously can be an external IP Address or hostname
    port: 502,
    timeout: 1000,
    unitId: 1,
  },
};

const variables: Variables<Sources> = {
  myModbusVariable: {
    id: "myModbusVariable",
    description: "A variable that gets its value from a Modbus source",
    datatype: "number",
    default: 0,
    decimals: 0,
    source: {
      id: "myModbusDevice",
      type: "modbus",
      rate: 1000,
      bidirectional: false,
      register: 1,
      registerType: "HOLDING_REGISTER",
      format: "INT16",
      onResponse: (value:number) => value
    },
  },
};

Because of how Tentacle's types are defined, your development environment will highlight the error if you try to set the source id to a source that doesn't exist in the Sources type.

Mqtt

You can setup one or many MQTT servers to which Tentacle will automatically publish all of it's variables to when they change. In the case of numeric variables you can use the deadband settings to control publish settings:

  • deadband.value: This is the amount the value has to change to trigger a publish
  • deadband.maxTime: If the value doesn't change beyond the deadband for this amount of time, tentacle will publish the value anyway

Mqtt servers require their own type, because the can also be used as a source of data to feed Variables of the WithMqttSource type.

Here's an example, types.ts:

import { PlcMqtts, MqttConnection } from "@joyautomation/tentacle";

export type Mqtts = {
  myMqtt: MqttConnection;
};

export type Variables<M extends PlcMqtts> = {
  myMqttVariable: PlcVariableNumberWithMqttSource<M>;
};

const mqtts: Mqtts = {
  myMqtt: {
    id: "myMqtt",
    host: "localhost",
    port: 1883,
    username: "username",
    password: "password",
  },
};

const variables: Variables<Mqtts> = {
  myMqttVariable: {
    id: "myMqttVariable",
    datatype: "number" as const,
    decimals: 2,
    description: "A variable that gets its value from an MQTT source",
    default: 0,
    deadband: {
      maxTime: 60000,
      value: 0.1,
    },
    source: {
      id: "myMqtt",
      type: "mqtt" as const,
      topic: "my/topic",
      onResponse: (value:number) => value
    },
  },
};

Rest

You can source a variable from a REST endpoint:

import { PlcVariableNumberWithRestSource } from "@joyautomation/tentacle";

export type Variables = {
  myRestVariable: PlcVariableNumberWithRestSource;
};

const variables: Variables = {
  myRestVariable: {
    id: "myRestVariable",
    datatype: "number" as const,
    decimals: 2,
    description: "A variable that gets its value from a REST source",
    default: 0,
    deadband: {
      maxTime: 60000,
      value: 0.1,
    },
    source: {
      id: "myRest",
      type: "rest" as const,
      method: "GET",
      url: "http://localhost:3000",
      onResponse: (value:string):number => parseFloat(value)
    },
  },
}

Redis

You can source a variable from a Redis (or redis compatible alternative, like Valkey) key:

import { PlcVariableNumberWithRedisSource } from "@joyautomation/tentacle";

export type Variables = {
  myRedisVariable: PlcVariableNumberWithRedisSource;
};

const variables: Variables = {
  myRedisVariable: {
    id: "myRedisVariable",
    datatype: "number" as const,
    decimals: 2,
    description: "A variable that gets its value from a Redis source",
    default: 0,
    deadband: {
      maxTime: 60000,
      value: 0.1,
    },
    source: {
      id: "myRedis",
      type: "redis" as const,
      key: "myKey",
      onResponse: (value:string):number => parseFloat(value)
    },
  },
};

Example With Many Variables of Different Types

Please refer to the Programmatically Generating Variables section for a comprehensive example that demonstrates how to work with multiple variables of different types.

Programmatically Generating Variables

Because the createTentacle function just expects Typescript objects to define mqtt connections, sources, and variables, all of these things can be generated programmatically with functions. Historically SCADA software has not been able to generate variables automatically, instead relying on templating and UDTs, which is a more Object Oriented approach. Using functional programming concepts to generate variables is far more powerful, flexible and maintainable.

Let's work with a simple practical example. Say we have twenty identical motors we want to control manually. In a traditional PLC system, you would identify the variables, create a UDT and manually instantiate each UDT. In tentacle PLC we can use functions to automate all of the tag creation.

Let's say we know each motor should have the following variables:

Variable NameTypeDescription
remoteBoolean With Modbus SourceWhether the motor is in local or remote control (true = remote, false = local)
runningBoolean With Modbus SourceWhether the motor is running or not (true = running, false = stopped)
startBoolean With Modbus SourceOutput to the motor to start it (true = on, false = off)
startCommandBooleanInternal momentary variable to start the motor
stopCommandBooleanInternal momentary variable to stop the motor

To explain more, each of these motors is controlled manually with start/stop commands and we have a modbus remote I/O module that all of these motors are connected to.

To auto generate variables in Tentacle for the motors, we'll first define a type to hold the motor Ids. In our case we'll choose motor1, motor2, motor3, and so forth. We'll start a main.ts with the following:

// main.ts
import { PlcModbusSource, PlcVariableBoolean, PlcVariableBooleanWithModbusSource } from "@joyautomation/tentacle";

// Define the source for the motor I/O module in the Sources type
export type Sources = {
  motorModbus: PlcModbusSource;
};

// Create the sources javascript object
const sources: Sources = {
  motorModbus: {
    id: "motorModbus",
    type: "modbus",
    enabled: true,
    name: "Motor Modbus",
    description: "Modbus source for motors",
    host: "127.0.0.1",
    port: 502,
    timeout: 1000,
    retryMinDelay: 1000,
    retryMaxDelay: 60000,
    unitId: 1,
    reverseBits: false,
    reverseWords: false,
  },
};

// Some helpers to generate the motor sequential motor Ids
type Range<F extends number, T extends number> =
  | Exclude<Enumerate<T extends number ? T : never>, Enumerate<F>>
  | F
  | T;
type Enumerate<
  N extends number,
  Acc extends number[] = []
> = Acc["length"] extends N
  ? Acc[number]
  : Enumerate<N, [...Acc, Acc["length"]]>;

// Create a type to hold the motor IDs
// gives us motor1, motor2, ... motor20
export type MotorIds = `motor${Range<1, 20>}`;

Then we'll create some ids for the variables that each motor needs, split up by variable type. We'll use that and the MotorIds type we just created to define an overall type to hold all motor variables called MotorVariables:

//... continuing from main.ts above
type MotorBooleanVariables = `startCommand` | `stopCommand`;
type MotorBooleanWithModbusSourceVariables = `start` | `running` | `remote`;

type MotorVariables = {
  [key in `${MotorIds}.${MotorBooleanVariables}`]: PlcVariableBoolean;
} & {
  [key in `${MotorIds}.${MotorBooleanWithModbusSourceVariables}`]: PlcVariableBooleanWithModbusSource<Sources>;
};

The above results in a MotorVariables type that looks like this:

type MotorVariables = {
    "motor1.startCommand": PlcVariableBoolean;
    "motor1.stopCommand": PlcVariableBoolean;
    "motor20.startCommand": PlcVariableBoolean;
    ... 36 more ...;
    "motor19.stopCommand": PlcVariableBoolean;
} & {
    ...;
}

Now we can get started on defining the actual Javascript object that is going to hold our variables. First we'll create functions that generate all the things each type of variables have in common. We'll do this by creating two functions, generateBooleanVariable and generateBooleanWithModbusSourceVariable:

//... continuing from main.ts above

const generateBooleanVariable = (
  config: { id: string } & Partial<PlcVariableBoolean>
): PlcVariableBoolean => ({
  description: `Description was not provided.`,
  datatype: "boolean" as const,
  default: false,
  ...config,
});

const generateBooleanWithModbusSourceVariable = (config: {
  id: string;
  description?: string;
  default?: boolean;
  source: Partial<PlcVariableBooleanWithModbusSource<Sources>["source"]>;
}): PlcVariableBooleanWithModbusSource<Sources> => ({
  description: `Description was not provided.`,
  datatype: "boolean" as const,
  default: false,
  source: {
    id: "motorModbus",
    type: "modbus",
    rate: 1000,
    register: 0,
    registerType: "COIL",
    format: "INT16",
    ...config.source,
  },
});

These functions will allow us to generate variables with intelligent defaults, but also allow us to override properties as needed. The most common things that need to be overridden are the description and the register property of the source for modbus variables. So lets create a motor config object that we'll use to set the configurable properties. For simplicities sake, we'll assume that each of the modbus registers we're using are sequential, starting at 1. With that in mind, it will be easiest to create a function to generate these called getMotorConfig:

//... continuing from main.ts above

// The motor config type will hold the register numbers for each motor by key
// { motor1: { start: 1, running: 2, remote: 3 }, ... }
type MotorConfig = {
  [key in motorIds]: {
    [key in MotorBooleanWithModbusSourceVariables]: number;
  };
};

// Type guard function for MotorConfig
// we do this to ensure that the config we're generating is valid. It ensures our code
// works properly even when we use highly dynamic functions like Object.fromEntries
function isMotorConfig(obj: unknown): obj is MotorConfig {
  if (!obj || typeof obj !== "object") return false;

  // Check that all required motor keys exist and no more than necessary
  if (Object.keys(obj).length !== 20) return false;

  // Check that all required motor keys exist
  for (let i = 1; i <= 20; i++) {
    const motorKey = `motor${i}`;

    // Check if the motor key exists
    if (!(motorKey in obj)) return false;

    const motor = (obj as Record<string, unknown>)[motorKey];
    if (!motor || typeof motor !== "object") return false;

    // Check that all required properties exist and are numbers
    const requiredProps: MotorBooleanWithModbusSourceVariables[] = [
      "start",
      "running",
      "remote",
    ];
    for (const prop of requiredProps) {
      if (
        !(prop in motor) ||
        typeof (motor as Record<string, unknown>)[prop] !== "number"
      ) {
        return false;
      }
    }
  }

  return true;
}

const getMotorConfig = (): MotorConfig => {
  // Create the config using Object.fromEntries
  const config = Object.fromEntries(
    Array.from({ length: 20 }, (_, i) => {
      return [
        `motor${i + 1}`,
        {
          start: 3 * i + 1,
          running: 3 * i + 2,
          remote: 3 * i + 3,
        },
      ];
    })
  );

  // Validate the config using the type guard
  // you can throw an error to stop Tentacle from running with a valid config
  // or you could squash the error and return an empty object if you want it to
  // run even in the event of an error.
  if (!isMotorConfig(config)) {
    throw new Error("Invalid motor configuration generated");
  }

  // Now TypeScript knows config is of type MotorConfig
  return config;
};

// This will give us a MotorConfig object that we can use to generate variables
// { motor1: { start: 1, running: 2, remote: 3 }, ... }
const motorConfig = getMotorConfig();

Now that we have the motor config, we can use it to generate the variables:

//... continuing from main.ts above

// Type guard function for MotorVariables
// we do this to ensure that the config we're generating is valid. It ensures our code
// works properly even when we use highly dynamic functions like Object.fromEntries
const isMotorVariables = (obj: unknown): obj is MotorVariables => {
  if (!obj || typeof obj !== "object") return false;

  for (const key of Object.keys(obj)) {
    const variable = (obj as Record<string, unknown>)[key];
    if (!variable || typeof variable !== "object") return false;
    if (!("id" in variable) || typeof variable.id !== "string") return false;
    if (
      !("description" in variable) ||
      typeof variable.description !== "string"
    )
      return false;
    if (!("datatype" in variable) || variable.datatype !== "boolean")
      return false;
    if (!("default" in variable) || typeof variable.default !== "boolean")
      return false;
  }

  return true;
};

// Helper function to convert camelCase to title case
// It helps us generate descriptions from our keys
const camelCaseToTitleCase = (str: string): string => {
  return str
    .replace(/([A-Z0-9])|([0-9][a-zA-Z])/g, " $1$2")
    .trim()
    .split(" ")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
    .join(" ");
};

// Generate our variables. This is where all of our helper functions and types come together to
// make a simple dynamic function that generates our variables
const getMotorVariables = (): MotorVariables => {
  const variables = Object.fromEntries(
    Object.entries(motorConfig).flatMap(([motorId, config]) => {
      return [
        ...["startCommand", "stopCommand"].map((key) => [
          `${motorId}.${key}`,
          generateBooleanVariable({
            id: `${motorId}.${key}`,
            description: `${camelCaseToTitleCase(
              motorId
            )} ${camelCaseToTitleCase(key)}`,
          }),
        ]),
        ...Object.entries(config).map(([key, value]) => [
          `${motorId}.${key}`,
          generateBooleanWithModbusSourceVariable({
            id: `${motorId}.${key}`,
            description: `${camelCaseToTitleCase(
              motorId
            )} ${camelCaseToTitleCase(key)}`,
            source: { register: value },
          }),
        ]),
      ];
    })
  );

  if (!isMotorVariables(variables)) {
    throw new Error("Invalid motor variables generated");
  }

  return variables;
};

// Export an object with our motor variables
// We can be confident it will look like this:
// { "motor1.startCommand": PlcVariableBoolean, ... }
export const motorVariables = getMotorVariables();

Now we can use the createTentacle function to create a Tentacle instance with our sources and variables:

//... continuing from main.ts above

// Create the Tentacle instance
const main = await createTentacle<PlcMqtts, Sources, MotorVariables>({
  tasks: {
    main: {
      name: "main",
      description: "The main task",
      scanRate: 1000,
      program: () => {},
    },
  },
  mqtt: {},
  sources,
  variables: motorVariables,
});

// Start Tentacle
main();

Here's the complete example:

import {
  createTentacle,
  PlcModbusSource,
  PlcMqtts,
  PlcVariableBoolean,
  PlcVariableBooleanWithModbusSource,
  PlcVariableNumber,
} from "@joyautomation/tentacle";

type Sources = {
  motorModbus: PlcModbusSource;
};

const sources: Sources = {
  motorModbus: {
    id: "motorModbus",
    type: "modbus",
    enabled: true,
    name: "Motor Modbus",
    description: "Modbus source for motors",
    host: "127.0.0.1",
    port: 502,
    timeout: 1000,
    retryMinDelay: 1000,
    retryMaxDelay: 60000,
    unitId: 1,
    reverseBits: false,
    reverseWords: false,
  },
};

type Variables = {
  count: PlcVariableNumber;
};

type Range<F extends number, T extends number> =
  | Exclude<Enumerate<T extends number ? T : never>, Enumerate<F>>
  | F
  | T;
type Enumerate<
  N extends number,
  Acc extends number[] = []
> = Acc["length"] extends N
  ? Acc[number]
  : Enumerate<N, [...Acc, Acc["length"]]>;

type MotorIds = `motor${Range<1, 20>}`;

type MotorBooleanVariables = `startCommand` | `stopCommand`;
type MotorBooleanWithModbusSourceVariables = `start` | `running` | `remote`;

type MotorVariables = {
  [key in `${motorIds}.${MotorBooleanVariables}`]: PlcVariableBoolean;
} & {
  [key in `${motorIds}.${MotorBooleanWithModbusSourceVariables}`]: PlcVariableBooleanWithModbusSource<Sources>;
};

const generateBooleanVariable = (
  config: { id: string } & Partial<PlcVariableBoolean>
): PlcVariableBoolean => ({
  description: `Description was not provided.`,
  datatype: "boolean" as const,
  default: false,
  ...config,
});

const generateBooleanWithModbusSourceVariable = (config: {
  id: string;
  description?: string;
  default?: boolean;
  source: Partial<PlcVariableBooleanWithModbusSource<Sources>["source"]>;
}): PlcVariableBooleanWithModbusSource<Sources> => ({
  id: config.id,
  description: config.description ?? "Description was not provided.",
  datatype: "boolean" as const,
  default: false,
  source: {
    id: "motorModbus",
    type: "modbus",
    rate: 1000,
    register: 0,
    registerType: "COIL",
    format: "INT16",
    ...config.source,
  },
});

type MotorConfig = {
  [key in motorIds]: {
    [key in MotorBooleanWithModbusSourceVariables]: number;
  };
};

// Type guard function for MotorConfig
function isMotorConfig(obj: unknown): obj is MotorConfig {
  if (!obj || typeof obj !== "object") return false;

  // Check that all required motor keys exist and no more than necessary
  if (Object.keys(obj).length !== 20) return false;

  // Check that all required motor keys exist
  for (let i = 1; i <= 20; i++) {
    const motorKey = `motor${i}`;

    // Check if the motor key exists
    if (!(motorKey in obj)) return false;

    const motor = (obj as Record<string, unknown>)[motorKey];
    if (!motor || typeof motor !== "object") return false;

    // Check that all required properties exist and are numbers
    const requiredProps: MotorBooleanWithModbusSourceVariables[] = [
      "start",
      "running",
      "remote",
    ];
    for (const prop of requiredProps) {
      if (
        !(prop in motor) ||
        typeof (motor as Record<string, unknown>)[prop] !== "number"
      ) {
        return false;
      }
    }
  }

  return true;
}

const getMotorConfig = (): MotorConfig => {
  // Create the config using Object.fromEntries
  const config = Object.fromEntries(
    Array.from({ length: 20 }, (_, i) => {
      return [
        `motor${i + 1}`,
        {
          start: 3 * i + 1,
          running: 3 * i + 2,
          remote: 3 * i + 3,
        },
      ];
    })
  );

  // Validate the config using the type guard
  if (!isMotorConfig(config)) {
    throw new Error("Invalid motor configuration generated");
  }

  // Now TypeScript knows config is of type MotorConfig
  return config;
};

const motorConfig = getMotorConfig();

const isMotorVariables = (obj: unknown): obj is MotorVariables => {
  if (!obj || typeof obj !== "object") return false;

  for (const key of Object.keys(obj)) {
    const variable = (obj as Record<string, unknown>)[key];
    if (!variable || typeof variable !== "object") return false;
    if (!("id" in variable) || typeof variable.id !== "string") return false;
    if (
      !("description" in variable) ||
      typeof variable.description !== "string"
    )
      return false;
    if (!("datatype" in variable) || variable.datatype !== "boolean")
      return false;
    if (!("default" in variable) || typeof variable.default !== "boolean")
      return false;
  }

  return true;
};

const camelCaseToTitleCase = (str: string): string => {
  return str
    .replace(/([A-Z0-9])|([0-9][a-zA-Z])/g, " $1$2")
    .trim()
    .split(" ")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
    .join(" ");
};

const getMotorVariables = (): MotorVariables => {
  const variables = Object.fromEntries(
    Object.entries(motorConfig).flatMap(([motorId, config]) => {
      return [
        ...["startCommand", "stopCommand"].map((key) => [
          `${motorId}.${key}`,
          generateBooleanVariable({
            id: `${motorId}.${key}`,
            description: `${camelCaseToTitleCase(
              motorId
            )} ${camelCaseToTitleCase(key)}`,
          }),
        ]),
        ...Object.entries(config).map(([key, value]) => [
          `${motorId}.${key}`,
          generateBooleanWithModbusSourceVariable({
            id: `${motorId}.${key}`,
            description: `${camelCaseToTitleCase(
              motorId
            )} ${camelCaseToTitleCase(key)}`,
            source: { register: value },
          }),
        ]),
      ];
    })
  );

  if (!isMotorVariables(variables)) {
    throw new Error("Invalid motor variables generated");
  }

  return variables;
};

export const motorVariables = getMotorVariables();

console.log(motorVariables);

const main = await createTentacle<PlcMqtts, Sources, MotorVariables>({
  tasks: {
    main: {
      name: "main",
      description: "The main task",
      scanRate: 1000,
      program: () => {},
    },
  },
  mqtt: {},
  sources,
  variables: motorVariables,
});

main();

While this is a little verbose, hopefully it's obvious just how flexible this setup is. Expanding the motor count from 20 to 100 or even 1000 motors is trivial and only needs to be adjusted in one place. Need to add another tag to each motor? You can also do that quickly without having to repeat yourself. In summary, Tentacle PLC is simply the best PLC for generating large amounts of similar tags that are already pre-configured to publish to MQTT sources.