# snap7ts

A pure TypeScript implementation of the Siemens S7 communication protocol, fully compatible with the [node-snap7](https://github.com/mathiasschul/node-snap7) API.

Zero native dependencies. No compilation toolchain required. Works on any platform Node.js runs on.

## Why snap7ts?

`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`.

## Protocol Stack

```
┌─────────────────────────┐
│   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
└─────────────────────────┘
```

## Installation

```bash
npm install snap7ts
```

## Quick Start

```typescript
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();
  });
});
```

### Promise Style

All async methods return a Promise when no callback is provided:

```typescript
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();
```

## Real-World Usage

Here are common patterns for working with S7 data in production:

### Read a DB block and decode S7 data types

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–7

Examples: `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:

```typescript
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: batch read/write with auto-merge

`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`:

```typescript
import { S7EnhancedClient } from 'snap7ts';

const client = new S7EnhancedClient({
  ip: '192.168.1.10',
  port: 102,
  rack: 0,
  slot: 1,
});
```

#### Lazy Connection & Auto-Reconnect

- **Lazy connection**: `read*`, `write*`, `startPolling` auto-connect when not connected — no need to call `connect()` first.
- **Auto-reconnect**: When PLC goes offline, the client automatically reconnects at `reconnectInterval` (default 5000ms) and resumes polling.
- **`disconnect()`** is the only way to fully stop — after that, `read`/`write` will throw. Calling them again re-triggers lazy connection.

```typescript
// 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');
```

#### Batch Read/Write

Automatically groups points by DB number — one `DBRead` per DB:

```typescript
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:

```typescript
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):

```typescript
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);
```

#### Single-value Read/Write

```typescript
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.

#### Polling with Change Detection

`S7EnhancedClient` can poll points at a fixed interval and fire callbacks when values change:

```typescript
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`).

#### Complete Demo: Data Collector with Heartbeat

```javascript
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);
```

### Data Type Reference

| 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.

### Read/write a single bit

Use `ReadArea`/`WriteArea` with `S7WLBit`:

```typescript
// 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);
```

### Connection setup with parameters

```typescript
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);
```

### Error handling pattern

```typescript
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);
  }
}
```

### Drop-in Replacement for node-snap7

```typescript
// Before: const snap7 = require('node-snap7');
// After:
const snap7 = require('snap7ts').default;

const client = new snap7.S7Client();
// Same API, no code changes needed
```

## API Reference

### Connection Management

| 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 |

### Data I/O

| 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 |

### Directory & Block Operations

| 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 |

### System Info & Control

| 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 |

### S7Server

snap7ts also includes a fully functional S7Server for simulating PLCs in testing:

```typescript
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
```

## Architecture

### Concurrency Safety

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.

### Automatic Chunking

Read/Write operations that exceed the negotiated PDU size are automatically split into chunks:

- **Read**: max bytes per chunk = `PDU - 18`
- **Write**: max bytes per chunk = `PDU - 34`

Each chunk is a separate request-response cycle, and results are assembled into a contiguous buffer.

### Error Detection

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.

## Project Structure

```
snap7ts/
├── src/
│   ├── index.ts              # Public API exports
│   ├── client.ts             # S7Client class
│   ├── server.ts             # S7Server class
│   ├── enhanced/             # S7EnhancedClient (batch, polling, auto-reconnect)
│   │   ├── types.ts          # Shared type definitions
│   │   ├── codec.ts          # Address parsing, type sizing, encode/decode
│   │   ├── batch.ts          # DB grouping and byte range calculation
│   │   ├── polling.ts        # PollingRunner (interval-based change detection)
│   │   ├── client.ts         # S7EnhancedClient (unified entry point)
│   │   └── index.ts          # Module re-exports
│   ├── 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
```

## Development

```bash
npm install
npm run build          # Compile TypeScript
npm test               # Run tests
npm run test:watch     # Watch mode
npm run test:coverage  # Coverage report
```

## Requirements

- Node.js >= 16
- No native dependencies

## License

MIT
