Modbus Data Source (MODBUS) v25.4+ PREVIEW

Modbus is one of the most widely adopted protocols in industrial environments, particularly for low-level devices such as sensors, controllers, and embedded systems. It operates over TCP or serial transport and exposes a register-based memory model, rather than structured schemas or entity lifecycles.

At its core, Modbus provides access to discrete coils and registers that represent device state, configuration, or control inputs. These registers are addressed numerically and interpreted according to device-specific conventions, data layouts, and access modes.

Kubling integrates with Modbus by allowing you to define logical tables that map directly to raw register addresses. This provides a powerful way to model, query, and modify industrial device state using standard SQL, even when the underlying system has no native notion of tables or schemas.

As with SNMP, Kubling handles the protocol complexity on behalf of the user: reading and writing registers, decoding raw values, and transforming them into usable types based on the table definitions you declare. This abstraction preserves Modbus semantics while exposing a relational interface that reflects the capabilities and constraints of the protocol.

Current support includes Modbus over TCP, using holding and input register access via CREATE FOREIGN TABLE declarations.

Supported SQL Operations

The Modbus Data Source supports a constrained subset of SQL operations that directly reflect the capabilities of the Modbus protocol.

This section clarifies the conceptual meaning of each operation, independently of the detailed technical configuration and examples described elsewhere in this document.

SELECT

SELECT operations retrieve register-backed device state and expose it through a relational interface.

Reads are executed on demand and reflect the device state at query time. Retrieved register values are decoded, transformed, and presented as typed columns according to the table definition.

⚠️

In the current version, read operations are implemented using Modbus holding registers access. Support for additional Modbus read functions (such as input registers, coils, or discrete inputs) is under evaluation and may be introduced in future versions through explicit configuration directives.

UPDATE

UPDATE operations write new values to one or more Modbus registers of an existing device.

Before performing the write operation, Kubling first retrieves the affected register-backed tuples in order to:

  • Determine the row identity of each affected entry
  • Establish row-level locking semantics
  • Derive the appropriate compensation commands when executed under Soft Transactions

This read-before-write phase is an intentional part of the execution model and is particularly relevant when updates are executed within a transactional context.

Once the affected rows have been identified, the corresponding Modbus write operations are issued to mutate device state, configuration, or control inputs. Only registers that are writable according to the device specification can be updated; attempts to write to read-only addresses result in execution errors propagated back to the engine.

Why INSERT and DELETE Are Not Supported

INSERT and DELETE operations are intentionally not supported by the Modbus Data Source.

This follows directly from the Modbus protocol model:

  • Modbus exposes a fixed, addressable memory space
  • Registers and coils exist independently of client interaction
  • There is no protocol-level concept of creating or deleting managed instances

As a result, there is no meaningful equivalent for INSERT or DELETE. Kubling preserves these semantics rather than introducing abstractions that would misrepresent the underlying device behavior.

Configuration

Modbus Source configuration
type: "object"
id: "schema:kubling:dbvirt:model:vdb:sources:ModbusSourceConfig"
properties:
  address:
    type: "string"
  port:
    type: "integer"
  transport:
    type: "string"
    enum:
    - "SERIAL"
    - "TCP"
  deviceId:
    type: "integer"
  timeoutMillis:
    type: "integer"
  byteOrder:
    type: "string"
    description: "Byte order for multi-register values. BIG_ENDIAN = MSB first (default),\
      \ LITTLE_ENDIAN = LSB first."
    enum:
    - "BIG_ENDIAN"
    - "LITTLE_ENDIAN"
  tlsConfig:
    type: "object"
    id: "schema:kubling:dbvirt:model:vdb:sources:ModbusTLSConfig"
    properties:
      certificates:
        type: "string"
        description: "The PEM-encoded certificate chain as a string. This should include\
          \ the full client certificate (e.g., content of client.crt). If provided,\
          \ it overrides 'certificatesFilePath'."
      certificatesFilePath:
        type: "string"
        description: "Path to a PEM-encoded certificate chain file (e.g., /etc/kubling/client.crt).\
          \ This is used only if 'certificates' is not provided."
      privateKey:
        type: "string"
        description: "The PEM-encoded private key as a string. This should match the\
          \ client certificate and typically comes from client.key. If provided, it\
          \ overrides 'privateKeyFilePath'."
      privateKeyFilePath:
        type: "string"
        description: "Path to a PEM-encoded private key file (e.g., /etc/kubling/client.key).\
          \ This is used only if 'privateKey' is not provided."
  softTransactions:
    type: "object"
    id: "schema:kubling:dbvirt:model:vdb:sources:SoftTransactionsMvccSupportConfig"
    properties:
      enabled:
        type: "boolean"
        description: "Enables soft-transaction processing. When enabled, write operations\
          \ are captured and applied at transaction boundaries (prepare/commit), without\
          \ requiring the underlying system to provide transactional guarantees. Defaults\
          \ to false."
      strategy:
        type: "string"
        description: "Defines how soft transactions should be handled for this data\
          \ source. DEFER_OPERATION: write operations are staged and executed only\
          \ at commit time. IMMEDIATE_OPERATION: write operations are executed immediately\
          \ but also recorded in the transaction log for potential rollback. Exact\
          \ semantics vary by data source type. See source type-specific documentation.Defaults\
          \ to DEFER_OPERATION."
        enum:
        - "IMMEDIATE_OPERATION"
        - "DEFER_OPERATION"
      mvcc:
        type: "object"
        id: "schema:kubling:dbvirt:model:vdb:sources:MVCCSimConfig"
        description: "Enables the simulated MVCC engine for the data source. When\
          \ enabled, Kubling maintains per-transaction visibility using an internal\
          \ RocksDB-backed MVCC store. This allows DELETE/UPDATE/INSERT operations\
          \ within a transaction to override rows returned by your scripts. Defaults\
          \ to disabled."
        properties:
          enabled:
            type: "boolean"
            description: "Enables the MVCC simulation layer. When enabled, Kubling\
              \ maintains transaction-local visibility using versioned records. Rows\
              \ inserted, deleted or updated inside the transaction will override\
              \ values returned by the remote data source. Defaults to false."
          dbBasePath:
            type: "string"
            description: "Optional path to the RocksDB directory used to store MVCC\
              \ state locally. If not provided, Kubling will allocate an isolated\
              \ temporary directory."
  contributesToHealth:
    type: "boolean"
    description: "Indicates whether this data source contributes to the engine's overall\
      \ health status. When set to true, if this data source is not healthy, the engine\
      \ will be marked as unhealthy. Otherwise, the health status of this data source\
      \ is ignored in the overall assessment."
  healthcheckAddress:
    type: "integer"
  cache:
    type: "object"
    id: "schema:kubling:dbvirt:translation:model:CacheDataSourceConfig"
    properties:
      enabled:
        type: "boolean"
        description: "Specifies whether the cache is enabled for this Data Source.\
          \ Default is false."
      ttlSeconds:
        type: "integer"
        description: "The time-to-live (TTL) for cache entries, in seconds. Default\
          \ is 43,200 seconds (12 hours)."

Let’s start by exploring a configuration example:

dataSources:
  - name: "modbus"
    dataSourceType: "MODBUS"
    configObject:
      transport: "TCP"
      address: localhost
      port: 8020
      deviceId: 1
      tlsConfig:
        certificatesFilePath: /etc/kubling/server.crt
        privateKeyFilePath: /etc/kubling/server.key
      cache:
        enabled: false
    schema:
      type: "PHYSICAL"
      cacheDefaultStrategy: "NO_CACHE"
      ddl: |
        CREATE FOREIGN TABLE local_test (
          test_id integer OPTIONS (modbus_address '0', modbus_data_type 'int'),        -- 1 reg (2 bytes)
          status  integer OPTIONS (modbus_address '1', modbus_data_type 'int'),        -- 1 reg
          type    integer OPTIONS (modbus_address '2', modbus_data_type 'int'),        -- 1 reg
          result  integer OPTIONS (modbus_address '3', modbus_data_type 'int'),        -- 1 reg
          owner   string  OPTIONS (modbus_address '4', modbus_data_length '6')         -- 6 regs (12 bytes)
        )

Configuration Goal

This example configures a connection to a Modbus device over TCP at localhost:8020, targeting device ID 1.

Basic Connection

  • transport: "TCP"
    Specifies the transport type. Currently, only TCP is supported. SERIAL is reserved for future use.

  • address / port
    IP address (or hostname) and TCP port where the Modbus device is reachable. Default Modbus TCP port is 502, but here we use 8020 as a custom port.

  • deviceId
    The Modbus Unit ID (also called slave address). This identifies which device on the bus to communicate with.

TLS Configuration

  • tlsConfig
    Optional. Enables secure communication with Modbus devices that support TLS (typically via a gateway). You can specify either:
    • Inline PEM strings (certificates, privateKey)
    • File paths to the cert/key (certificatesFilePath, privateKeyFilePath) — as shown in the example.
💡

Modbus sources do not expose high-level semantics, just registers. Kubling gives you the tools to describe those registers in a structured, SQL-friendly way. The real value comes in how you define your tables.

Specific COLUMN directives

DirectiveTypeOptionsDescription
modbus_addressStringAny valid register address (e.g. 0, 30001)The starting address of the register to read. This is the base offset for extracting the field value.
modbus_data_typeStringboolean, int, uint16, int32, float, float32, uint32, int64, float64, double, uint64, string, byte[]Describes how the raw register value should be interpreted. Used to infer the number of registers to read if modbus_data_length is not specified.
modbus_data_lengthIntegerAny positive integerExplicitly sets how many registers to read starting from modbus_address. Overrides all other type inference mechanisms.
modbus_scaleNumberAny numeric valuev26.1+ Applies a numeric scaling factor to the decoded register value. The final value is computed by multiplying the decoded value by the specified scale.

When decoding a field, Kubling resolves the number of registers to read using the following precedence:

  1. If modbus_data_length is provided → it is used directly.
  2. Else if modbus_data_type is specified → the register length is inferred from it.
  3. Else → Kubling falls back to infer both the data type and length from the column’s SQL type.

Data Type Resolution

When mapping Modbus registers to SQL columns, Kubling uses a layered approach to determine how to extract values from the underlying protocol. If neither modbus_data_type nor modbus_data_length is provided for a column, Kubling will infer both based on the declared SQL type.

This section documents the default logic used for:

  • How Modbus types map to register lengths
  • How Kubling types map to default Modbus types

Handling Over-Allocated Reads

Modbus tables in Kubling allow you to define a modbus_data_length that exceeds the minimum required length for a given type. In these cases, Kubling will read all the specified registers but only decode the portion it needs, discarding any extra bytes.

This behavior can be useful in industrial scenarios where:

  • Devices return fixed-length blocks that contain padding, unused registers, or metadata
  • Registers are aligned for hardware constraints, and only a portion holds meaningful data
  • You need to scan or “probe” a range even if you’re using only part of it

Example

CREATE FOREIGN TABLE example (
  "value" integer OPTIONS (modbus_address '0', modbus_data_type 'int32', modbus_data_length '4')
);

In the example above:

  • Kubling reads 4 registers (8 bytes) starting at address 0
  • The int32 decoder uses only the first 2 registers (4 bytes) to construct the value
  • The remaining 4 bytes are discarded

This allows you to model “weird” layouts without failing, or needing to preprocess the Modbus memory space.

⚠️

This also means if meaningful data exists beyond the expected range, it will be silently ignored unless you explicitly declare it in another column.


Modbus Data Types → Register Lengths

The table below lists how many 16-bit registers are read for each Modbus type when no explicit modbus_data_length is specified:

Modbus Data TypeRegister LengthNotes
boolean116-bit single-bit encoded
int, uint16116-bit signed/unsigned integer
int32, float, float32, uint32232-bit (2 registers)
int64, float64, double, uint64464-bit (4 registers)
string2Default: 4 characters = 2 registers
byte[]2Default: 4 bytes = 2 registers

Kubling SQL Types → Inferred Modbus Types

If neither modbus_data_type nor modbus_data_length is defined, Kubling infers both using the SQL column type.

Kubling SQL TypeInferred Modbus TypeNotes
booleanboolean
tinyint, smallintintMapped to 16-bit signed integer
integer, serialint3232-bit integer
bigint, bigintegerint6464-bit integer (4 registers)
float, realfloat32
double, decimalfloat64Used for double, bigdecimal, or fallback
char, varchar, string, clob, json, xml, objectstringTextual and structured types as strings
varbinary, blob, geometry, geographybyte[]Binary and spatial types
date, time, timestampint32Expected as encoded numeric formats
💡

Not all Modbus devices support 64-bit values or float encoding natively. You may need to verify support on the specific device you’re integrating with.

Data Reading vs. Value Transformation

When querying a Modbus data source, Kubling performs two distinct operations for each column:

  1. Register Reading — how many 16-bit registers to read from the device
  2. Value Transformation — how to decode those raw bytes into a usable SQL value

These two steps are related but not the same. It’s important to understand the distinction.


1. Register Reading (Length)

Kubling first determines how many registers to read based on:

  • An explicitly defined modbus_data_length, or
  • The implied length from modbus_data_type, or
  • A fallback rule based on the column’s SQL type (see Data Type Resolution)

The register length only defines how many bytes to pull, not how they’ll be interpreted.


2. Value Transformation (Decoding Formats)

Once the bytes are read, Kubling decodes them using a limited set of internal decoding formats. These formats define how raw bytes are interpreted, before being transformed into the target SQL type.

Supported decoding formats:

Modbus TypeDecoding FormatNotes
intbuffer.short() & 0xFFFFUnsigned 16-bit integer
int32buffer.int()Signed 32-bit integer (2 registers)
float, float32toFloat(buffer.int())IEEE 754 float (2 registers)
stringUTF-16 string decoded from buffer.short() per registerEach 2-byte register holds 2 characters

If the resulting value can’t be converted into the SQL column type, Kubling will raise a transformation error.

Example of a mismatch:

CREATE FOREIGN TABLE faulty (
  "status" geometry OPTIONS (modbus_address '0', modbus_data_type 'int')
);

Here, the Modbus adapter will correctly decode a 16-bit integer, but fail to convert that integer into a geometry object, because that transformation is not supported.

💡

For a full list of supported data type conversions, refer to the DataTypeManager source code.

Soft Transactions and Modbus Semantics

The Modbus Data Source can participate in Kubling Soft Transactions (STX), allowing Modbus operations to be coordinated with other data sources within a single transactional scope.

As with other protocol-based data sources, this participation must be interpreted in light of Modbus protocol semantics and device behavior.

Participation in Soft Transactions

When executed within a Soft Transaction, Modbus UPDATE operations are coordinated by the engine alongside operations targeting other data sources. Execution ordering, failure propagation, and transactional boundaries are enforced consistently at the engine level.

This allows Modbus updates to be composed as part of broader orchestration workflows that may span databases, APIs, and other industrial systems.

Rollback Semantics

Modbus does not provide native transactional support.

For Modbus updates executed under STX, Kubling performs rollback by issuing compensation commands, typically expressed as follow-up write operations intended to restore previous register values.

A rollback therefore indicates that the overall Soft Transaction did not complete successfully, but it does not guarantee that the device has returned to an identical operational state.

Compensation Semantics and Operational Considerations

In Kubling, rollback for Modbus is always expressed through compensation: the engine derives and executes write-back operations as part of the rollback path.

However, the semantic meaning of “reverting” a Modbus write depends heavily on the nature of the device and the role of the affected registers:

  • Writing a previous value back to a register may not undo device-side side effects triggered by the original write.
  • Registers may act as control inputs rather than durable state, and reverting their value may not restore prior behavior.
  • Device state may evolve independently between the original update and the compensation attempt.

As a result, while compensation commands are always issued, they should be understood as a best-effort attempt to restore register values, not as a guarantee of full state reversal.

Operational Implications

Modbus updates should be treated as direct mutations of device state or control surfaces, not as database-style row updates.

Soft Transactions provide coordination, ordering, and a structured rollback mechanism through compensation, but they do not introduce stronger guarantees than those offered by the Modbus protocol and the managed device itself.

Correct usage therefore requires domain understanding of:

  • The specific device being controlled
  • The semantics of each register
  • The operational impact of writing and rewriting values within a transaction lifecycle

Kubling preserves these semantics intentionally, favoring transparency and correctness over artificial transactional abstractions.