ciplink-tagserver 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. ciplink_tagserver-0.1.0/LICENSE +21 -0
  2. ciplink_tagserver-0.1.0/PKG-INFO +192 -0
  3. ciplink_tagserver-0.1.0/README.md +167 -0
  4. ciplink_tagserver-0.1.0/ciplink_tagserver/__init__.py +3 -0
  5. ciplink_tagserver-0.1.0/ciplink_tagserver/cli.py +128 -0
  6. ciplink_tagserver-0.1.0/ciplink_tagserver/config.py +76 -0
  7. ciplink_tagserver-0.1.0/ciplink_tagserver/mock.py +76 -0
  8. ciplink_tagserver-0.1.0/ciplink_tagserver/server.py +280 -0
  9. ciplink_tagserver-0.1.0/ciplink_tagserver/service.py +99 -0
  10. ciplink_tagserver-0.1.0/ciplink_tagserver/values.py +54 -0
  11. ciplink_tagserver-0.1.0/ciplink_tagserver/worker.py +205 -0
  12. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/PKG-INFO +192 -0
  13. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/SOURCES.txt +24 -0
  14. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/dependency_links.txt +1 -0
  15. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/entry_points.txt +2 -0
  16. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/requires.txt +3 -0
  17. ciplink_tagserver-0.1.0/ciplink_tagserver.egg-info/top_level.txt +1 -0
  18. ciplink_tagserver-0.1.0/pyproject.toml +46 -0
  19. ciplink_tagserver-0.1.0/setup.cfg +4 -0
  20. ciplink_tagserver-0.1.0/tests/test_config_values.py +91 -0
  21. ciplink_tagserver-0.1.0/tests/test_http.py +46 -0
  22. ciplink_tagserver-0.1.0/tests/test_mock.py +55 -0
  23. ciplink_tagserver-0.1.0/tests/test_service.py +86 -0
  24. ciplink_tagserver-0.1.0/tests/test_types.py +49 -0
  25. ciplink_tagserver-0.1.0/tests/test_worker.py +88 -0
  26. ciplink_tagserver-0.1.0/tests/test_ws.py +220 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rowan Ramamurthy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciplink-tagserver
3
+ Version: 0.1.0
4
+ Summary: One server per site that owns the PLC connections and serves tag values over a WebSocket
5
+ Author-email: Rowan Ramamurthy <rramamurthy@factoryautomation.com>
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://github.com/RowanRama/ciplink-tagserver
8
+ Project-URL: Driver, https://github.com/RowanRama/ciplink
9
+ Project-URL: React client, https://www.npmjs.com/package/@ciplink/react
10
+ Keywords: plc,allen-bradley,logix,ethernet-ip,hmi,websocket,react
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Manufacturing
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
17
+ Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: ciplink<0.2,>=0.1
22
+ Requires-Dist: fastapi
23
+ Requires-Dist: uvicorn[standard]
24
+ Dynamic: license-file
25
+
26
+ # ciplink-tagserver
27
+
28
+ One server per site that owns all the PLC connections and serves tag values to React (or any) apps
29
+ over a WebSocket. Apps never talk to the PLCs: each PLC gets one connection, one thread and one poll
30
+ loop, however many screens are open.
31
+
32
+ It is built on [ciplink](https://pypi.org/project/ciplink/), a fork of pycomm3 that reads by instance
33
+ ID and reloads its tag table by itself after online edits.
34
+
35
+ ## Install
36
+
37
+ ```
38
+ pip install ciplink-tagserver
39
+ ```
40
+
41
+ or from a checkout with [uv](https://docs.astral.sh/uv/): `uv sync`, then `uv run ciplink-tagserver ...`.
42
+
43
+ ## Run
44
+
45
+ ```
46
+ ciplink-tagserver run --config tagserver.toml # real PLCs
47
+ ciplink-tagserver run --config tagserver.toml --mock # fake values, no PLC needed
48
+ ciplink-tagserver dump-tags --config tagserver.toml --plc PLC1 --out PLC1_tags.json
49
+ ciplink-tagserver types --url http://127.0.0.1:8765 --token T --plc PLC1 --out src/tags.d.ts
50
+ ```
51
+
52
+ `types` asks a running server for its tag list (`GET /tags`) and writes a TypeScript union,
53
+ `export type TagName = "Line1.Speed" | ...;`. AOIs appear as their readable members (`Valve1.Open`),
54
+ never their local tags. Pass `--plc` once per PLC; with more than one, names are qualified (`PLC1/Line1.Speed`).
55
+
56
+ Add `--log-file tagserver.log` to `run` to log to a file (rotated at 10 MB, 5 kept) instead of the console.
57
+
58
+ ## Run at Windows startup
59
+
60
+ From an administrator terminal (right-click Terminal, "Run as administrator"):
61
+
62
+ ```
63
+ ciplink-tagserver service install --config C:\TagServer\tagserver.toml
64
+ ciplink-tagserver service status
65
+ ciplink-tagserver service remove
66
+ ```
67
+
68
+ `install` registers a Task Scheduler task that starts at every boot as SYSTEM, without anyone logging in,
69
+ and starts it now. The task runs `ciplink-tagserver service run`, which starts the server again 5 s after
70
+ it stops for any reason; Task Scheduler also restarts the task itself if it fails. Logs go to
71
+ `tagserver.log` next to the config (`--log-file` to change it). Use full paths in the config, or paths
72
+ relative to it; the task runs in the config's folder. `--name` sets the task name (default
73
+ `ciplink-tagserver`), so several servers can run on one PC with different configs and ports.
74
+
75
+ ## Config
76
+
77
+ ```toml
78
+ [server]
79
+ host = "127.0.0.1" # localhost only by default
80
+ port = 8765
81
+ cycle_ms = 250 # poll period per PLC
82
+ audit_log = "audit.log" # optional, JSON lines of every write attempt
83
+
84
+ [plc.PLC1]
85
+ path = "192.168.1.10" # a ciplink/pycomm3 path, e.g. "10.0.0.5/1/3"
86
+ tag_cache_file = "cache/PLC1.json" # optional: fast restarts
87
+ mock_tags = "PLC1_tags.json" # optional: used only with --mock
88
+
89
+ [token.viewer]
90
+ secret = "change-me-too" # no write patterns: read-only
91
+
92
+ [token.hmi1]
93
+ secret = "change-me"
94
+ write = ["PLC1/Line1.*.SpeedSP", "PLC1/Program:Station1.Door*"]
95
+ ```
96
+
97
+ Relative file paths are relative to the config file. A working example is in `examples/`.
98
+
99
+ **Tag names** are qualified as `<plc>/<tag>`, e.g. `PLC1/Program:Main.Speed` (a slash cannot appear
100
+ in a Logix tag name). With exactly one PLC configured, unqualified names mean that PLC.
101
+
102
+ **Write patterns** are `fnmatch` patterns matched against the qualified name, ignoring case. `*` also
103
+ matches dots, and `[...]` is a character class, so to allow `Arr[3]` write `Arr[[]3]` or `Arr?3?`.
104
+
105
+ ## Protocol (v1)
106
+
107
+ JSON messages over `ws://host:port/ws`.
108
+
109
+ Client to server:
110
+
111
+ | message | meaning |
112
+ |---|---|
113
+ | `{"op":"hello","v":1,"token":"<secret>"}` | must be the first message |
114
+ | `{"op":"sub","id":N,"tags":["..."],"tier":1}` | subscribe; reusing an id replaces that subscription |
115
+ | `{"op":"unsub","id":N}` | drop subscription N |
116
+ | `{"op":"write","id":N,"tag":"...","value":...}` | write one tag |
117
+
118
+ Server to client:
119
+
120
+ | message | when |
121
+ |---|---|
122
+ | `{"op":"welcome","v":1,"plcs":{"PLC1":"connected"},"write":true}` | after a good hello; `write` says whether the token has any write patterns |
123
+ | `{"op":"values","d":{"<tag>":[value, quality, t]}}` | changed values only; right after a `sub`, the values already cached |
124
+ | `{"op":"ack","id":N,"ok":true,"error":null}` | for `sub`, `unsub` and `write` |
125
+ | `{"op":"status","plcs":{"PLC1":"disconnected"}}` | a PLC changed state |
126
+ | `{"op":"meta","plc":"PLC1"}` | the PLC's tag table was reloaded (online edit, download): fetch `/tags` again |
127
+ | `{"op":"error","id":N,"error":"..."}` | bad request (`id` is null when unknown) |
128
+
129
+ - A bad token, a first message that is not `hello`, or another protocol version: the socket is closed with code **4401**.
130
+ - Tags in `values` are keyed by the name as the client subscribed it.
131
+ - `quality` is `good`, `bad` (the read failed), `missing` (no such tag, or an AOI local tag that cannot
132
+ be read from outside) or `stale` (PLC disconnected: the last value is kept). `value` is null for bad and missing.
133
+ - `t` is the time of the change in ms since the epoch.
134
+ - Values: structs and AOIs are objects, arrays are lists, bytes are hex strings, NaN and infinities are
135
+ the strings `"NaN"`, `"Infinity"`, `"-Infinity"`.
136
+ - PLC states: `connecting`, `connected`, `disconnected`. Reconnects back off 1, 2, 5, then every 10 s.
137
+ - `tier` is stored but not used yet: every subscribed tag is read every cycle.
138
+ - Writes go to the PLC's thread, run after that cycle's read, and the ack carries the driver's result.
139
+ They are never retried; a write while the PLC is not connected fails at once.
140
+
141
+ ## HTTP
142
+
143
+ - `GET /health`: per PLC `state`, `last_cycle_ms`, `last_ok_ts`, `reconnects`, `reloads`.
144
+ - `GET /stats`: `sessions`, and per PLC `reads_per_cycle`, `last_cycle_ms`, `cycle_ms`, `overruns`, `subscriptions`.
145
+ - `GET /tags?plc=NAME` with `Authorization: Bearer <secret>`: the PLC's tags with data type and, for
146
+ AOIs, `aoi_readable` (the members that can be read). `plc` may be left out with one PLC.
147
+
148
+ ## React
149
+
150
+ [`@ciplink/react`](client/) (in `client/`) is the React side: one provider that owns the WebSocket, and
151
+ hooks and components that subscribe while mounted.
152
+
153
+ ```tsx
154
+ import { TagServerProvider, TagValue, TagLamp, useWriteTag, useTagServer } from "@ciplink/react";
155
+
156
+ <TagServerProvider url="ws://127.0.0.1:8765/ws" token="change-me">
157
+ <TagValue name="Line1.Station1.Speed" decimals={1} unit="m/min" />
158
+ <TagLamp name="Line1.Running" />
159
+ </TagServerProvider>
160
+ ```
161
+
162
+ - `useTag(name)` gives `{value, quality, t}` and re-renders only when that tag changes; subscriptions are
163
+ counted across components and batched.
164
+ - `useTags(names)`, `useWriteTag(name)` (resolves with the write's ack), `useTagServer()` (connection
165
+ state, PLC states, `canWrite`).
166
+ - `<TagValue>` and `<TagLamp>` are fixed width with a quality marker, so nothing moves as values change.
167
+ - `<TagLink to as?>` prefetches the tags of a page registered with `registerPage(path, tags)`.
168
+ - Reconnects after 1, 2, 5, then every 10 s, re-subscribes, and shows values as `stale` meanwhile.
169
+
170
+ A runnable app is in [`examples/react-app/`](examples/react-app/): start the server with
171
+ `uv run ciplink-tagserver run --config examples/tagserver.toml --mock`, build `client/` once
172
+ (`npm install && npm run build`), then `npm install && npm run dev` in the example.
173
+
174
+ ## Mock mode
175
+
176
+ `--mock` replaces every PLC driver with a fake serving the tags in that PLC's `mock_tags` file:
177
+
178
+ ```json
179
+ {"Line1.Station1.Speed": "REAL", "Line1.Running": "BOOL", "PartCount": "DINT"}
180
+ ```
181
+
182
+ Numbers random-walk, BOOLs toggle now and then, writes are stored, unknown names are `missing`. Other
183
+ types (UDTs, AOIs) read as `bad`; list their atomic members by full name to simulate them.
184
+ `dump-tags` writes this file from a real PLC (it only uploads the tag list), so a mock can mirror it.
185
+
186
+ ## Safety
187
+
188
+ - Writes are off unless a token has `write` patterns, and only names matching them can be written.
189
+ - Every write attempt, allowed or not, is appended to the audit log.
190
+ - The server binds to `127.0.0.1` by default. Put it behind a TLS proxy before opening it to a network:
191
+ tokens travel in clear text over plain `ws://` and `http://`.
192
+ - Change the example secrets.
@@ -0,0 +1,167 @@
1
+ # ciplink-tagserver
2
+
3
+ One server per site that owns all the PLC connections and serves tag values to React (or any) apps
4
+ over a WebSocket. Apps never talk to the PLCs: each PLC gets one connection, one thread and one poll
5
+ loop, however many screens are open.
6
+
7
+ It is built on [ciplink](https://pypi.org/project/ciplink/), a fork of pycomm3 that reads by instance
8
+ ID and reloads its tag table by itself after online edits.
9
+
10
+ ## Install
11
+
12
+ ```
13
+ pip install ciplink-tagserver
14
+ ```
15
+
16
+ or from a checkout with [uv](https://docs.astral.sh/uv/): `uv sync`, then `uv run ciplink-tagserver ...`.
17
+
18
+ ## Run
19
+
20
+ ```
21
+ ciplink-tagserver run --config tagserver.toml # real PLCs
22
+ ciplink-tagserver run --config tagserver.toml --mock # fake values, no PLC needed
23
+ ciplink-tagserver dump-tags --config tagserver.toml --plc PLC1 --out PLC1_tags.json
24
+ ciplink-tagserver types --url http://127.0.0.1:8765 --token T --plc PLC1 --out src/tags.d.ts
25
+ ```
26
+
27
+ `types` asks a running server for its tag list (`GET /tags`) and writes a TypeScript union,
28
+ `export type TagName = "Line1.Speed" | ...;`. AOIs appear as their readable members (`Valve1.Open`),
29
+ never their local tags. Pass `--plc` once per PLC; with more than one, names are qualified (`PLC1/Line1.Speed`).
30
+
31
+ Add `--log-file tagserver.log` to `run` to log to a file (rotated at 10 MB, 5 kept) instead of the console.
32
+
33
+ ## Run at Windows startup
34
+
35
+ From an administrator terminal (right-click Terminal, "Run as administrator"):
36
+
37
+ ```
38
+ ciplink-tagserver service install --config C:\TagServer\tagserver.toml
39
+ ciplink-tagserver service status
40
+ ciplink-tagserver service remove
41
+ ```
42
+
43
+ `install` registers a Task Scheduler task that starts at every boot as SYSTEM, without anyone logging in,
44
+ and starts it now. The task runs `ciplink-tagserver service run`, which starts the server again 5 s after
45
+ it stops for any reason; Task Scheduler also restarts the task itself if it fails. Logs go to
46
+ `tagserver.log` next to the config (`--log-file` to change it). Use full paths in the config, or paths
47
+ relative to it; the task runs in the config's folder. `--name` sets the task name (default
48
+ `ciplink-tagserver`), so several servers can run on one PC with different configs and ports.
49
+
50
+ ## Config
51
+
52
+ ```toml
53
+ [server]
54
+ host = "127.0.0.1" # localhost only by default
55
+ port = 8765
56
+ cycle_ms = 250 # poll period per PLC
57
+ audit_log = "audit.log" # optional, JSON lines of every write attempt
58
+
59
+ [plc.PLC1]
60
+ path = "192.168.1.10" # a ciplink/pycomm3 path, e.g. "10.0.0.5/1/3"
61
+ tag_cache_file = "cache/PLC1.json" # optional: fast restarts
62
+ mock_tags = "PLC1_tags.json" # optional: used only with --mock
63
+
64
+ [token.viewer]
65
+ secret = "change-me-too" # no write patterns: read-only
66
+
67
+ [token.hmi1]
68
+ secret = "change-me"
69
+ write = ["PLC1/Line1.*.SpeedSP", "PLC1/Program:Station1.Door*"]
70
+ ```
71
+
72
+ Relative file paths are relative to the config file. A working example is in `examples/`.
73
+
74
+ **Tag names** are qualified as `<plc>/<tag>`, e.g. `PLC1/Program:Main.Speed` (a slash cannot appear
75
+ in a Logix tag name). With exactly one PLC configured, unqualified names mean that PLC.
76
+
77
+ **Write patterns** are `fnmatch` patterns matched against the qualified name, ignoring case. `*` also
78
+ matches dots, and `[...]` is a character class, so to allow `Arr[3]` write `Arr[[]3]` or `Arr?3?`.
79
+
80
+ ## Protocol (v1)
81
+
82
+ JSON messages over `ws://host:port/ws`.
83
+
84
+ Client to server:
85
+
86
+ | message | meaning |
87
+ |---|---|
88
+ | `{"op":"hello","v":1,"token":"<secret>"}` | must be the first message |
89
+ | `{"op":"sub","id":N,"tags":["..."],"tier":1}` | subscribe; reusing an id replaces that subscription |
90
+ | `{"op":"unsub","id":N}` | drop subscription N |
91
+ | `{"op":"write","id":N,"tag":"...","value":...}` | write one tag |
92
+
93
+ Server to client:
94
+
95
+ | message | when |
96
+ |---|---|
97
+ | `{"op":"welcome","v":1,"plcs":{"PLC1":"connected"},"write":true}` | after a good hello; `write` says whether the token has any write patterns |
98
+ | `{"op":"values","d":{"<tag>":[value, quality, t]}}` | changed values only; right after a `sub`, the values already cached |
99
+ | `{"op":"ack","id":N,"ok":true,"error":null}` | for `sub`, `unsub` and `write` |
100
+ | `{"op":"status","plcs":{"PLC1":"disconnected"}}` | a PLC changed state |
101
+ | `{"op":"meta","plc":"PLC1"}` | the PLC's tag table was reloaded (online edit, download): fetch `/tags` again |
102
+ | `{"op":"error","id":N,"error":"..."}` | bad request (`id` is null when unknown) |
103
+
104
+ - A bad token, a first message that is not `hello`, or another protocol version: the socket is closed with code **4401**.
105
+ - Tags in `values` are keyed by the name as the client subscribed it.
106
+ - `quality` is `good`, `bad` (the read failed), `missing` (no such tag, or an AOI local tag that cannot
107
+ be read from outside) or `stale` (PLC disconnected: the last value is kept). `value` is null for bad and missing.
108
+ - `t` is the time of the change in ms since the epoch.
109
+ - Values: structs and AOIs are objects, arrays are lists, bytes are hex strings, NaN and infinities are
110
+ the strings `"NaN"`, `"Infinity"`, `"-Infinity"`.
111
+ - PLC states: `connecting`, `connected`, `disconnected`. Reconnects back off 1, 2, 5, then every 10 s.
112
+ - `tier` is stored but not used yet: every subscribed tag is read every cycle.
113
+ - Writes go to the PLC's thread, run after that cycle's read, and the ack carries the driver's result.
114
+ They are never retried; a write while the PLC is not connected fails at once.
115
+
116
+ ## HTTP
117
+
118
+ - `GET /health`: per PLC `state`, `last_cycle_ms`, `last_ok_ts`, `reconnects`, `reloads`.
119
+ - `GET /stats`: `sessions`, and per PLC `reads_per_cycle`, `last_cycle_ms`, `cycle_ms`, `overruns`, `subscriptions`.
120
+ - `GET /tags?plc=NAME` with `Authorization: Bearer <secret>`: the PLC's tags with data type and, for
121
+ AOIs, `aoi_readable` (the members that can be read). `plc` may be left out with one PLC.
122
+
123
+ ## React
124
+
125
+ [`@ciplink/react`](client/) (in `client/`) is the React side: one provider that owns the WebSocket, and
126
+ hooks and components that subscribe while mounted.
127
+
128
+ ```tsx
129
+ import { TagServerProvider, TagValue, TagLamp, useWriteTag, useTagServer } from "@ciplink/react";
130
+
131
+ <TagServerProvider url="ws://127.0.0.1:8765/ws" token="change-me">
132
+ <TagValue name="Line1.Station1.Speed" decimals={1} unit="m/min" />
133
+ <TagLamp name="Line1.Running" />
134
+ </TagServerProvider>
135
+ ```
136
+
137
+ - `useTag(name)` gives `{value, quality, t}` and re-renders only when that tag changes; subscriptions are
138
+ counted across components and batched.
139
+ - `useTags(names)`, `useWriteTag(name)` (resolves with the write's ack), `useTagServer()` (connection
140
+ state, PLC states, `canWrite`).
141
+ - `<TagValue>` and `<TagLamp>` are fixed width with a quality marker, so nothing moves as values change.
142
+ - `<TagLink to as?>` prefetches the tags of a page registered with `registerPage(path, tags)`.
143
+ - Reconnects after 1, 2, 5, then every 10 s, re-subscribes, and shows values as `stale` meanwhile.
144
+
145
+ A runnable app is in [`examples/react-app/`](examples/react-app/): start the server with
146
+ `uv run ciplink-tagserver run --config examples/tagserver.toml --mock`, build `client/` once
147
+ (`npm install && npm run build`), then `npm install && npm run dev` in the example.
148
+
149
+ ## Mock mode
150
+
151
+ `--mock` replaces every PLC driver with a fake serving the tags in that PLC's `mock_tags` file:
152
+
153
+ ```json
154
+ {"Line1.Station1.Speed": "REAL", "Line1.Running": "BOOL", "PartCount": "DINT"}
155
+ ```
156
+
157
+ Numbers random-walk, BOOLs toggle now and then, writes are stored, unknown names are `missing`. Other
158
+ types (UDTs, AOIs) read as `bad`; list their atomic members by full name to simulate them.
159
+ `dump-tags` writes this file from a real PLC (it only uploads the tag list), so a mock can mirror it.
160
+
161
+ ## Safety
162
+
163
+ - Writes are off unless a token has `write` patterns, and only names matching them can be written.
164
+ - Every write attempt, allowed or not, is appended to the audit log.
165
+ - The server binds to `127.0.0.1` by default. Put it behind a TLS proxy before opening it to a network:
166
+ tokens travel in clear text over plain `ws://` and `http://`.
167
+ - Change the example secrets.
@@ -0,0 +1,3 @@
1
+ """One server per site that owns the PLC connections and serves tag values over a WebSocket."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,128 @@
1
+ """ciplink-tagserver run | dump-tags | types | service"""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import logging.handlers
7
+ from pathlib import Path
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ from .config import load_config
12
+ from .values import type_name
13
+
14
+
15
+ def run(args):
16
+ import uvicorn
17
+
18
+ from .server import create_app
19
+
20
+ config = load_config(args.config)
21
+ log_config = None if args.log_file else uvicorn.config.LOGGING_CONFIG # with a log file, uvicorn logs go there too
22
+ uvicorn.run(create_app(config, mock=args.mock), host=config.host, port=config.port, log_config=log_config)
23
+
24
+
25
+ def service(args):
26
+ from . import service as svc
27
+
28
+ config = Path(args.config).resolve()
29
+ log_file = Path(args.log_file).resolve() if args.log_file else config.with_name("tagserver.log")
30
+ if args.action == "install":
31
+ return svc.install(args.name, config, log_file)
32
+ if args.action == "remove":
33
+ return svc.remove(args.name)
34
+ if args.action == "status":
35
+ return svc.status(args.name)
36
+ args.mock, args.config, args.log_file = False, str(config), str(log_file)
37
+ svc.supervise(lambda: run(args)) # "run": what the installed task executes
38
+
39
+
40
+ def setup_logging(log_file=None):
41
+ handlers = [logging.StreamHandler()]
42
+ if log_file:
43
+ handlers = [logging.handlers.RotatingFileHandler(log_file, maxBytes=10_000_000, backupCount=5, encoding="utf-8")]
44
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", handlers=handlers)
45
+
46
+
47
+ def dump_tags(args):
48
+ """Uploads the tag list of one PLC (reads only) and writes it in the mock_tags format."""
49
+ from .server import driver_factory
50
+
51
+ config = load_config(args.config)
52
+ with driver_factory(config.plcs[args.plc]) as plc:
53
+ tags = {name: type_name(info) for name, info in plc.tags.items()}
54
+ with open(args.out, "w", encoding="utf-8") as file:
55
+ json.dump(tags, file, indent=2)
56
+ print(f"wrote {len(tags)} tags to {args.out}")
57
+
58
+
59
+ def fetch_tags(url, token, plc):
60
+ """GET <url>/tags?plc=NAME; returns the "tags" list."""
61
+ query = f"/tags?plc={urllib.parse.quote(plc)}" if plc else "/tags"
62
+ request = urllib.request.Request(url.rstrip("/") + query, headers={"Authorization": f"Bearer {token}"})
63
+ with urllib.request.urlopen(request) as response:
64
+ return json.load(response)["tags"]
65
+
66
+
67
+ def tag_names(tags):
68
+ """Readable names: each tag, but for an AOI its readable members (Inst.Member) instead of the instance."""
69
+ names = []
70
+ for tag in tags:
71
+ if "aoi_readable" in tag:
72
+ names += [f"{tag['name']}.{member}" for member in tag["aoi_readable"]]
73
+ else:
74
+ names.append(tag["name"])
75
+ return names
76
+
77
+
78
+ def types(args):
79
+ """Writes a TypeScript union of the tag names served by a running tag server."""
80
+ plcs = args.plc or [None]
81
+ names = []
82
+ for plc in plcs:
83
+ prefix = f"{plc}/" if len(plcs) > 1 else ""
84
+ names += [prefix + name for name in tag_names(fetch_tags(args.url, args.token, plc))]
85
+ names = list(dict.fromkeys(names))
86
+ lines = "".join(f"\n | {json.dumps(name)}" for name in names) or " never"
87
+ with open(args.out, "w", encoding="utf-8") as file:
88
+ file.write(f"// Generated by ciplink-tagserver types. Do not edit.\nexport type TagName ={lines};\n")
89
+ print(f"wrote {len(names)} tag names to {args.out}")
90
+
91
+
92
+ def main(argv=None):
93
+ parser = argparse.ArgumentParser(prog="ciplink-tagserver")
94
+ commands = parser.add_subparsers(dest="command", required=True)
95
+
96
+ p = commands.add_parser("run", help="serve tag values over a WebSocket")
97
+ p.add_argument("--config", default="tagserver.toml")
98
+ p.add_argument("--mock", action="store_true", help="serve fake values from each PLC's mock_tags file")
99
+ p.add_argument("--log-file", help="write logs to this file (rotated at 10 MB, 5 kept) instead of the console")
100
+ p.set_defaults(func=run)
101
+
102
+ p = commands.add_parser("service", help="run at Windows startup (install/remove need an administrator terminal)")
103
+ p.add_argument("action", choices=["install", "remove", "status", "run"])
104
+ p.add_argument("--config", default="tagserver.toml")
105
+ p.add_argument("--name", default="ciplink-tagserver", help="Task Scheduler task name")
106
+ p.add_argument("--log-file", help="default: tagserver.log next to the config")
107
+ p.set_defaults(func=service)
108
+
109
+ p = commands.add_parser("dump-tags", help="write a PLC's tag list as a mock_tags file")
110
+ p.add_argument("--config", default="tagserver.toml")
111
+ p.add_argument("--plc", required=True)
112
+ p.add_argument("--out", required=True)
113
+ p.set_defaults(func=dump_tags)
114
+
115
+ p = commands.add_parser("types", help="write the tag names of a running server as a TypeScript type")
116
+ p.add_argument("--url", default="http://127.0.0.1:8765")
117
+ p.add_argument("--token", required=True)
118
+ p.add_argument("--plc", action="append", help="repeat for several PLCs; names are then qualified as PLC/tag")
119
+ p.add_argument("--out", required=True)
120
+ p.set_defaults(func=types)
121
+
122
+ args = parser.parse_args(argv)
123
+ setup_logging(getattr(args, "log_file", None))
124
+ return args.func(args)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ """Reads the TOML config file."""
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class PlcConfig:
10
+ name: str
11
+ path: str
12
+ tag_cache_file: str | None = None
13
+ mock_tags: str | None = None
14
+
15
+
16
+ @dataclass
17
+ class TokenConfig:
18
+ name: str
19
+ secret: str
20
+ write: list[str] = field(default_factory=list) # fnmatch patterns on qualified names
21
+
22
+
23
+ @dataclass
24
+ class Config:
25
+ host: str = "127.0.0.1"
26
+ port: int = 8765
27
+ cycle_ms: int = 250
28
+ audit_log: str = "audit.log"
29
+ plcs: dict[str, PlcConfig] = field(default_factory=dict)
30
+ tokens: dict[str, TokenConfig] = field(default_factory=dict)
31
+
32
+ def qualify(self, name: str) -> tuple[str, str]:
33
+ """Splits ``<plc>/<tag>`` into (plc, tag); an unqualified name means the only PLC, if there is one."""
34
+ if "/" in name:
35
+ plc, tag = name.split("/", 1)
36
+ if plc not in self.plcs:
37
+ raise ValueError(f"unknown PLC {plc!r}")
38
+ elif len(self.plcs) == 1:
39
+ plc, tag = next(iter(self.plcs)), name
40
+ else:
41
+ raise ValueError(f"tag {name!r} must be qualified as <plc>/<tag>")
42
+ if not tag:
43
+ raise ValueError(f"empty tag name in {name!r}")
44
+ return plc, tag
45
+
46
+
47
+ def load_config(path) -> Config:
48
+ path = Path(path)
49
+ with open(path, "rb") as file:
50
+ return parse_config(tomllib.load(file), path.parent)
51
+
52
+
53
+ def parse_config(data: dict, base_dir=".") -> Config:
54
+ """Builds a Config from the parsed TOML; relative file paths are relative to ``base_dir``."""
55
+ base = Path(base_dir)
56
+
57
+ def resolve(value):
58
+ return str(base / value) if value else None
59
+
60
+ server = data.get("server", {})
61
+ config = Config(
62
+ host=server.get("host", "127.0.0.1"),
63
+ port=server.get("port", 8765),
64
+ cycle_ms=server.get("cycle_ms", 250),
65
+ audit_log=resolve(server.get("audit_log", "audit.log")),
66
+ )
67
+ for name, plc in data.get("plc", {}).items():
68
+ config.plcs[name] = PlcConfig(
69
+ name=name,
70
+ path=plc["path"],
71
+ tag_cache_file=resolve(plc.get("tag_cache_file")),
72
+ mock_tags=resolve(plc.get("mock_tags")),
73
+ )
74
+ for name, token in data.get("token", {}).items():
75
+ config.tokens[name] = TokenConfig(name=name, secret=token["secret"], write=list(token.get("write", [])))
76
+ return config
@@ -0,0 +1,76 @@
1
+ """A fake driver for --mock: serves the tags listed in the PLC's ``mock_tags`` JSON file."""
2
+
3
+ import json
4
+ import random
5
+
6
+ from ciplink import Tag
7
+
8
+ INTS = {"SINT", "INT", "DINT", "LINT", "USINT", "UINT", "UDINT", "ULINT"}
9
+ FLOATS = {"REAL", "LREAL"}
10
+
11
+
12
+ def initial(data_type):
13
+ if data_type == "BOOL":
14
+ return False
15
+ if data_type in INTS:
16
+ return 0
17
+ if data_type in FLOATS:
18
+ return 0.0
19
+ if data_type == "STRING":
20
+ return ""
21
+ return None
22
+
23
+
24
+ class MockDriver:
25
+ """Same calls as LogixDriver as far as the worker uses them; numbers random-walk, BOOLs toggle."""
26
+
27
+ def __init__(self, cfg):
28
+ types = {}
29
+ if cfg.mock_tags:
30
+ with open(cfg.mock_tags, encoding="utf-8") as file:
31
+ types = json.load(file)
32
+ self.types = types
33
+ self.values = {name: initial(data_type) for name, data_type in types.items()}
34
+ self.data_types = {}
35
+ self._tag_table_version = 1
36
+ self._random = random.Random()
37
+
38
+ @property
39
+ def tags(self):
40
+ return {name: {"tag_name": name, "tag_type": "atomic", "data_type": t, "data_type_name": t}
41
+ for name, t in self.types.items()}
42
+
43
+ def open(self):
44
+ return True
45
+
46
+ def close(self):
47
+ pass
48
+
49
+ def _step(self, name):
50
+ data_type, value = self.types[name], self.values[name]
51
+ if data_type == "BOOL":
52
+ if self._random.random() < 0.1:
53
+ value = not value
54
+ elif data_type in INTS:
55
+ value += self._random.choice((-1, 0, 1))
56
+ elif data_type in FLOATS:
57
+ value = round(value + self._random.uniform(-1, 1), 3)
58
+ self.values[name] = value
59
+ return value
60
+
61
+ def read(self, *tags):
62
+ results = []
63
+ for name in tags:
64
+ if name not in self.types:
65
+ results.append(Tag(name, None, None, f"Tag doesn't exist - {name}"))
66
+ elif self.values[name] is None:
67
+ results.append(Tag(name, None, self.types[name], f"mock does not simulate {self.types[name]}"))
68
+ else:
69
+ results.append(Tag(name, self._step(name), self.types[name], None))
70
+ return results[0] if len(results) == 1 else results
71
+
72
+ def write(self, name, value):
73
+ if name not in self.types:
74
+ return Tag(name, None, None, f"Tag doesn't exist - {name}")
75
+ self.values[name] = value
76
+ return Tag(name, value, self.types[name], None)