An Elixir client for classic S7comm over RFC 1006. The protocol surface includes COTP connection
setup, S7 Setup Communication, single- or multi-item Read Var and Write Var jobs, bounded
userdata-backed System Status List (SZL/SSL) reads, and classic block directory and metadata
queries. Bounded classic block upload, PLC clock access, and classic session-password
authorization are also supported. Destructive block download, replacement, and deletion are
available only through an explicit two-level opt-in, as are CPU stop/start, RAM-to-ROM copy, and
memory compression. Capture-backed read-only programmer diagnostics and one-shot variable-status
sampling are raw-first and bounded. Typed fixed-cycle subscriptions, raw change-driven cyclic
jobs, and classic alarms deliver decoded events directly to their monitored owner processes.
Classic ALARM_S and ALARM_8 queries and explicit acknowledgments use the same session-bound
ownership model. One S7ANY item may represent either a scalar or a fixed-count range.
The implementation keeps protocol codecs pure and gives the TCP socket to one :gen_statem
process. Its active: :once request engine correlates responses by PDU reference, bounds queued
and in-flight work, and keeps negotiated limits in connection state.
The classic core and loopback interoperability suite support scalar, counted, and multi-item
reads and writes. CI also builds a server from a pinned Snap7 revision and verifies automatic PDU
splitting and read-after-write for every supported area and value type. PLCSIM Advanced and
physical Siemens hardware remain external release gates, so 0.1.0 remains a release candidate.
S7comm-plus, symbolic addressing, optimized DB access, and destructive programmer commands are not supported. The common classic userdata envelope is implemented for SZL, block-directory, clock, protected-session, evidence-backed read-only programmer jobs, cyclic subscriptions, and classic alarms. Alarm success paths remain subject to named physical-device qualification.
Runnable read, write, multi-item, metadata, and supervision workflows are in the examples guide.
{:ok, client} =
S7.connect("192.168.1.10",
rack: 0,
slot: 2
)
{:ok, current} = S7.read(client, "DB1.DBW0")
:ok = S7.write(client, "DB1.DBW0", current + 1)
{:ok, raw} = S7.read_raw(client, "DB1.DBW0")
{:ok, %S7.PLC.OrderCode{code: order_code}} = S7.PLC.order_code(client)
{:ok, dbs} = S7.Blocks.list(client, :db)
{:ok, %S7.PLC.Clock{datetime: plc_time}} = S7.PLC.read_clock(client)
{:ok, %S7.Programmer.VariableStatus{items: status_items}} =
S7.Programmer.variable_status(client, ["MB0", "DB1.DBW0"])
{:ok, cyclic} = S7.Cyclic.subscribe(client, ["DB1.DBW0"], interval: 1_000)
cyclic_reference = cyclic.reference
receive do
{:s7, ^cyclic_reference,
%S7.Cyclic.Event{items: [%S7.Cyclic.Event.Item{value: next_value}]}} ->
next_value
end
:ok = S7.Cyclic.unsubscribe(cyclic)
:ok = S7.close(client)Counted addresses return lists from typed reads and accept lists for typed writes:
words = %S7.Address{
area: :db,
db_number: 1,
byte_offset: 20,
data_type: :word,
count: 4
}
:ok = S7.write(client, words, [1, 2, 3, 4])
{:ok, [1, 2, 3, 4]} = S7.read(client, words)
{:ok, <<0, 1, 0, 2, 0, 3, 0, 4>>} = S7.read_raw(client, words)The client returned by connect/2 is the PID that owns the socket. All public failures use
%S7.Error{}. A multi-item operation that stops after execution begins returns
{:error, error, results} so partial and indeterminate outcomes are not discarded.
| Option | Default | Meaning |
|---|---|---|
:rack |
0 |
PLC rack used to construct the destination TSAP |
:slot |
2 |
PLC slot used to construct the destination TSAP |
:connection_type |
:programming_device |
TSAP role; also accepts :operator_panel and :basic |
:src_tsap |
<<0x01, 0x00>> |
Explicit calling TSAP |
:dst_tsap |
derived | Explicit called TSAP; bypasses rack/slot construction |
:port |
102 |
RFC 1006 TCP port |
:timeout |
5000 |
Connect and request timeout in milliseconds |
:tpdu_size |
1024 |
Requested COTP TPDU size |
:pdu_size |
480 |
Requested S7 PDU size |
:max_jobs |
1 |
Requested and local maximum number of concurrent S7 jobs |
:queue_limit |
64 |
Maximum callers waiting behind in-flight jobs; 0 disables waiting |
:reconnect |
false |
Establish fresh sessions after loss; start_link/1 also survives unavailable startup |
:reconnect_min_delay |
250 |
Initial reconnect backoff in milliseconds |
:reconnect_max_delay |
30000 |
Upper bound for reconnect delay, including jitter |
:reconnect_max_attempts |
:infinity |
Attempt cap before the process remains :disconnected |
:reconnect_jitter |
0.2 |
Random proportional backoff jitter from 0.0 to 1.0 |
:max_tpkt_size |
65535 |
Maximum accepted TPKT frame size |
:receive_buffer_limit |
derived | Maximum buffered TCP bytes; at least :max_tpkt_size |
:max_items_per_pdu |
20 |
Conservative peer-compatible Read/Write Var item limit |
:subscription_limit |
16 |
Maximum session-local userdata indication subscriptions |
:allow_destructive |
false |
Enables destructive APIs; every call still requires its exact confirmation atom |
The PLC may negotiate smaller PDU or job limits. S7.info/1 returns
{:ok, information} with negotiated limits, the next reference, and current
queue/in-flight counts. The default remains one job for broad PLC compatibility;
opt into concurrency with max_jobs: n only when the peer supports it.
Use start_link/1 under a supervisor when the client must retain a stable PID or registered name:
children = [
{S7,
host: "192.168.1.10",
name: MyApp.PLC,
rack: 0,
slot: 2,
reconnect: true}
]
Supervisor.start_link(children, strategy: :one_for_one)An unavailable supervised client with reconnect enabled starts in :reconnecting. Backoff is
bounded and reset after a successful Setup Communication exchange. Session loss fails all accepted
calls before reconnecting; requests and writes are never replayed. S7.reconnect/1 starts a
fresh explicit attempt after a configured attempt cap is reached.
Immediate close remains the default. It fails accepted work, sends a COTP Disconnect Request when a COTP session exists, and waits boundedly for a Disconnect Confirm or TCP close before forcing socket closure. To finish accepted work before that exchange while rejecting new calls:
:ok = S7.close(client, mode: :drain, timeout: 5_000)String parsing supports:
DB1.DBX20.3 DB1.DBB20 DB1.DBW20 DB1.DBD20
DBI1.DBIX0.0 DBI1.DBIB0 DBI1.DBIW0 DBI1.DBID0
M10.0 MB10 MW10 MD10
I0.0 IB0 IW0 ID0
Q0.0 QB0 QW0 QD0
P0.0 PB0 PW0 PD0
L0.0 LB0 LW0 LD0
V0.0 VB0 VW0 VD0
C10 T5
String B, W, and D forms are unsigned :byte, :word, and :dword values. Use an explicit
address when the same bytes represent a signed integer or REAL:
temperature = %S7.Address{
area: :db,
db_number: 1,
byte_offset: 20,
data_type: :real
}
{:ok, 12.5} = S7.read(client, temperature)The address model also supports peripheral, instance DB, local, previous-local,
counter, and timer areas. Counters and timers use element_offset; byte areas
use byte_offset. All types except :bit support a count greater than one.
S7.Data provides signed and unsigned 8/16/32/64-bit integers, REAL/LREAL,
byte and wide characters, fixed {:string, maximum} and
{:wstring, maximum} storage, DATE, TIME, TIME_OF_DAY,
DATE_AND_TIME, S5TIME, counter, and timer codecs. Address semantics are
separate from S7ANY transport selection. The default uses broadly compatible
byte access where a native transport is not portable; set transport_size on
an explicit address to opt into a native S7ANY date/time code.
Wire conversion is independently available through S7.Data.encode/2,
S7.Data.encode/3, S7.Data.decode/2, and S7.Data.decode/3.
S7.write_raw/3 accepts an already encoded binary whose size exactly matches the address
type and count. Raw access does not bypass address validation or negotiated PDU limits.
Multi-item operations preserve input order and split automatically:
{:ok, results} = S7.read_many(client, ["DB1.DBW0", "MW10", "IW0"])
values = Enum.map(results, &{&1.status, &1.value})
{:ok, results} =
S7.write_many(client, [
{"DB1.DBW0", 123},
{"MW10", 456}
])Each entry is an %S7.Result{} with status :ok, :error, :indeterminate, or
:not_attempted. The last two statuses matter when a multi-PDU write loses its connection.
Raw SZL reads preserve record boundaries and bytes while validating the PLC's declared geometry:
{:ok, %S7.SZL{record_length: length, records: records}} =
S7.PLC.read_szl(client, 0x0011,
index: 0,
max_bytes: 1_048_576,
max_fragments: 64
)
{:ok, ids} = S7.PLC.list_szl(client)
{:ok, %S7.PLC.CPUInfo{} = cpu} = S7.PLC.cpu_info(client)
{:ok, %S7.PLC.CPInfo{} = communication} = S7.PLC.cp_info(client)
{:ok, %S7.PLC.Status{state: :run}} = S7.PLC.status(client)The order-code, component-identification, communication-limit, and status helpers decode known
Siemens layouts. Every typed result retains either its source record or the complete component
map; use S7.PLC.read_szl/3 for CPU-specific and firmware-specific records.
Classic userdata block services expose inventory and metadata without transferring executable block images:
{:ok, %S7.Block.Inventory{counts: %{db: db_count}}} =
S7.Blocks.counts(client)
{:ok, [%S7.Block.Entry{} | _] = dbs} =
S7.Blocks.list(client, :db,
max_bytes: 1_048_576,
max_fragments: 64
)
{:ok, %S7.Block.Info{name: name, mc7_size: size}} =
S7.Blocks.info(client, :db, 1)S7.Blocks.list/3 assembles the PLC's continuation sequence under aggregate byte and fragment
limits. Directory entries and detailed metadata retain raw bytes and preserve unknown language,
type, and security codes. Block upload and download use different stateful Job services and are
not aliases for Read Var, Write Var, or these directory calls.
Classic block upload retrieves one complete load-memory image through an exclusive, bounded
Start Upload / Upload / End Upload transaction:
{:ok, %S7.Block.Image{} = image} =
S7.Blocks.upload(client, :db, 1,
max_bytes: 1_048_576,
max_fragments: 64,
timeout: 30_000
)
{:ok, raw_image} = S7.Blocks.upload_raw(client, %S7.Block{type: :db, number: 1})The parsed image retains every original byte, validates the requested block identity and declared
sizes, and exposes known header, timestamp, MC7, and footer fields. Classic MC7 and footer ranges
can overlap, so S7.Block.Image also exposes non-overlapping payload, raw_header, and
raw_footer fields. The raw API is available for CPU-specific image variants.
An initial PLC rejection leaves the connection usable. Local byte or fragment limits close the
remote upload cleanly; malformed or ambiguous mid-transaction responses invalidate the session.
Successful upload is qualified by an independently captured real-PLC exchange and the fault
server. The pinned Snap7 server rejects upload with 0xD241, which the interoperability gate
checks explicitly.
Block download is a PLC-driven, bidirectional transaction and is not Write Var. It is disabled by default. Both the connection capability and the operation-specific confirmation are required:
{:ok, maintenance_client} =
S7.connect("192.168.1.10",
rack: 0,
slot: 2,
allow_destructive: true
)
:ok =
S7.Blocks.download(maintenance_client, image,
confirm: :download_block
)
:ok =
S7.Blocks.replace(maintenance_client, replacement,
confirm: :replace_block
)
:ok =
S7.Blocks.delete(maintenance_client, :db, 1,
confirm: :delete_block
)S7.Blocks.download_raw/4 and S7.Blocks.replace_raw/4 validate the complete image and requested block
identity before sending. Download reserves the connection for Request Download, every
PLC-initiated Download Block job, Download Ended, and _INSE activation. Responses are split
against the negotiated PDU size. A complete PLC rejection leaves the connection usable; timeout,
disconnect, malformed traffic, or an ambiguous activation response invalidates the session and
returns details.outcome: :indeterminate. No destructive operation is replayed after reconnect.
The exact successful transfer is capture- and fault-server-qualified. The pinned Snap7 server
deliberately rejects Request Download, so successful PLCSIM and physical-PLC qualification remains
a release gate. Whether _INSE creates or replaces an existing block is PLC-dependent;
S7.Blocks.replace/3 records explicit caller intent but uses the same classic wire service.
CPU control uses a short exclusive transaction and the same two-level policy as block download. Each action has a distinct confirmation atom:
:ok = S7.PLC.stop(maintenance_client, confirm: :stop_cpu)
:ok = S7.PLC.warm_start(maintenance_client, confirm: :warm_start_cpu)
:ok = S7.PLC.cold_start(maintenance_client, confirm: :cold_start_cpu)
# These operations commonly require the CPU to be in STOP.
:ok = S7.PLC.copy_ram_to_rom(maintenance_client, confirm: :copy_ram_to_rom)
:ok = S7.PLC.compress_memory(maintenance_client, confirm: :compress_memory)All calls accept bounded :timeout and :step_timeout options. A complete PLC rejection returns
details.outcome: :rejected and leaves the session usable. Once a request has been transmitted,
a timeout, disconnect, or malformed response is reported as :indeterminate and invalidates the
session. The client never replays CPU control after reconnect.
Classic PLC clock values are timezone-free local civil time:
{:ok, %S7.PLC.Clock{datetime: current}} = S7.PLC.read_clock(client)
:ok = S7.PLC.set_clock(client, ~N[2030-02-03 04:05:06.789])set_clock/2 is state-changing and is never replayed after an ambiguous outcome. The returned
clock struct retains the complete wire timestamp because observed PLCs do not use its century hint
consistently.
Classic protected-session login changes authorization for the current connection only:
password = System.fetch_env!("S7_PASSWORD")
:ok = S7.Session.authenticate(client, password)
:ok = S7.Session.logout(client)Passwords are one to eight printable ASCII bytes. Authentication is an ordering barrier: earlier jobs complete before login/logout and later jobs wait behind it. Authorization is cleared by logout or session loss and is never restored automatically after reconnect. This legacy exchange provides no encryption, integrity, or peer authentication; see Security policy.
Fixed-cycle subscriptions decode standard S7ANY values using the same address and value model as Read Var:
{:ok, subscription} =
S7.Cyclic.subscribe(client, ["MW10", "DB1.DBD20"],
interval: 1_000
)
reference = subscription.reference
receive do
{:s7, ^reference, %S7.Cyclic.Event{items: items}} -> items
{:s7, ^reference, {:error, %S7.Error{} = error}} -> {:error, error}
after
5_000 -> :no_update
end
:ok = S7.Cyclic.unsubscribe(subscription)Intervals must be represented exactly by the classic 100 ms, 1 s, or 10 s wire bases; the client
never rounds. S7.Cyclic.subscribe_raw/4 additionally supports fixed and change-driven jobs using
complete S7ANY or DBREAD specifications, and S7.Cyclic.modify_raw/3 replaces a change-driven item
set without changing its remote job ID. CPU-specific change records remain raw.
The creating process owns the handle and receives the initial setup snapshot through the same
message shape as later updates. A receive timeout is local and leaves the remote job active.
Delivery uses the owner mailbox, so high-rate subscriptions should be owned by a dedicated process
that can apply application-specific backpressure. Decode or connection failures arrive as
{:s7, reference, {:error, error}}. Owner death or an ambiguous setup, modification, or teardown
invalidates the session; reconnect never restores a subscription, and stale handles are rejected.
Alarm subscriptions expose validated timestamps and known object headers while retaining every wire record and CPU-specific associated value:
{:ok, alarms} = S7.Alarm.subscribe(client, :alarm_8)
alarm_reference = alarms.reference
event =
receive do
{:s7, ^alarm_reference, %S7.Alarm.Event{} = event} -> event
end
[object | _] = event.objects
{:ok, %S7.Alarm.Query{records: records}} =
S7.Alarm.query(client, :alarm_8)
:ok = S7.Alarm.acknowledge(client, object)
{:ok, results} = S7.Alarm.acknowledge_many(client, event)
:ok = S7.Alarm.unsubscribe(alarms)Use :alarm_s for the S7-300-style path and :alarm_8 for the S7-400-style path. Events are
delivered in receive order without deduplication. Handles belong to their creating process and
current S7 session; applications should use a dedicated owner process when their mailbox needs
rate control. Reconnect requires explicit resubscription. Alarm acknowledgment is
state-changing and is never replayed. A complete PLC rejection leaves the session usable, while a
missing or malformed response after transmission is indeterminate and invalidates the session.
Query records and associated values preserve raw bytes for CPU-family-specific variants.
S7
-> S7.Connection (:gen_statem, active-once :gen_tcp owner)
-> bounded queue / PDU-reference correlation / request timers / reconnect backoff
-> S7.Protocol.{SetupCommunication, ReadVar, WriteVar, UserData, SZL, Blocks, BlockUpload, BlockDownload, PIService, Clock, Security, Programmer, Cyclic, Alarm}
-> S7.Protocol.PDU / Header / Item / DataItem
-> S7.Transport.COTP
-> S7.Transport.TPKT
TPKT decoding supports incomplete and concatenated frames. The connection also reassembles bounded COTP Data fragments. PDU references are not reused while in flight, responses may arrive out of order, and a request timeout invalidates the session so late bytes cannot affect later work.
The tracked design contract is documented in:
- Architecture
- Protocol support
- Errors and retries
- Telemetry
- Compatibility
- Interoperability evidence
- Device qualification
- Release process
- Security policy
- Changelog
- Roadmap to 1.0
mix deps.get
mix ci
mix docs --warnings-as-errors
mix release.checkThe CI alias runs compilation with warnings as errors, formatting, unit/property/integration tests with a 90% coverage gate, strict Credo, Dialyzer, clone detection, and architecture checks. The loopback PLC test uses real TCP and intentionally fragments responses at both TCP and COTP boundaries.
Run the pinned Snap7 interoperability suite locally with:
bash scripts/run_snap7_integration.shThe script uses the ignored local Snap7 reference when present and otherwise checks out the pinned revision into a temporary directory.
With Docker available, capture the same exchange and require tshark to identify Setup, Read Var, Write Var, SZL, block-directory, block transfer, CPU control, set-clock, session-password, programmer, cyclic, and alarm services without malformed packets:
bash scripts/run_snap7_packet_check.shCI runs the packet check and retains its PCAP artifact for seven days.
Named PLCSIM Advanced and physical PLC runs use the safety-gated device harness.
It restores a reserved scratch DB range and produces an ExUnit log, tshark
decode, report, and hashed PCAP. Read Device qualification
before setting the required S7_QUAL_* environment or enabling writes.
Run the scheduled long-form qualification locally with:
S7_SOAK_ITERATIONS=20000 mix soakGolden fixtures live in test/fixtures. Rebuild their binary files with
elixir scripts/build_test_fixtures.exs.
Source manuals, reference implementations, captures, and generated Markdown extracts under
resources/ are local development references and are intentionally excluded from version control.
The local index files are resources/s7-1500-communication-manual.md and
resources/gmiru-s7comm/README.md. Rebuild the S7-1500 manual extract with
elixir scripts/extract_s7_manual.exs.
The package is not published to Hex yet. Until it is, use a Git dependency or a local path.