otfabric/go-s7comm - Siemens S7 Protocol Library for Go

A pure Go implementation of the Siemens S7 communication protocol. It builds on go-cotp for the TP0 transport service (complete TSDUs). TPKT framing is owned by go-cotp via go-tpkt and is not a direct dependency of this module.
The library provides:
- S7 client connection setup (TCP + go-cotp TP0 + S7 setup communication)
- Read/write operations for DB, inputs, outputs, and markers (rich
ReadResult with explicit status), plus native single-bit ReadBit/WriteBit (BIT transport, not byte RMW)
- Readable range scan and compare-read across rack/slot candidates
- Device discovery over CIDR ranges with rack/slot probing
- SZL-based identification and diagnostics helpers
- Block listing, block metadata retrieval, and block upload
- Low-level wire parsing/encoding packages for protocol internals
Docs: API.md (public API) · ERRORS.md (error and result semantics) · OBSERVABILITY.md (logging) · RELEASE.md (changelog)
Table of contents
Error and result semantics
| Situation |
How it is reported |
| Validation failure (bad input) |
*ValidationError; use errors.As(err, &ValidationError{}) to detect |
| Byte read outcome (success / short / empty / rejected / timeout) |
ReadResult.Status and result.Err() |
| Bit R/W, writes, Connect, Upload, GetCPUState, … |
Strict error return |
| Metadata / best-effort (Identify, GetBlockInfo, …) |
May return (value, error) both non-nil |
Full guide (statuses, sentinels, wire S7Error, CLI exit-code contract): ERRORS.md. API signatures: API.md.
Context: cancel without a deadline is effective on TCP dial and go-cotp
TSDU I/O (via go-cotp’s deadline watcher), but mid-I/O cancel typically aborts
the COTP session — reconnect afterward. Prefer context.WithTimeout /
WithDeadline for wall-clock bounds. Client WithTimeout bounds Connect
(and dial), not ongoing PDU reads/writes; without cancel and without a deadline,
open-session I/O can block indefinitely. UploadBlock cleanup uses a separate
500ms background context. Details: ERRORS.md.
A second Connect() on an already-connected client only replaces the session
after the new handshake succeeds, so a failed reconnect does not drop a healthy
session. Logging is silent by default; see OBSERVABILITY.md.
Install
go get github.com/otfabric/go-s7comm
Requires Go 1.23 or later.
Project structure
go-s7comm/
├── doc.go Module-root package docs (import client/model/wire)
├── client/ High-level S7 API (Connect, R/W, discover, probe, SZL, blocks)
├── model/ Areas, addresses, device info, codecs
│ └── codec/ Typed encode/decode helpers
├── wire/ S7 PDU encode/decode and TSAP helpers (no TPKT/COTP)
├── interop/ Build-tagged (`-tags=interop`) Snap7 black-box tests
├── testdata/ Frame fixtures
├── API.md Public API reference
├── ERRORS.md Error / ReadResult semantics
├── OBSERVABILITY.md Logging (silent by default)
└── RELEASE.md Release history
Transport stack: client → go-cotp (Connect / ReadTSDU / WriteTSDU) → go-tpkt (owned by go-cotp). This module does not import go-tpkt directly.
Quickstart
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/otfabric/go-s7comm/client"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c := client.New("192.168.0.10", client.WithRackSlot(0, 1))
if err := c.Connect(ctx); err != nil {
log.Fatal(err)
}
defer func() { _ = c.Close() }()
result, err := c.ReadDB(ctx, 1, 0, 16)
if err != nil {
log.Fatal(err)
}
if err := result.Err(); err != nil {
log.Fatal(err)
}
fmt.Printf("DB1.DBB0..15 = % X\n", result.Data)
// Native BIT transport (not byte read-modify-write): DB1.DBX10.3
if err := c.WriteDBBit(ctx, 1, 10, 3, true); err != nil {
log.Fatal(err)
}
bit, err := c.ReadDBBit(ctx, 1, 10, 3)
if err != nil {
log.Fatal(err)
}
fmt.Printf("DB1.DBX10.3 = %v\n", bit)
}
Discovery
Use conservative CIDR ranges in OT environments (e.g. /24 or smaller); the library allows up to /12 but large scans create many connections and load.
results, err := client.Discover(ctx, "192.168.0.0/24",
client.WithDiscoverParallel(20),
client.WithDiscoverRackSlotRange(0, 3, 0, 5),
)
Each result reports IP/port reachability, detected rack/slot, negotiated PDU size, and TSAP.
Rack/Slot Probe
Probe a target for accepted rack/slot combinations:
result, err := client.ProbeRackSlots(ctx, client.RackSlotProbeRequest{
Address: "192.168.0.10",
Port: 102,
RackMin: 0,
RackMax: 7,
SlotMin: 0,
SlotMax: 31,
Timeout: 2 * time.Second,
Parallelism: 4,
})
Each candidate has a Status and Stage. Without Strict, "valid" means setup was accepted (setup-only, valid-connect, or valid-query). With strict mode (Strict: true), "valid" means only valid-query: setup succeeded and a benign follow-up S7 query (e.g. SZL or CPU state) also succeeded. This avoids false positives from permissive gateways or simulators that accept setup but do not map to a real CPU.
Strict mode with default confirmation (try SZL, then CPU state, then protection):
result, err := client.ProbeRackSlots(ctx, client.RackSlotProbeRequest{
Address: "192.168.0.10",
Port: 102,
Strict: true, // equivalent to Confirm: client.ConfirmAny
})
Use a specific confirmation strategy:
result, err := client.ProbeRackSlots(ctx, client.RackSlotProbeRequest{
Address: "192.168.0.10",
Strict: true,
Confirm: client.ConfirmSZL, // or ConfirmCPUState, ConfirmAny
})
The result exposes summary counts: SetupAccepted, ConfirmedByQuery, and TCPOnly. In strict mode only candidates with valid-query are included in result.Valid.
| Status |
Meaning |
valid-query |
Setup ok and follow-up query succeeded (strongest) |
valid-connect |
Setup ok; follow-up failed or not attempted |
setup-only |
Setup ok; no follow-up (non-strict only) |
cotp-only |
COTP ok, S7 setup failed |
tcp-only |
TCP ok, COTP failed |
unreachable |
TCP connect failed |
rejected |
Target rejected (S7 error) |
Use StopOnFirst: true to stop after the first valid combination; in strict mode that means the first valid-query.
Readable range scan
Scan an area to discover which byte ranges are readable (client must be connected):
import (
"github.com/otfabric/go-s7comm/client"
"github.com/otfabric/go-s7comm/model"
)
result, err := c.ProbeReadableRanges(ctx, client.RangeProbeRequest{
Area: model.AreaInputs,
Start: 0,
End: 256,
Step: 8,
ProbeSize: 8,
Repeat: 1,
Retries: 0,
})
// result.Spans = consolidated [Start, End) ranges per status
// result.Summary.ReadableSpans, .EmptySpans, .FailedSpans, .InconclusiveSpans
// result.Probes = raw per-offset observations
Compare read
Run the same read across multiple rack/slot candidates to detect whether the endpoint responds identically (rack/slot-insensitive):
import (
"github.com/otfabric/go-s7comm/client"
"github.com/otfabric/go-s7comm/model"
)
result, err := client.CompareRead(ctx, client.CompareReadRequest{
Address: "192.168.0.10",
Port: 102,
Candidates: []client.RackSlot{{0, 1}, {0, 2}},
Area: model.AreaDB,
DBNumber: 1,
Offset: 0,
Size: 32,
})
// result.ByCandidate = one ReadResult per candidate
// result.RackSlotInsensitive = true if all succeeded with identical data
For CLI usage see s7commctl probe rackslot:
s7commctl probe rackslot --ip 192.168.0.10
s7commctl probe rackslot --ip 192.168.0.10 --strict
s7commctl probe rackslot --ip 192.168.0.10 --confirm szl
s7commctl probe rackslot --ip 192.168.0.10 --strict --first-confirmed
Development
make check
Useful targets:
make test
make test-race — tests with race detector
make coverage
make bench — run benchmarks (discovery, probe, compare, wire parsers)
make lint — staticcheck
make lint-ci — golangci-lint
make vuln — govulncheck
make interop — SNAP7 interop suite (requires Docker; see below)
Interop tests
Black-box tests against both snap7-interop v0.1.0 servers (native Snap7 and pure-Python), using the full canonical fixture matrix. Servers listen on host ports 1102 (native) and 2102 (python); rack/slot is 0/2.
make interop
This pulls pinned GHCR image digests, starts both containers per fixture, and runs go test -tags=interop ./interop/.... make check typechecks the interop package (-tags=interop vet/lint + compile) but does not start Docker or run the matrix.
| Variable |
Purpose |
SNAP_INTEROP_NATIVE_IMAGE / SNAP_INTEROP_PYTHON_IMAGE |
Override pinned image refs |
SNAP_INTEROP_MANAGED=0 |
Use already-running servers (SNAP_INTEROP_NATIVE_ADDR / SNAP_INTEROP_PYTHON_ADDR) |
SNAP_INTEROP_FIXTURE=<id> |
Run a single fixture (e.g. basic-db) |
License
This project is licensed under the MIT License. See LICENSE.