A pure TypeScript implementation of the Siemens S7 communication protocol, fully compatible with the node-snap7 API.
Zero native dependencies. No compilation toolchain required. Works on any platform Node.js runs on.
node-snap7 wraps the snap7 C++ library as a native addon, which requires a C++ compiler toolchain and platform-specific binaries. This causes friction in CI/CD pipelines, cross-platform deployments, and environments where native module compilation is restricted.
snap7ts reimplements the entire S7 protocol stack in pure TypeScript — from TPKT framing to S7 application-layer PDUs — while preserving the exact same API surface as node-snap7.
Zero runtime dependencies. Small footprint. The entire library is just the code you see — no hidden native modules, no platform-specific binaries, and no dependency tree to audit or break.
For production use, S7EnhancedClient offers lazy connection, automatic reconnection, batch read/write with DB merging, and polling with change detection — all built on top of the core S7Client. For testing, S7EnhancedServer provides declarative area management, type-aware read/write, data simulation, change watch, and snapshot/restore — all built on top of the core S7Server.
┌─────────────────────────┐ │ S7 Application Layer │ Read/Write, Control, System Info PDUs ├─────────────────────────┤ │ ISO COTP (Class 0) │ Connection Request/Confirm, TSAP negotiation ├─────────────────────────┤ │ ISO TPKT (RFC 1006) │ 4-byte header: version(0x03), reserved, length ├─────────────────────────┤ │ TCP │ Port 102 └─────────────────────────┘
npm install snap7ts
import { S7Client } from 'snap7ts';
const client = new S7Client();
// Callback style (node-snap7 compatible)
client.ConnectTo('192.168.1.10', 0, 1, (err) => {
if (err) {
console.error('Connection failed:', client.ErrorText(err));
return;
}
client.DBRead(1, 0, 100, (err, data) => {
if (err) {
console.error('Read failed:', client.ErrorText(err));
return;
}
console.log('Data:', data.toString('hex'));
client.Disconnect();
});
});
All async methods return a Promise when no callback is provided:
import { S7Client } from 'snap7ts';
const client = new S7Client();
client.SetConnectionType(3); // CONNTYPE_BASIC
await client.ConnectTo('192.168.1.10', 0, 1);
const data = await client.DBRead(1, 0, 100);
console.log('Data:', data.toString('hex'));
client.Disconnect();
Here are common patterns for working with S7 data in production:
S7 addresses follow the format DB<n>.<type><m>.<b>:
DB<n> — Data Block number (e.g. DB1 = DB block 1)<type> — Access size: DBX (bit), DBB (byte), DBW (word/2 bytes), DBD (double word/4 bytes)<m> — Byte offset within the DB block.<b> — Bit offset (only for DBX), range 0–7Examples: DB1.DBX10.1 = DB1, byte 10, bit 1; DB1.DBB2 = DB1, byte 2; DB1.DBW20 = DB1, word at byte 20.
Read a block of bytes with DBRead, then decode by offset:
const client = new S7Client();
await client.ConnectTo('192.168.1.10', 0, 1);
// To read DB1.DBB2 (string) and DB1.DBX0.0, DB1.DBX10.1 (bools),
// read 20 bytes starting from offset 0
const buf = await client.DBRead(1, 0, 20);
// DB1.DBB2 — S7 string: maxLen(1) + actualLen(1) + chars
const actualLen = buf.readUInt8(3); // offset 2 + 1 = byte 3
const str = buf.toString('utf-8', 4, 4 + actualLen);
// DB1.DBX0.0 — bool at byte 0, bit 0
const bool0_0 = (buf.readUInt8(0) & 0x01) !== 0;
// DB1.DBX10.1 — bool at byte 10, bit 1
const bool10_1 = (buf.readUInt8(10) & 0x02) !== 0;
console.log({ str, bool0_0, bool10_1 });
client.Disconnect();
S7EnhancedClient is a high-level client with lazy connection, auto-reconnect, batch operations, and polling. Constructor takes PLC connection parameters — no need to manually create an S7Client:
import { S7EnhancedClient } from 'snap7ts';
const client = new S7EnhancedClient({
ip: '192.168.1.10',
port: 102,
rack: 0,
slot: 1,
});
read*, write*, startPolling auto-connect when not connected — no need to call connect() first.reconnectInterval (default 5000ms) and resumes polling.disconnect() is the only way to fully stop — after that, any read/write will throw Client is explicitly disconnected, and auto-reconnect is cancelled. You must explicitly call connect() or startPolling() again to re-establish the connection. Both connect() and startPolling() clear the disconnected flag, allowing auto-reconnect to resume.Connection state machine:
connect() / startPolling() ┌──────────┐ ────────────────────────────► ┌───────────┐ │ Initial │ │ Connected │ └──────────┘ ◄──────────────────────────── └───────────┘ disconnect() │ ▲ PLC │ │ auto-reconnect offline │ │ (reconnectInterval) ▼ │ ┌──────────┐ │ Reconnect │ └──────────┘ │ disconnect() ▼ ┌────────────┐ │ Disconnected│ ← read/write throw, no auto-reconnect └────────────┘ │ connect() / startPolling() │ (clears _disconnected flag) ▼ back to Initial / Connected
| State | read/write | Auto-reconnect | Transition out |
|---|---|---|---|
| Initial | triggers lazy connect | yes (on failure) | → Connected or → Reconnect |
| Connected | works normally | yes (on PLC offline) | → Reconnect |
| Reconnect | throws, waits for reconnect | yes (retrying) | → Connected |
| Disconnected | throws Client is explicitly disconnected | no | → connect() or startPolling() clears _disconnected flag |
// No connect() needed — readBool auto-connects
const flag = await client.readBool('DB1.DBX10.1');
// PLC goes offline here... auto-reconnect kicks in
// Next read/write works automatically after reconnect
const temp = await client.readReal('DB1.DBD30');
Automatically groups points by DB number — one DBRead per DB:
import { ReadPoint, WritePoint } from 'snap7ts';
// Batch read — points can span different DB blocks
const points: ReadPoint[] = [
{ address: 'DB1.DBB2', dataType: 'string' },
{ address: 'DB1.DBX10.0', dataType: 'bool' },
{ address: 'DB1.DBX10.1', dataType: 'bool' },
{ address: 'DB1.DBB20', dataType: 'int' },
];
const values = await client.batchRead(points);
console.log(values.get('DB1.DBB2')); // "OP9999"
console.log(values.get('DB1.DBX10.0')); // true / false
console.log(values.get('DB1.DBB20')); // 42
Points from different DB blocks are merged — only 2 reads total (DB1 + DB2), not 5:
const points: ReadPoint[] = [
{ address: 'DB1.DBX0.0', dataType: 'bool' },
{ address: 'DB1.DBX0.1', dataType: 'bool' },
{ address: 'DB2.DBB10', dataType: 'int' },
{ address: 'DB2.DBX20.3', dataType: 'bool' },
{ address: 'DB2.DBB30', dataType: 'real' },
];
Batch write reads current data first, encodes values, then writes back (safe for bit operations):
const writes: WritePoint[] = [
{ address: 'DB1.DBX10.0', dataType: 'bool', value: true },
{ address: 'DB1.DBB20', dataType: 'int', value: 100 },
{ address: 'DB1.DBB2', dataType: 'string', value: 'OP0001', stringLen: 10 },
];
await client.batchWrite(writes);
const flag = await client.readBool('DB1.DBX10.1');
const count = await client.readInt('DB1.DBW20');
const temp = await client.readReal('DB1.DBD30');
const name = await client.readString('DB1.DBB50', 20); // stringLen=20
await client.writeBool('DB1.DBX10.1', true);
await client.writeInt('DB1.DBW20', 100);
await client.writeReal('DB1.DBD30', 36.5);
await client.writeString('DB1.DBB50', 'hello', 20);
Available methods:
| Read | Write | Returns / Value type |
|---|---|---|
readBool(addr) | writeBool(addr, val) | boolean |
readByte(addr) | writeByte(addr, val) | number (uint8) |
readInt(addr) | writeInt(addr, val) | number (int16) |
readWord(addr) | writeWord(addr, val) | number (uint16) |
readDInt(addr) | writeDInt(addr, val) | number (int32) |
readDWord(addr) | writeDWord(addr, val) | number (uint32) |
readLInt(addr) | writeLInt(addr, val) | bigint (int64) |
readULInt(addr) | writeULInt(addr, val) | bigint (uint64) |
readReal(addr) | writeReal(addr, val) | number (float32) |
readString(addr, len?) | writeString(addr, val, len?) | string |
readWString(addr, len?) | writeWString(addr, val, len?) | string |
dataType is case-insensitive ('BOOL', 'Bool', 'bool' all work). Invalid types throw an error.
S7EnhancedClient can poll points at a fixed interval and fire callbacks when values change:
const client = new S7EnhancedClient({
ip: '192.168.1.10',
port: 102,
rack: 0,
slot: 1,
interval: 500, // poll every 500ms
reconnectInterval: 3000, // reconnect every 3s after disconnect
});
client.setPollPoints([
{ address: 'DB1.DBB2', dataType: 'string', name: 'StationCode' },
{ address: 'DB1.DBX10.1', dataType: 'bool' },
]);
client.onChange(changes => {
for (const c of changes) {
const label = c.name || c.address;
console.log(`[${new Date().toISOString()}] ${label}: ${c.oldValue} → ${c.newValue}`);
}
});
client.onConnect(() => console.log('PLC connected'));
client.onDisconnect(() => console.log('PLC disconnected'));
client.onError(err => console.error('Error:', err.message || err));
// startPolling auto-connects — no need to call connect() first
await client.startPolling();
// ... later
client.stopPolling(); // stops polling, connection stays alive
client.disconnect(); // fully disconnects
Polling methods:
| Method | Description |
|---|---|
setPollPoints(points) | Set points to poll. Each point can have an optional name for easy identification in change callbacks. |
setPollInterval(ms) | Set poll interval (default 1000ms, or use interval in constructor) |
startPolling() | Start polling. Auto-connects if not connected. Resumes after reconnect. |
stopPolling() | Stop polling. Connection remains active — read/write still work. |
Event callbacks:
| Callback | Description |
|---|---|
onChange(handler) | Fired with ChangeRecord[] when any polled value changes. First poll always fires (old value is undefined). |
onError(handler) | Fired on any error (poll failure, connection lost). |
onConnect(handler) | Fired when connection is established (including auto-reconnect). |
onDisconnect(handler) | Fired when connection is lost. |
ChangeRecord fields: address, dataType, oldValue, newValue, name? (carried from PollPoint).
const { S7EnhancedClient } = require('snap7ts');
const client = new S7EnhancedClient({
ip: '127.0.0.1',
port: 102,
rack: 0,
slot: 1,
interval: 500,
});
const POLL_POINTS = [
{ address: 'DB9002.DBB2', dataType: 'string', name: 'StationCode' },
{ address: 'DB9002.DBX10.1', dataType: 'bool' },
];
const HEARTBEAT_ADDR = 'DB9002.DBX0.0';
client.onChange(changes => {
for (const c of changes) {
const label = c.name || c.address;
console.log(`[${new Date().toISOString()}] ${label}: ${c.oldValue} → ${c.newValue}`);
}
});
client.onConnect(() => console.log('PLC connected'));
client.onDisconnect(() => console.log('PLC disconnected'));
client.onError(err => console.error('Error:', err.message || err));
client.setPollPoints(POLL_POINTS);
client.startPolling().catch(err => console.error('Error:', err.message || err));
let heartbeat = false;
setInterval(() => {
heartbeat = !heartbeat;
client.writeBool(HEARTBEAT_ADDR, heartbeat)
.catch(err => console.error('Write heartbeat failed:', err.message || err));
}, 1000);
| S7DataType | PLC Type | Bytes | Address | Description |
|---|---|---|---|---|
bool | BOOL | 1 bit | DBX | Bit access with bit offset, e.g. DB1.DBX10.1 |
byte | BYTE / CHAR | 1 | DBB | Unsigned 8-bit integer |
int | INT | 2 | DBW | Signed 16-bit integer (BigEndian) |
word | WORD | 2 | DBW | Unsigned 16-bit integer (BigEndian) |
dint | DINT | 4 | DBD | Signed 32-bit integer (BigEndian) |
dword | DWORD | 4 | DBD | Unsigned 32-bit integer (BigEndian) |
lint | LINT | 8 | DBD | Signed 64-bit integer (BigEndian), S7-1500 only |
ulint | ULINT | 8 | DBD | Unsigned 64-bit integer (BigEndian), S7-1500 only |
real | REAL | 4 | DBD | IEEE 754 32-bit float (BigEndian) |
string | STRING | 2+N | DBB | S7 format: maxLen(1) + actualLen(1) + ASCII chars |
wstring | WSTRING | 4+2N | DBB | S7 format: maxLen(2) + actualLen(2) + UTF-16LE chars |
For string, stringLen defaults to 254. For wstring, defaults to 512. Override if your PLC uses different lengths.
Use ReadArea/WriteArea with S7WLBit:
// Read one byte containing the target bit
const data = await client.ReadArea(0x84, dbNumber, byteOffset, 1, 0x01);
const bitSet = (data.readUInt8(0) & (1 << bitOffset)) !== 0;
// Write: read-modify-write the byte
const current = data.readUInt8(0);
const buf = Buffer.alloc(1);
buf.writeUInt8(bitSet ? (1 << bitOffset) | current : ~(1 << bitOffset) & current);
await client.WriteArea(0x84, dbNumber, byteOffset, 1, 0x01, buf);
const client = new S7Client();
client.SetConnectionType(3); // CONNTYPE_BASIC
client.SetParam(2, 102); // RemotePort
client.SetParam(5, 5000); // RecvTimeout
client.SetParam(4, 5000); // SendTimeout
await client.ConnectTo('192.168.1.10', 0, 1);
try {
const data = await client.DBRead(1, 0, 100);
// process data...
} catch (err) {
const code = typeof err === 'number' ? err : 0;
console.error('PLC error:', client.ErrorText(code));
// Reconnect on connection errors
if (code === 0x00030000 || code === 0x00020000) {
client.Disconnect();
await client.ConnectTo(ip, rack, slot);
}
}
// Before: const snap7 = require('node-snap7');
// After:
const snap7 = require('snap7ts').default;
const client = new snap7.S7Client();
// Same API, no code changes needed
| Method | Description |
|---|---|
ConnectTo(ip, rack, slot, callback?) | Connect to a Siemens PLC |
Connect(callback?) | Connect using preset parameters |
Disconnect() | Disconnect from PLC |
SetConnectionParams(ip, localTSAP, remoteTSAP) | Set connection parameters |
SetConnectionType(type) | Set connection type (PG/OP/Basic) |
GetParam(paramNumber) | Get internal parameter value |
SetParam(paramNumber, value) | Set internal parameter value |
| Method | Description |
|---|---|
ReadArea(area, db, start, size, wordLen, callback?) | Read from any PLC area |
WriteArea(area, db, start, size, wordLen, buffer, callback?) | Write to any PLC area |
ReadMultiVars(items, callback?) | Batch read up to 20 variables |
WriteMultiVars(items, callback?) | Batch write up to 20 variables |
DBRead(db, start, size, callback?) | Read DB area |
DBWrite(db, start, size, buffer, callback?) | Write DB area |
ABRead/Write, EBRead/Write, MBRead/Write | Convenience wrappers for I/O/Merker areas |
TMRead/Write, CTRead/Write | Timer/Counter area access |
| Method | Description |
|---|---|
ListBlocks(callback?) | List AG block counts |
ListBlocksOfType(type, callback?) | List blocks of a specific type |
GetAgBlockInfo(type, num, callback?) | Get AG block info |
Upload/FullUpload(type, num, callback?) | Upload block from PLC |
Download(num, buffer, callback?) | Download block to PLC |
Delete(type, num, callback?) | Delete a block |
DBGet(db, callback?) | Get entire DB block |
DBFill(db, fillChar, callback?) | Fill DB with a byte |
| Method | Description |
|---|---|
ReadSZL(id, index, callback?) | Read System Status List |
GetOrderCode(callback?) | Get PLC order code |
GetCpuInfo(callback?) | Get CPU module info |
GetCpInfo(callback?) | Get CP module info |
GetPlcDateTime(callback?) | Read PLC date/time |
SetPlcDateTime(dateTime, callback?) | Set PLC date/time |
PlcHotStart/PlcColdStart/PlcStop | PLC run control |
SetSessionPassword/ClearSessionPassword | Security management |
GetProtection(callback?) | Read protection level |
PlcStatus(callback?) | Get PLC run status |
ErrorText(errNum) | Error code to text |
snap7ts also includes a fully functional S7Server for simulating PLCs in testing:
import { S7Server } from 'snap7ts';
const server = new S7Server();
const db = Buffer.alloc(1024);
server.RegisterArea(server.srvAreaDB, 1, db);
server.StartTo('127.0.0.1');
// Now S7Client can connect to 127.0.0.1:102
The S7 protocol is inherently request-response over a single TCP connection. snap7ts enforces this with an internal mutex (withLock) that serializes all send+recv cycles. This matches node-snap7's C++ synchronous blocking behavior and prevents the response-mixing race condition that would otherwise occur with JavaScript's async I/O model.
Read/Write operations that exceed the negotiated PDU size are automatically split into chunks:
PDU - 18PDU - 34Each chunk is a separate request-response cycle, and results are assembled into a contiguous buffer.
S7 error responses (MsgType 0x02 with non-zero error class/code) are explicitly detected and surfaced as error codes, rather than being silently treated as successful empty responses.
snap7ts/ ├── src/ │ ├── index.ts # Public API exports │ ├── client.ts # S7Client class │ ├── server.ts # S7Server class │ ├── enhanced/ # S7EnhancedClient + S7EnhancedServer │ │ ├── index.ts # Module re-exports │ │ ├── shared/ # Shared types and codec │ │ │ ├── types.ts # Shared type definitions │ │ │ └── codec.ts # Address parsing, type sizing, encode/decode │ │ ├── client/ # Enhanced client │ │ │ ├── client.ts # S7EnhancedClient (lazy connect, auto-reconnect, polling) │ │ │ ├── polling.ts # PollingRunner (interval-based change detection) │ │ │ └── batch.ts # DB grouping and byte range calculation │ │ └── server/ # Enhanced server │ │ ├── server.ts # S7EnhancedServer (area manager, simulator, watch) │ │ ├── area-manager.ts # AreaManager (type-aware read/write, snapshot) │ │ └── simulator.ts # Simulator (sine/random/toggle/increment/sequence) │ ├── protocol/ │ │ ├── constants.ts # Protocol constants (Area, WordLen, Error codes, etc.) │ │ ├── tpkt.ts # TPKT frame encoding/decoding │ │ ├── cotp.ts # COTP connection management (CR/CC/DR/DT) │ │ └── s7-protocol.ts # S7 PDU building/parsing │ ├── transport/ │ │ └── tcp-transport.ts # TCP transport (net.Socket + frame buffering) │ └── utils/ │ ├── error-text.ts # Error code → text mapping │ └── buffer-utils.ts # Buffer read/write helpers ├── test/ # 192 unit & integration tests ├── doc/ # Documentation ├── dist/ # Compiled output ├── package.json ├── tsconfig.json └── vitest.config.ts
npm install
npm run build # Compile TypeScript
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
MIT