Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"plugins": [
{
"name": "plc-programming",
"description": "Design, write, convert, review and debug PLC programs: IEC 61131-3 (ST, LD, FBD, SFC, IL), PLCopen XML / IEC 61131-10, Siemens TIA (SCL, SimaticML, SIMATIC SD), Schneider (Machine Expert, Control Expert), CODESYS, TwinCAT, ABB Freelance, Rockwell L5X/L5K, IEC 61499, AutomationML, DEXPI, PackML/ISA-88, OPC UA; with a stdlib-only structural validator, an edit-time validation hook, and an offline reader for CODESYS / Machine Expert .project files.",
"description": "Design, write, convert, review and debug PLC programs: IEC 61131-3 (ST, LD, FBD, SFC, IL), PLCopen XML / IEC 61131-10, Siemens TIA (SCL, SimaticML, SIMATIC SD), Schneider (Machine Expert, Control Expert), CODESYS, TwinCAT, ABB Freelance, Rockwell L5X/L5K, IEC 61499, AutomationML, DEXPI, PackML/ISA-88, OPC UA; with a stdlib-only structural validator, an edit-time validation hook, an offline reader for CODESYS / Machine Expert .project files, and an offline decoder for WinCC Tag Logging fast-archive blocks.",
"source": "./"
}
]
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "plc-programming",
"version": "0.2.2",
"version": "0.3.0",
"description": "PLC programming across IEC 61131-3 (ST, LD, FBD, SFC, IL), PLCopen XML / IEC 61131-10, Siemens TIA (SCL, SimaticML, SIMATIC SD), Schneider (Machine Expert, Control Expert), CODESYS, TwinCAT, ABB Freelance, Rockwell L5X/L5K, IEC 61499, AutomationML, DEXPI, PackML/ISA-88 and OPC UA: design, write, convert, review and debug programs, with a stdlib-only structural validator.",
"author": {
"name": "SmailG",
Expand Down
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ privately, as described in [SECURITY.md](SECURITY.md). Pull requests use the tem
| `hooks/hooks.json` | PostToolUse hook that runs the validator on every file Claude writes or edits |
| `scripts/plc_validate.py` | Validator CLI and hook entry point |
| `scripts/codesys_project.py` | Offline reader for CODESYS 3.5 / Machine Expert `.project` files (`list`, `st`, `dump-st`, `graph`, `info`, `diff`, `xref`); `scripts/codesysproj/` holds `binary.py` (varint, string table), `project.py` (objects, kinds, text), `graph.py` (LD/FBD records) and `graphview.py` (their text and JSON), `export.py` (`dump-st`), `buildinfo.py` (`info`), `diff.py` (`diff`), `xref.py` and `stscan.py` (`xref`) |
| `scripts/wincc_binvalues.py` | Offline decoder and CLI for WinCC 7 Tag Logging fast-archive blocks (`TagCompressed.BinValues` to CSV) |
| `scripts/plccheck/` | One module per format (`st.py`, `plcopen.py`, `simaticml.py`, `twincat.py`, `logix.py`, `schneider.py`, `iec61499.py`, `aml.py`, `dexpi.py`, `tabular.py`); `findings.py` holds the finding type, `xmlutil.py` the line-numbered XML parser |
| `tests/` | `test_st.py`, `test_formats.py` (validator rules), `test_codesys_project.py` and `test_codesys_tools.py` (the `.project` reader and its `info`, `diff` and `xref`, on synthetic projects only), `test_codesys_hardening.py` (hostile and damaged `.project` input and the reader's CLI contracts, synthetic projects only), `test_linear_xml.py`, `test_linear_aml.py` and `test_linear_text.py` (CPU-time scaling and ordering of the format checkers on crafted input; `linear_support.py` holds their ratio helper and XML builders), `test_skills.py` (frontmatter, templates, every `iecst` block lints clean) |
| `tests/` | `test_st.py`, `test_formats.py` (validator rules), `test_codesys_project.py` and `test_codesys_tools.py` (the `.project` reader and its `info`, `diff` and `xref`, on synthetic projects only), `test_codesys_hardening.py` (hostile and damaged `.project` input and the reader's CLI contracts, synthetic projects only), `test_linear_xml.py`, `test_linear_aml.py` and `test_linear_text.py` (CPU-time scaling and ordering of the format checkers on crafted input; `linear_support.py` holds their ratio helper and XML builders), `test_wincc_binvalues.py` (the WinCC archive decoder and its CLI, on synthetic blocks built by an encoder inside the test), `test_skills.py` (frontmatter, templates, every `iecst` block lints clean) |
| `evals/` | `claude plugin eval` cases comparing the plugin against a no-plugin baseline |

## Rules

- **The validator and the `.project` reader use only the Python standard library** and must
- **The validator, the `.project` reader and the WinCC archive decoder use only the Python standard library** and must
run on Python 3.9 (CI runs 3.9 and 3.12). Optional tools such as `xmllint` or `lxml` are allowed only behind `--xsd`.
- **Every validator rule needs two tests:** an input it must catch and a valid control it must
accept. A rule tested only on bad input cannot show it is not firing on everything.
Expand All @@ -34,6 +35,9 @@ privately, as described in [SECURITY.md](SECURITY.md). Pull requests use the tem
- Do not bundle vendor schemas or manuals whose licence does not clearly permit redistribution.
- **Test fixtures for the `.project` reader are synthetic.** Never commit a real `.project`,
or names, text or guids taken from one: it is someone's program.
- **Test fixtures for the WinCC archive decoder are synthetic too.** Never commit real block
bytes, decoded values, tag names, segment or project names or host names from an archive: it is
someone's process data. Build blocks with the encoder in the test.

## Tests

Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ git clone https://github.com/SmailG/plc-programming.git .agents/plugins/plc-prog
| `schneider` skill | automatic | Machine Expert (M241/M251/M262), Control Expert (M340/M580), Automation Expert, Modbus, EtherNet/IP, OPC UA |
| `iec-61131-3` skill | automatic | Ed.2–Ed.4, the five languages, POUs, OOP, data types, the standard library, PLCopen guidelines, Motion and Safety |
| `exchange-formats` skill | automatic | PLCopen XML (TC6 v2.0/v2.01), IEC 61131-10, AutomationML (IEC 62714), DEXPI / ISO 15926 |
| `other-platforms` skill | automatic | Beckhoff TwinCAT, CODESYS (incl. reading a binary `.project` offline: ST, build identity, version diff, cross-reference), ABB Freelance (PRT/CSV) / AC500 / 800xA, Rockwell L5X/L5K |
| `other-platforms` skill | automatic | Beckhoff TwinCAT, CODESYS (incl. reading a binary `.project` offline: ST, build identity, version diff, cross-reference), ABB Freelance (PRT/CSV) / AC500 / 800xA, Rockwell L5X/L5K, Siemens WinCC 7 Tag Logging fast-archive blocks to CSV (offline decoder) |
| `iec-61499` skill | automatic | event-driven FBs, ECC, 4diac file formats |
| `packml-isa88-opcua` skill | automatic | PackML states and PackTags, ISA-88 / IEC 61512, OPC UA (OPC 30000/30001/30050) |
| `/plc-programming:new-pou` | user only | scaffold a POU for a chosen platform and format |
Expand Down Expand Up @@ -105,6 +105,22 @@ not crashed on. Prefer a PLCopen XML export from the IDE when you
can get one, and verify graphical logic in the IDE. Details:
[codesys-project-files.md](skills/other-platforms/references/codesys-project-files.md).

## The WinCC Tag Logging archive decoder

```bash
python3 scripts/wincc_binvalues.py dump.txt > tag.csv # or one block as hex on standard input
```

Decodes the fast-archive blocks (`TagCompressed.BinValues`) of a Siemens WinCC 7 Tag Logging
segment to CSV (`timestamp,value,quality`) without WinCC (standard library only, Python 3.9+).
**The format is undocumented**: the decoder is reverse-engineered on WinCC 7.5 SP2 and untested
on other versions. It raises an error for a block that fails its checks; it does not check some
header bytes, and a damaged value inside the stream can still decode. The compressed archive
type is not decoded and the signedness of the 32-bit integer code is a guess. A block that fails
is reported by line and the others are still written. Attach copies of the segments to a
separate SQL Server instance, never a live WinCC. The workflow, the query and what is established:
[wincc-tag-logging-archive.md](skills/other-platforms/references/wincc-tag-logging-archive.md).

## Tests and evals

```bash
Expand All @@ -114,7 +130,7 @@ claude plugin eval . --trust-plugin --allow-tools Write Edit "Bash(python3 *)" -

- **Unit tests:** every validator rule is exercised by an input it must catch and by a
valid control it must pass. The `.project` reader is tested on synthetic projects built in
the test, never on a real program. Every shipped template and every `iecst` code block in the
the test, never on a real program, and the WinCC decoder on synthetic blocks, never on real archive data. Every shipped template and every `iecst` code block in the
skills must lint clean.
- **Evals:** they compare the plugin against a no-plugin baseline on edition facts, SFC
semantics, a classic scan-cycle bug, PLCopen XML generation, SimaticML namespaces,
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "plc-programming",
"displayName": "PLC Programming",
"version": "0.2.2",
"version": "0.3.0",
"description": "PLC programming across IEC 61131-3 (ST, LD, FBD, SFC, IL), PLCopen XML / IEC 61131-10, Siemens TIA, Schneider, CODESYS, TwinCAT, ABB Freelance, Rockwell L5X/L5K, IEC 61499, AutomationML, DEXPI, PackML/ISA-88 and OPC UA with a stdlib-only structural validator.",
"author": {
"name": "SmailG",
Expand Down
280 changes: 280 additions & 0 deletions scripts/wincc_binvalues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Decode WinCC Tag Logging fast-archive blocks (`TagCompressed.BinValues`) offline.

wincc_binvalues.py [DUMP]

DUMP is a file, or `-` or nothing for standard input. A file is read as UTF-8 (a byte order mark
is accepted; a UTF-16 file is refused with exit 2). Each line is either one block as hex, or
four `|`-separated fields as `sqlcmd` prints them, the last being the hex (the first three are
ignored; the block's own header carries the start time). Output is CSV on standard output:
`timestamp,value,quality`, timestamps in UTC to the millisecond, quality as the decimal OPC
quality byte (192 = 0xC0 = good, 8 = 0x08 = bad / not connected).

The format is undocumented. It was reverse-engineered on WinCC 7.5 SP2 fast archives and is
untested on other versions. The decoder raises DecodeError on a block that fails its checks
(checksum, length, sample count and period, value code, chunk headers, the stream's own
consistency) rather than guessing. It does not check header bytes 1-3, 5-6 and 25-36, the value of
a quality byte or the daylight-saving bit, and a damaged value inside the stream can decode.
Established, guessed and undecoded parts are listed in
skills/other-platforms/references/wincc-tag-logging-archive.md.

As a library:

from wincc_binvalues import decode_block
rows = decode_block(binvalues_bytes) # [(datetime, value, quality), ...]

Exit: 0 clean, 1 some blocks did not decode or none were found (the others are still written),
2 usage or unreadable input. Standard library only; Python 3.9 or later.
"""
import argparse
import csv
import datetime
import struct
import sys

HEADER_LEN = 37 # fixed byte header before the bit stream
MRU_SIZE = 19 # history depth (current + 18 back); index 19 is never referenced
OLE_EPOCH = datetime.datetime(1899, 12, 30)

# byte 4 of the header -> (kind, width in bits, signed). 'f' = IEEE float32 little-endian.
# Widths are established (bit accounting). Signedness of 4-7 follows from observed values;
# signedness of 8 is a guess (no value >= 2**31 was seen).
VALUE_CODES = {
3: ('f', 32, True),
4: ('i', 16, True),
5: ('i', 8, True),
6: ('i', 8, False),
7: ('i', 16, False),
8: ('i', 32, True),
}


class DecodeError(ValueError):
pass


class _Bits:
def __init__(self, data):
self.s = ''.join(f'{x:08b}' for x in data)
self.p = 0

def take(self, n):
if self.p + n > len(self.s):
raise DecodeError(f'read past end at bit {self.p} (+{n})')
v = self.s[self.p:self.p + n]
self.p += n
return v

def peek(self, n):
return self.s[self.p:self.p + n]


def _raw(bitstr, width):
return int(bitstr, 2).to_bytes(width // 8, 'big') # bytes in stream order


def _value(raw, kind, signed):
if kind == 'f':
return struct.unpack('<f', raw)[0]
return int.from_bytes(raw, 'little', signed=signed)


def _ole_date(b, offset):
try:
return OLE_EPOCH + datetime.timedelta(days=struct.unpack_from('<d', b, offset)[0])
except (OverflowError, ValueError):
raise DecodeError(f'start time at header byte {offset} is not a date')


def parse_header(b):
n = int.from_bytes(b[9:13], 'little')
t0 = _ole_date(b, 13)
period_ms = int.from_bytes(b[21:25], 'little')
return {
'code': b[4], 'length': int.from_bytes(b[7:9], 'little'),
'n': n, 't0': t0, 'period_ms': period_ms,
}


def _chunk_header(bs):
m = int(bs.take(10), 2)
qword = bs.take(32)
m2 = int(bs.take(10), 2)
tail = bs.take(32)
if m != m2 or m == 0:
raise DecodeError(f'chunk header count mismatch {m}/{m2} at bit {bs.p - 84}')
# qword[31] is the daylight-saving flag (1 in summer-time segments); either value is accepted.
q1 = int(qword[8:16], 2)
q2 = int(tail[24:32], 2)
if q1 != q2 or tail[:24] != '0' * 24 or qword[16:31] != '000100000000000' or qword[:8] != '0' * 8:
raise DecodeError(f'unexpected chunk header {qword}/{tail} at bit {bs.p - 84}')
return m, q1


def decode_values(b):
"""Return (values, qualities, info) for one block. Raises DecodeError when a check fails."""
if len(b) < HEADER_LEN:
raise DecodeError(f'block of {len(b)} bytes is shorter than the {HEADER_LEN}-byte header')
xor = 0
for x in b[1:]:
xor ^= x
if xor != b[0]:
raise DecodeError(f'checksum byte 0 = {b[0]:#04x}, XOR of bytes 1.. = {xor:#04x}')
h = parse_header(b)
if h['length'] != len(b):
raise DecodeError(f"length field {h['length']} != {len(b)}")
if h['code'] not in VALUE_CODES:
raise DecodeError(f"unknown value code {h['code']}")
if h['n'] < 1:
raise DecodeError(f"sample count {h['n']} is below 1")
if h['period_ms'] == 0:
raise DecodeError('period of 0 ms')
kind, width, signed = VALUE_CODES[h['code']]
n = h['n']
bs = _Bits(b[HEADER_LEN:])
hist = [] # raw value bytes, not numbers: 0.0 and -0.0 are two entries (a guess, see the reference page)

def push(v):
if v in hist:
hist.pop(hist.index(v))
hist.insert(0, v)
if len(hist) > MRU_SIZE:
hist.pop()

if bs.take(1) != '0':
raise DecodeError('stream does not start with a value token')
cur = _raw(bs.take(width), width)
push(cur)
vals = [cur]
left, q = _chunk_header(bs)
chunks = [(left, q)]
counts = {'same': 0, 'new': 1, 'ref': 0, 'chunks': 1}
while len(vals) < n:
if left == 0:
left, q = _chunk_header(bs)
chunks.append((left, q))
counts['chunks'] += 1
continue
if bs.peek(2) == '11':
bs.take(2)
counts['same'] += 1
elif bs.peek(1) == '0':
bs.take(1)
cur = _raw(bs.take(width), width)
push(cur)
counts['new'] += 1
else:
bs.take(2)
c = int(bs.take(2), 2)
idx = c if c else 4 + int(bs.take(4), 2)
if idx >= len(hist):
raise DecodeError(f'history index {idx} beyond {len(hist)} at sample {len(vals)}')
cur = hist[idx]
push(cur)
counts['ref'] += 1
vals.append(cur)
left -= 1
if left == 0:
# the chunk counts sum to n: a block whose last chunk filled up exactly ends with
# an empty chunk header (m = 1, no tokens)
left, q = _chunk_header(bs)
chunks.append((left, q))
counts['chunks'] += 1
pad = bs.s[bs.p:]
if len(pad) >= 8 or '1' in pad:
raise DecodeError(f'{len(pad)} trailing bits left, ones={pad.count("1")}')
if left != 1:
raise DecodeError(f'last chunk left={left}, expected 1')
# Chunk i covers m_i consecutive samples counted from sample 0. For n >= 1 (checked above) the
# loop reads exactly n - 1 tokens, which is sum(m) - left = sum(m) - 1 once left == 1, so no
# separate check that the counts sum to n is needed. A chunk's header sits in the stream
# immediately after its first sample, so quality is assigned by these cumulative ranges, not
# by header position.
quals = [q for m, q in chunks for _ in range(m)]
info = dict(h, counts=counts, pad_bits=len(pad), chunks=chunks)
return [_value(r, kind, signed) for r in vals], quals, info


def decode_block(b, t0=None):
"""Bytes of one BinValues -> [(timestamp, value, quality)]. t0 defaults to the header's start."""
vals, quals, info = decode_values(b)
start = t0 or info['t0']
step = datetime.timedelta(milliseconds=info['period_ms'])
try:
return [(start + k * step, v, q) for k, (v, q) in enumerate(zip(vals, quals))]
except OverflowError:
raise DecodeError(f"sample times run past the last representable date from {start}")


def format_timestamp(dt):
"""UTC to the millisecond, rounded: the header's start time is a double and carries microsecond noise."""
try:
dt = dt + datetime.timedelta(microseconds=500)
except OverflowError:
raise DecodeError(f'timestamp {dt} is too late to format')
return f'{dt:%Y-%m-%dT%H:%M:%S}.{dt.microsecond // 1000:03d}Z'


def format_value(v):
return f'{v:.9g}' if isinstance(v, float) else str(v) # 9 digits round-trip a float32


def block_from_line(line):
"""The block bytes from a bare hex line or from a dump line of four `|`-separated fields."""
fields = [f.strip() for f in line.split('|')]
if len(fields) == 1:
text = fields[0]
elif len(fields) == 4:
text = fields[3]
else:
raise DecodeError(f'expected hex or three columns then hex, got {len(fields)} fields')
try:
return bytes.fromhex(text)
except ValueError:
raise DecodeError(f'not hex: {text[:30]!r}')


def convert(lines, out, err):
"""Write the CSV rows of every block in `lines`; return (blocks decoded, blocks failed)."""
writer = csv.writer(out, lineterminator='\n')
writer.writerow(['timestamp', 'value', 'quality'])
ok = bad = 0
for number, line in enumerate(lines, 1):
if not line.strip():
continue
try:
rows = [[format_timestamp(t), format_value(v), q]
for t, v, q in decode_block(block_from_line(line))]
except DecodeError as e:
bad += 1
print(f'line {number}: {e}', file=err)
continue
ok += 1
writer.writerows(rows)
return ok, bad


def main(argv=None):
parser = argparse.ArgumentParser(prog='wincc_binvalues.py', description=__doc__.split('\n\n')[0])
parser.add_argument('dump', nargs='?', default='-', help='dump file, or - for standard input (default)')
try:
args = parser.parse_args(argv)
except SystemExit as e:
return e.code
try:
if args.dump != '-':
with open(args.dump, encoding='utf-8-sig') as fh:
lines = fh.read().splitlines()
else:
lines = sys.stdin.read().splitlines()
except (OSError, UnicodeDecodeError) as e:
print(f'cannot read input: {e}', file=sys.stderr)
return 2
ok, bad = convert(lines, sys.stdout, sys.stderr)
if ok == 0 and bad == 0:
print('no blocks found in the input', file=sys.stderr)
return 1 if bad or ok == 0 else 0


if __name__ == '__main__':
sys.exit(main())
Loading