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, onlyTCPis supported.SERIALis reserved for future use. -
address/port
IP address (or hostname) and TCP port where the Modbus device is reachable. Default Modbus TCP port is502, but here we use8020as 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.
- Inline PEM strings (
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
| Directive | Type | Options | Description |
|---|---|---|---|
modbus_address | String | Any 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_type | String | boolean, 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_length | Integer | Any positive integer | Explicitly sets how many registers to read starting from modbus_address. Overrides all other type inference mechanisms. |
modbus_scale | Number | Any numeric value | v26.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:
- If
modbus_data_lengthis provided → it is used directly. - Else if
modbus_data_typeis specified → the register length is inferred from it. - 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
int32decoder 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 Type | Register Length | Notes |
|---|---|---|
boolean | 1 | 16-bit single-bit encoded |
int, uint16 | 1 | 16-bit signed/unsigned integer |
int32, float, float32, uint32 | 2 | 32-bit (2 registers) |
int64, float64, double, uint64 | 4 | 64-bit (4 registers) |
string | 2 | Default: 4 characters = 2 registers |
byte[] | 2 | Default: 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 Type | Inferred Modbus Type | Notes |
|---|---|---|
boolean | boolean | |
tinyint, smallint | int | Mapped to 16-bit signed integer |
integer, serial | int32 | 32-bit integer |
bigint, biginteger | int64 | 64-bit integer (4 registers) |
float, real | float32 | |
double, decimal | float64 | Used for double, bigdecimal, or fallback |
char, varchar, string, clob, json, xml, object | string | Textual and structured types as strings |
varbinary, blob, geometry, geography | byte[] | Binary and spatial types |
date, time, timestamp | int32 | Expected 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:
- Register Reading — how many 16-bit registers to read from the device
- 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 Type | Decoding Format | Notes |
|---|---|---|
int | buffer.short() & 0xFFFF | Unsigned 16-bit integer |
int32 | buffer.int() | Signed 32-bit integer (2 registers) |
float, float32 | toFloat(buffer.int()) | IEEE 754 float (2 registers) |
string | UTF-16 string decoded from buffer.short() per register | Each 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.