Metadata-Version: 2.4
Name: s7-gateway
Version: 0.1.0
Summary: Read and write Siemens S7 tags over HTTP
Author: George Vasiliades
License-Expression: MIT
Project-URL: Homepage, https://github.com/Poseidonas/s7-gateway
Project-URL: Repository, https://github.com/Poseidonas/s7-gateway
Project-URL: Issues, https://github.com/Poseidonas/s7-gateway/issues
Keywords: siemens,s7,plc,snap7,industrial,rest
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Manufacturing
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: python-snap7>=3.1
Requires-Dist: fastapi>=0.110
Requires-Dist: pydantic>=2.6
Requires-Dist: PyYAML>=6.0
Requires-Dist: uvicorn>=0.29
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: httpx>=0.27; extra == "test"
Requires-Dist: ruff>=0.4; extra == "test"
Dynamic: license-file

# s7-gateway

An HTTP interface to Siemens S7 controllers. It reads and writes the tags you
declare in a YAML file, and exposes them for Prometheus.

It exists because getting a value out of an S7 PLC from anything that is not
TIA Portal usually means writing snap7 code, and every plant writes that code
again. This is that code, written once, with the addresses in a file instead of
in a script.

```console
$ curl localhost:8080/plcs/line1/tags/motor_temp
{"plc":"line1","tag":"motor_temp","value":72.5,"type":"real","unit":"°C"}
```

## Status

First public version, and it should be read as one.

Every path is tested against a real S7 server over the actual protocol, but on
localhost — **it has not yet been run against physical hardware.** The protocol
work is `python-snap7`'s and is well travelled; what is new here is the layer
above it, and that is what needs your plant to find the rest of. Reports from a
real line are the most useful thing anyone can send.

Read paths first, and read
[Before you point it at a real line](#before-you-point-it-at-a-real-line).

## Install

```console
pip install git+https://github.com/Poseidonas/s7-gateway
```

Python 3.11+. `python-snap7` 3.1+ speaks S7 in Python over a plain socket, so
there is no snap7 C library to install and nothing to compile.

## Configure

Nothing is discovered: a tag exists if you declare it, and it is read-only
unless you say otherwise.

```yaml
plcs:
  line1:
    host: 192.168.0.10
    rack: 0          # default
    slot: 1          # default; 2 on some S7-300 CPUs
    port: 102        # default

    tags:
      motor_temp:
        db: 10
        offset: 0
        type: real
        unit: "°C"
        description: Motor winding temperature

      line_speed:
        db: 10
        offset: 4
        type: int
        unit: m/min
        writable: true

      running:
        db: 10
        offset: 8
        type: bool
        bit: 0       # required for bool: which bit of the byte
```

Check it before starting anything. This reads the file only — it does not
contact a controller:

```console
$ s7-gateway --config config.yml --check
line1  192.168.0.10:102  rack 0 slot 1
    line_speed               int    DB10.DBX4        rw  m/min
    motor_temp               real   DB10.DBX0        r-  °C
    running                  bool   DB10.DBX8.0      r-
```

## Run

```console
s7-gateway --config config.yml
```

It listens on `127.0.0.1:8080`. Pass `--host 0.0.0.0` to accept connections from
the network — deliberately not the default, since a config with a writable tag
would otherwise become reachable from the plant network the moment someone tries
it out.

Interactive documentation is at `/docs`.

To run it under your own ASGI host instead, point `S7GATEWAY_CONFIG` at the
file and use the factory:

```console
S7GATEWAY_CONFIG=config.yml uvicorn --factory s7gateway.api:create_app
```

## Endpoints

| | |
| --- | --- |
| `GET /health` | the gateway is up. Does not touch a controller |
| `GET /plcs` | the configured controllers |
| `GET /plcs/{plc}/tags` | the tags declared on one, with type and address |
| `GET /plcs/{plc}/values` | every tag in one request |
| `POST /plcs/{plc}/values` | write several tags in one request |
| `GET /plcs/{plc}/tags/{tag}` | one tag |
| `PUT /plcs/{plc}/tags/{tag}` | write one tag — `{"value": 1500}` |
| `GET /plcs/{plc}/status` | whether the controller answers, and its CPU state |
| `GET /plcs/{plc}/info` | CPU type, serial number, PDU length, and clock drift |
| `GET /metrics` | every tag in the Prometheus text format |

`GET /plcs/{plc}/values` reports a failing tag individually rather than failing
the whole response, so one bad address does not cost you the other readings:

```json
{"plc": "line1", "readings": [
  {"tag": "motor_temp", "type": "real", "value": 72.5, "unit": "°C", "error": null},
  {"tag": "line_speed", "type": "int", "value": null, "unit": null,
   "error": "could not read 'line_speed' from PLC 'line1' (DB10.DBX4): ..."}
]}
```

`GET /plcs/{plc}/status` answers `200` even when the controller is unreachable,
and says so in the body. A monitoring system needs to tell "the plant is down"
apart from "the gateway is down", which a `502` makes harder.

`POST /plcs/{plc}/values` takes `[{"tag": "line_speed", "value": 1500}, ...]`
and reports each tag separately. There is no transaction and the protocol
offers none, so a write that fails halfway leaves the earlier ones in place —
the response says which of them landed rather than implying all or nothing.

`GET /plcs/{plc}/info` reports what the CPU says it is, and
`clock_drift_seconds` — the controller's clock minus the gateway's. Drift is
the thing plants go hunting for after the fact, when timestamps in a PLC log
will not line up with anything else.

Status codes elsewhere: `404` unknown PLC or tag, `403` writing a read-only tag,
`400` a value the tag cannot hold, `502` the controller could not be reached.
Every message names the PLC and the tag.

## Prometheus

```yaml
scrape_configs:
  - job_name: plc
    static_configs:
      - targets: ["localhost:8080"]
```

Tag names travel in labels, not in metric names, because a tag may be called
whatever the plant calls it and a Prometheus metric name may not.

```
s7_up{plc="line1"} 1
s7_tag_value{plc="line1",tag="motor_temp"} 72.5
s7_tag_read_ok{plc="line1",tag="motor_temp"} 1
```

`s7_tag_value` carries numbers only; a `char` tag has no numeric form and is
reported through `s7_tag_read_ok` alone. Scraping reads every tag on every
controller, so keep the scrape interval away from the controller's cycle time.

## Types

`bool` `byte` `char` `sint` `usint` `int` `uint` `word` `dint` `udint` `dword`
`real` `lreal` `lint` `ulint` `lword`

Two values snap7 accepts and quietly converts are refused instead, because a
wrong number in a controller is worse than a rejected request:

- `1.5` into an `int` would be written as `1`
- `"off"` into a `bool` would be written as **true**, since `bool("off")` is `True`

A whole number arriving as `1500.0` is accepted — JSON from a browser has no
integers.

## Before you point it at a real line

- **Write access is opt-in per tag.** Nothing is writable unless the config says
  `writable: true`. Declare the tags you monitor and leave it at that until you
  have a reason not to.
- **There is no authentication.** Put it behind a reverse proxy or keep it on a
  host only your monitoring system can reach. Do not expose it to the plant
  network with writable tags declared.
- **Writing a `bool` writes the byte it lives in.** The gateway reads that byte
  first and puts the other seven bits back, but that is a read-modify-write: if
  the PLC program changes a neighbouring bit in between, that change is lost.
  Do not use it for a bit the program also writes.
- **One connection per controller**, reused and reopened after a drop. An S7 CPU
  allows only a handful of connections, so the gateway does not open more.

## Development

```console
pip install -e ".[test]"
pytest
```

The tests run against a real S7 server on localhost, over the actual protocol —
no mocking of snap7. Exactly one test injects a failure, because the test server
answers an unregistered data block with fabricated bytes rather than an error
and so cannot be made to fail on demand.

That last point is worth knowing if you write your own tests against
`snap7.server`: a read from a DB that does not exist returns `42 FF 12 34`
instead of raising, so a test can pass while reading an address that is not
there. Verified against python-snap7 3.1.2.

## Licence

MIT
