Metadata-Version: 2.5
Name: py61850
Version: 0.6.0
Summary: Pure-Python IEC 61850 toolkit — an MMS (ISO 9506) client, and an SCL reader/writer/editor with a byte-faithful round trip. Standard library only.
Project-URL: Homepage, https://github.com/GuilhermeMarini/py61850
Project-URL: Repository, https://github.com/GuilhermeMarini/py61850
Project-URL: Issues, https://github.com/GuilhermeMarini/py61850/issues
Project-URL: Changelog, https://github.com/GuilhermeMarini/py61850/blob/main/CHANGELOG.md
Project-URL: Roadmap, https://github.com/GuilhermeMarini/py61850/blob/main/ROADMAP.md
Author: Guilherme
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
License-File: NOTICE-IEC.txt
Keywords: goose,iec61850,ied,iso9506,mms,relay,sampled-values,scada,substation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: System :: Networking
Requires-Python: >=3.13
Description-Content-Type: text/markdown

# py61850 — IEC 61850 toolkit (pure Python)

An importable library (and its CLIs) for talking to an IEC 61850 IED over MMS
(ISO 9506) on TCP port 102 — scan the data model, read values, transfer files.
**No external dependencies** — Python standard library only. The full OSI stack
is hand-built:

```
TCP → TPKT (RFC1006) → COTP (ISO8073) → ISO Session → ISO Presentation
    → ACSE (AARQ/AARE) → MMS (Initiate + confirmed services)
```

It is also a **61850-6 (SCL) toolkit**: `py61850.scl` reads an `.scd`/`.cid`/
`.icd` into a general object model, **changes** it through an edit layer whose
every operation is invertible, and writes it back **byte for byte** where it did
not touch. That half needs no relay and no network at all.

See [ROADMAP.md](ROADMAP.md) for what's next (an MMS sniffer, MMS simulation
from SCL, and a GOOSE sniff/publish interface), and
[CHANGELOG.md](CHANGELOG.md) for what changed in each release.

## What this library is for

py61850 is a general IEC 61850 toolkit, not a support library for one
application. Every part of it is judged against what an open equivalent of
IEDScout or an IEC browser would need — and against what the *next* project to
use it will need, not only the one that prompted the code.

Concretely, that means three things for any change here:

- **Model the standard, not the file in front of you.** If IEC 61850 defines
  it and a vendor-neutral tool would show it, the API should express it — even
  when today's caller does not ask.
- **No vendor specifics in this library.** Vendors extend SCL through
  `Private` elements and through standard attributes carrying their own value
  grammars (`sAddr` is the usual one). py61850 exposes both faithfully and
  interprets neither. A vendor library attaches to the model; it does not
  live inside it.
- **Derive, do not hardcode.** A list of names that stands in for a type the
  file already declares will be wrong on some file. Resolve
  `DataTypeTemplates` and answer from what the document says.

## Install

```bash
pip install -e .          # from a checkout (editable)
# or, once published/tagged:
# pip install py61850
# pip install "git+https://github.com/OWNER/py61850@v0.6.0"
```

Requires **Python ≥ 3.13** (raised from 3.9 in 0.5.0 — see
[CHANGELOG.md](CHANGELOG.md) for both reasons). Installs `py61850`
(subcommands) plus `mms-scan` and `mms-files` for the two MMS drivers directly.

## Use as a library

The public API lives at the top level — import from `py61850`, not from
the submodules:

```python
from py61850 import MmsClient

with MmsClient("192.0.2.22", timeout=10) as c:      # connects; closes on exit
    for ld in c.get_server_directory():                # logical devices
        variables = c.get_logical_device_directory(ld)
        print(ld, len(variables), "variables")
        print(c.read_value(ld, "LLN0$ST$Beh$stVal"))   # decoded Python value
```

Polling many values — one Read may name many variables, so a poll loop costs one
request per batch, not one per point. The batch is sized against the PDU limit
the server negotiated at association time:

```python
with MmsClient("192.0.2.22") as c:
    bits = [f"ACN1GGIO1$ST$Ind{i}$stVal" for i in range(1, 65)]
    values = c.read_many("MYLD_ANN", bits)          # decoded, in order

    for ds in c.get_data_set_directory("MYLD_ANN"): # predefined DataSets
        print(ds, c.read_data_set("MYLD_ANN", ds))  # a whole set in one request
```

A poll list that spans logical devices takes `(ld, item)` pairs instead — the
domain rides in each entry of the request, so one round trip can name several
devices, and the values come back in the order you asked for them:

```python
    c.read_refs([("MYLD_ANN",  "ACN1GGIO1$ST$Ind1$stVal"),
                 ("MYLD_PROT", "LLN0$ST$Beh$stVal")])
```

Logical nodes — MMS answers with the LD's whole flattened variable list, so the
client reduces it back to the LN level and filters it there:

```python
with MmsClient("192.0.2.22") as c:
    for ln in c.find_logical_nodes(["MMXU", "PTOC"]):   # every LD, one request each
        print(ln.ref, ln.ln_class, ln.prefix, ln.instance)

    c.get_logical_nodes("MYLD_PROT")             # one LD, every LN
    c.get_logical_nodes(ld, pattern="G?PTOC*")          # or by name / regex
```

File transfer, with your own progress callback (`progress(got, size)` — wire it
to a bar, a Qt signal, a WebSocket push …):

```python
from py61850 import FileTransfer

with FileTransfer("192.0.2.22") as ft:
    for e in ft.file_directory("/EVENTS/"):            # DirEntry: .name .size .last_modified
        print(e.name, e.size, e.last_modified)
    ft.download_file("/EVENTS/C4_10117.TXT", "out/C4_10117.TXT",
                     progress=lambda got, total: print(f"{got}/{total}"))
```

Retries and resumes are opt-in, and you can hand them to the library or run
them yourself. Either way the library checks a resume before trusting it: same
size and last-modified time, a re-read overlap that matches the bytes already
held, and a final length that matches the reported size. Anything that fails a
check starts again from byte 0:

```python
from py61850 import FileTransfer, Iec61850Error, RetryPolicy, is_retryable

with FileTransfer("192.0.2.22") as ft:
    # the library owns it: back off on file-busy, reconnect, resume
    ft.download_file("/EVENTS/C4.DAT", "out/C4.DAT", retry=RetryPolicy(retries=3))

    # you own it: every error carries where the transfer stopped
    try:
        data = ft.get_file("/EVENTS/C4.DAT")
    except Iec61850Error as ex:
        if not is_retryable(ex):
            raise
        ft.close(); ft.connect()                        # when and how you choose
        data = ft.get_file("/EVENTS/C4.DAT", resume_from=ex.transfer)
```

`download_file` writes through `out/C4.DAT.part` and renames the file into place
only once it is complete. A failed download leaves the `.part` behind, and
`resume=True`, or `mms-files --continue`, picks it up on a later run.

Whether a resume saves anything depends on the IED. Retry works on all of them.
A resume only helps where the server seeks and reports something to check the
file against: its size or, if there is no size, its lastModified.
Everywhere else the library detects that and restarts from byte 0, which is
correct but no faster. Measured read-only on live IEDs:

| IED | `sizeOfFile` from FileOpen | `lastModified` from FileOpen | `initialPosition` | Resume |
|---|---|---|---|---|
| SEL-451, 751, 487E, 421, 311C | exact; overstated for generated files (`CFG.TXT`) | space-padded (repaired) | refused: `position-invalid` | restarts from 0 |
| Schneider MiCOM P139, P632 | always 0 | absent | ignored, sends from 0 | restarts from 0 |
| GE UR L90 | always 0 | absent | ignored, sends from 0 | restarts from 0 |
| GE 850D | exact | the file's date | honoured | **resumes** |
| Siemens 7SJ85, 7UT85, and a third Siemens unit | exact | the relay's current time, new on every open | honoured | **resumes** |

File services must be enabled in the IED's own settings. SEL relays answer every
listing with `file-access-denied` until they are. The GE C70 lists its files but
was not read-tested.

### Reading an SCL file — and writing it back unchanged

```python
from py61850.scl import SclDocument

doc = SclDocument.parse("station.scd")

for name, header in doc.ied_headers.items():           # cheap: no instance tree
    print(name, header.manufacturer, header.config_version)

print(doc.communication.ip_by_ied())                   # {iedName: IP}

ied = doc.ied("QPC1_TR1_UPC1")                         # built on demand, cached
for ln in ied.logical_nodes():
    for attr in ln.walk():
        print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
```

A parsed document goes back out as the file it came from:

```python
doc.write("station.scd")          # atomic: temp file beside it, then os.replace
raw = doc.to_bytes()              # or the bytes, to hand somewhere else
```

**Byte for byte**, with no edit applied — comments, indentation, attribute
order, namespace prefixes, `xmlns` declarations nothing uses, the line ending
the file was written with and the XML declaration as it was spelled all
survive. Five differences are permitted, none of them observable through an
XML parser and every one a limit of the standard library's serialiser:
attribute quote style, empty-element spacing and form, CDATA boundaries, and
the spelling of a numeric character reference. `SclDocument.to_bytes` states
all five; the round-trip test holds them against 44 MB of real station
exports from three vendors.

That guarantee is the point of the write side. The file goes back into DIGSI
and SEL Architect, and a library that reformats the 99 % of an SCD it did not
touch turns every save into a whole-file diff.

### Changing it — the edit layer

Since 0.5.0 a document can also be **edited**, and every edit is invertible.
Four primitives (`Insert`, `Remove`, `SetAttributes`, `SetTextContent`) each
compute their inverse *before* they apply; a list of them is itself an edit,
applied in order and inverted in reverse. On top of those sit the IEC 61850-6
rules that decide what may be created where, what a rename has to drag along,
and where in a parent a new element belongs.

```python
from py61850.scl import SclDocument, Connection, subscribe

doc = SclDocument.parse("station.scd")

sink  = doc.ied("QMA1_MU1").ext_refs()[2]            # an input awaiting a source
block = doc.ied("TR01_2414").control_blocks()[3]     # the GOOSE that publishes it
fcda  = block.logical_node.data_sets[block.dat_set].fcdas[12]

edits = subscribe(doc, [Connection(sink=sink.element,
                                   fcda=fcda.element,
                                   control_block=block.element)])

undo = doc.apply_edit(edits)     # ONE history entry, however many edits it is
doc.write("station.scd")
doc.apply_edit(undo)             # restores the document exactly
```

Three things hold the layer together:

- **Every function returns everything you should apply, input edit first.** One
  rule, one `apply_edit` call, one undo entry — so "subscribe this ExtRef",
  which may touch an `ExtRef`, an `Inputs` and a supervision logical node in
  three places, is one step in a caller's history.
- **The edit functions take `xml.etree` elements, not the model objects.** The
  model is how you find things and `.element` is what you pass; that is what
  lets an edit reach a part of the file the read model does not cover.
- **A rejected edit changes nothing.** Guards raise `EditRejected` before the
  tree is touched, so there is no half-applied state — the same rule the round
  trip depends on.

`py61850.scl.__all__` is 132 names across the areas it can edit: control blocks
and datasets, GSE/SMV addresses, report and sampled-value control, IEDs and the
rename fan-out, subscription supervision, `DataTypeTemplates` import and merge,
the `Substation` section, and the allocators for MAC addresses, APPIDs and
instance numbers. `CHANGELOG.md` lists them by area, says where this diverges
from OpenSCD and — more usefully — what it does **not** implement.

`py61850.scl` is imported explicitly and is never pulled in by `import
py61850`, so the MMS client keeps installing and running unprivileged.

Errors share one base, so a fleet job catches the whole family at once:

```python
from py61850 import Iec61850Error   # → TransportError | MmsError

try:
    ...
except Iec61850Error as exc:
    log.warning("relay unreachable: %s", exc)
```

`read()` / `get_data_definition()` return **raw MMS TLV bytes**; decode them with
`decode_read_response` / `decode_data_definition` (or use `read_value()` for the
common case). Note: one `MmsClient` owns one socket and is **not thread-safe** —
use one client per thread.

### Public API

| Name | What it is |
|------|-----------|
| `MmsClient` | association + directory / read / data-definition services |
| `FileTransfer` | `MmsClient` + MMS file services (list / search / view / download) |
| `DirEntry` | one file-directory entry (`.name`, `.size`, `.last_modified`) |
| `LogicalNode` | one LN from `find_logical_nodes()` — `.ld`, `.name`, `.ref`, `.prefix`, `.ln_class`, `.instance` |
| `folder_of` | the folder part of an MMS file name, for grouping search hits |
| `Iec61850Error` | base of `TransportError`, `MmsError`, and the reserved `LinkError` / `GooseError` / `SvError` / `SclError` |
| `decode_read_response`, `decode_data_definition`, `decode_service_error` | TLV decoders |

`py61850.scl` has a public surface of its own — 132 names, imported from
`py61850.scl` rather than from `py61850` — and the two do not overlap.

Everything else — `core.ber`, `mms.pdu`, `osi.*`, `CotpTransport` — is internal
and may change between releases.

## Command line

```bash
mms-scan 192.0.2.22            # summary + samples
mms-scan 192.0.2.22 --full     # dump every variable

mms-scan 192.0.2.22 --nodes                      # logical nodes, by LD
mms-scan 192.0.2.22 --nodes --ln-class MMXU,PTOC # only these LN classes
mms-scan 192.0.2.22 --nodes --ld MYLD_PROT --flat

mms-files 192.0.2.22 --list                    # list files
mms-files 192.0.2.22 --view /CFG.TXT           # print a file
mms-files 192.0.2.22 --get  /EVENTS/C4_10117.TXT
mms-files 192.0.2.22 --get-all --filter /EVENTS/ --out relay_files

mms-files 192.0.2.22 --search --ext cfg,dat,hdr   # which folders hold them?
mms-files 192.0.2.22 --search "C4_*.TXT" --root /EVENTS/
mms-files 192.0.2.22 --search --regex "1011[0-9]" --folders
mms-files 192.0.2.22 --get-all --ext cfg,dat --out comtrade
```

`--search` (spelled `--scan` too) walks every file in every folder — the whole
tree, no depth limit — and groups the hits by folder; with no filter it reports
the IED's entire file tree. Its three filters — `--search GLOB` on the file
name, `--ext cfg,dat,hdr` on the extension, `--regex` on the whole path — also
narrow `--list` and `--get-all`, and a file has to match every filter given.
Narrow the walk itself with `--root DIR`, `--depth N` or `--no-recursive`.
`--flat` prints one full path per line for piping; `--folders` prints only the
folders.

MMS has no service that returns logical nodes on their own — 61850-8-1 flattens
each logical device into one `GetNameList` — so `--nodes` reduces that list back
to the LN view (`get_logical_nodes` / `find_logical_nodes` in the library) and
filters it by `--ln-class`, an `--ln GLOB` on the name, or `--regex` on the
`LD/LN` reference. It costs one request per logical device.

`py61850 <command>` reaches the same drivers — `py61850 scan …`,
`py61850 files …` — which is where `goose` and `sv` will attach. Without
installing, `python -m py61850 scan …` works from a checkout.

## Services (ACSI request → MMS service)

| Requested command          | MMS service                             |
|----------------------------|-----------------------------------------|
| Initiate                   | Initiate-Request                        |
| GetServerDirectory         | GetNameList (class=domain, scope=vmd)   |
| GetLogicalDeviceDirectory  | GetNameList (class=namedVariable/List)  |
| GetDataDefinition          | GetVariableAccessAttributes             |
| GetDataValues              | Read                                    |
| GetBRCBValues              | Read on `LLN0$BR$<rcb>`                 |
| GetSGCBValues              | Read on `LLN0$SP$SGCB`                  |
| GetServerDirectory{FILE}   | FileDirectory                           |
| GetFile                    | FileOpen → FileRead* → FileClose        |

Note: a relay's directory-reported file size can be larger than the bytes
actually streamed by FileRead — the download is complete when the relay sets
`moreFollows = FALSE`, which is authoritative.

## Layout

```
src/py61850/
├── __init__.py         # the public API (import from here)
├── errors.py           # the whole exception tree, one file
├── core/               # shared by MMS, GOOSE and SV — pure codec, no I/O
│   ├── ber.py          #   definite-length BER (+ a zero-copy decode path for SV)
│   ├── data.py         #   MMS Data values, encode AND decode
│   ├── quality.py      #   the 13-bit Quality bitstring
│   └── time.py         #   UtcTime / BinaryTime
├── osi/                # the stack under MMS-over-TCP (GOOSE/SV are raw L2)
│   ├── tpkt.py         #   RFC 1006 framing
│   ├── cotp.py         #   ISO 8073 class 0 — the only socket in osi/
│   ├── session.py      #   ISO 8327
│   ├── presentation.py #   ISO 8823
│   ├── acse.py         #   ISO 8650 AARQ/AARE
│   └── stack.py        #   compose the above: associate, wrap, extract
├── mms/
│   ├── pdu.py          #   every builder and decoder — no sockets, ever
│   ├── types.py        #   TypeDescription
│   ├── service_error.py
│   ├── client.py       #   MmsClient / FileTransfer: association + transactions
│   └── services/       #   one mixin per service group
│       ├── directory.py
│       ├── read.py
│       └── files.py
├── link/               # packet-capture sources — planned (ROADMAP 0.2, 2.0)
├── goose/              # GOOSE pub/sub — planned (ROADMAP 2.0)
├── sv/                 # Sampled Values — planned (after GOOSE)
├── scl/                # IEC 61850-6: read, edit, write. The largest package
│   │                   #   here, and never imported by `py61850` itself
│   ├── document.py     #   SclDocument: parse, the byte-faithful write, edits
│   ├── model.py        #   the instance tree, built per IED on demand
│   ├── templates.py    #   DataTypeTemplates resolved once per document
│   ├── edit.py         #   the four primitives, each inverted before it applies
│   ├── ordering.py     #   where a new element goes, from the content models
│   └── …               #   one module per 61850-6 area it can edit
└── cli/
    ├── main.py         # `py61850` — subcommand dispatcher
    ├── scan.py         # `mms-scan` — data-model scan driver
    └── files.py        # `mms-files` — file scan / search / download driver
```

Two rules hold the shape together:

- **`core/` and `mms/pdu.py` never touch a socket.** That is what lets the MMS
  server (ROADMAP 1.0) reuse the same codec in the opposite direction, and what
  lets the test suite run with no relay reachable.
- **`link/`, `goose/`, `sv/` are never imported from `py61850/__init__.py`.**
  GOOSE/SV are a non-IP EtherType, which no OS lets a plain socket reach, so
  they need a capture driver — Npcap on Windows, libpcap plus `CAP_NET_RAW` on
  Linux — and a NIC on the station bus. The MMS half must keep installing and
  running anywhere with no driver present. **That requirement is GOOSE's, not
  the library's:** MMS is TCP/IP, so an MMS-only user — client *or* sniffer —
  installs nothing (`link.ip`, `link.pcap` and the TCP-102 proxy are all
  standard library). See [ROADMAP.md](ROADMAP.md) 0.2 and 2.0.

## Tests

```bash
python -m unittest discover -s tests -t .
```

Standard-library `unittest`, no test dependencies, and entirely offline — see
[tests/README.md](tests/README.md).

## Licence

py61850 is dual-licensed.

- **[GNU AGPL v3 or later](LICENSE)** — free to use, study, modify and share.
  If you distribute software built on py61850, or let users reach it over a
  network, the whole work must be released under the AGPL as well, source
  included. Using it inside your own organisation, with nothing published and
  no outside users, costs nothing and requires nothing.
- **[Commercial licence](COMMERCIAL.md)** — for closed-source products or hosted
  services that cannot publish their source under the AGPL. Available from the
  copyright holder on negotiated terms.

Contributions are welcome under the [CLA](CLA.md), which is what keeps that
second option possible — see [CONTRIBUTING.md](CONTRIBUTING.md).
