ciplink-tagserver 0.1.0__py3-none-any.whl
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.
- ciplink_tagserver/__init__.py +3 -0
- ciplink_tagserver/cli.py +128 -0
- ciplink_tagserver/config.py +76 -0
- ciplink_tagserver/mock.py +76 -0
- ciplink_tagserver/server.py +280 -0
- ciplink_tagserver/service.py +99 -0
- ciplink_tagserver/values.py +54 -0
- ciplink_tagserver/worker.py +205 -0
- ciplink_tagserver-0.1.0.dist-info/METADATA +192 -0
- ciplink_tagserver-0.1.0.dist-info/RECORD +14 -0
- ciplink_tagserver-0.1.0.dist-info/WHEEL +5 -0
- ciplink_tagserver-0.1.0.dist-info/entry_points.txt +2 -0
- ciplink_tagserver-0.1.0.dist-info/licenses/LICENSE +21 -0
- ciplink_tagserver-0.1.0.dist-info/top_level.txt +1 -0
ciplink_tagserver/cli.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The hub (subscriptions, fan-out, writes) and the FastAPI app (WebSocket /ws plus HTTP)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from contextlib import asynccontextmanager, suppress
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
14
|
+
|
|
15
|
+
from .worker import PlcWorker
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
PROTOCOL = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def driver_factory(cfg):
|
|
23
|
+
from ciplink import LogixDriver
|
|
24
|
+
|
|
25
|
+
if cfg.tag_cache_file:
|
|
26
|
+
os.makedirs(os.path.dirname(os.path.abspath(cfg.tag_cache_file)), exist_ok=True)
|
|
27
|
+
return LogixDriver(cfg.path, tag_cache_file=cfg.tag_cache_file)
|
|
28
|
+
return LogixDriver(cfg.path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def mock_factory(cfg):
|
|
32
|
+
from .mock import MockDriver
|
|
33
|
+
|
|
34
|
+
return MockDriver(cfg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Session:
|
|
38
|
+
def __init__(self, token):
|
|
39
|
+
self.token = token
|
|
40
|
+
self.subs = {} # sub id -> {"tier": int, "tags": {qualified name: name as the client sent it}}
|
|
41
|
+
self.queue = asyncio.Queue() # messages to send
|
|
42
|
+
|
|
43
|
+
def name_of(self, qname):
|
|
44
|
+
for sub in self.subs.values():
|
|
45
|
+
if qname in sub["tags"]:
|
|
46
|
+
return sub["tags"][qname]
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Hub:
|
|
51
|
+
def __init__(self, config, factory):
|
|
52
|
+
self.config = config
|
|
53
|
+
self.sessions = set()
|
|
54
|
+
self.refs = Counter() # qualified name -> subscriptions across sessions
|
|
55
|
+
self.loop = None
|
|
56
|
+
self.workers = {
|
|
57
|
+
name: PlcWorker(cfg, factory, config.cycle_ms, self._on_values, self._on_status, self._on_meta)
|
|
58
|
+
for name, cfg in config.plcs.items()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def start(self, loop):
|
|
62
|
+
self.loop = loop
|
|
63
|
+
for worker in self.workers.values():
|
|
64
|
+
worker.start()
|
|
65
|
+
|
|
66
|
+
def stop(self):
|
|
67
|
+
for worker in self.workers.values():
|
|
68
|
+
worker.stop()
|
|
69
|
+
|
|
70
|
+
def states(self):
|
|
71
|
+
return {name: worker.state for name, worker in self.workers.items()}
|
|
72
|
+
|
|
73
|
+
def authenticate(self, secret):
|
|
74
|
+
if not isinstance(secret, str):
|
|
75
|
+
return None
|
|
76
|
+
for token in self.config.tokens.values():
|
|
77
|
+
if hmac.compare_digest(token.secret.encode(), secret.encode()):
|
|
78
|
+
return token
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# worker threads -> event loop
|
|
82
|
+
|
|
83
|
+
def _call(self, func, *args):
|
|
84
|
+
try:
|
|
85
|
+
self.loop.call_soon_threadsafe(func, *args)
|
|
86
|
+
except RuntimeError: # loop closed during shutdown
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def _on_values(self, plc, changes):
|
|
90
|
+
self._call(self._fan_values, plc, changes)
|
|
91
|
+
|
|
92
|
+
def _on_status(self, plc, state):
|
|
93
|
+
self._call(self._broadcast, "status")
|
|
94
|
+
|
|
95
|
+
def _on_meta(self, plc):
|
|
96
|
+
self._call(self._broadcast, "meta", plc)
|
|
97
|
+
|
|
98
|
+
def _fan_values(self, plc, changes):
|
|
99
|
+
for session in self.sessions:
|
|
100
|
+
d = {}
|
|
101
|
+
for tag, entry in changes.items():
|
|
102
|
+
name = session.name_of(f"{plc}/{tag}")
|
|
103
|
+
if name is not None:
|
|
104
|
+
d[name] = entry
|
|
105
|
+
if d:
|
|
106
|
+
session.queue.put_nowait({"op": "values", "d": d})
|
|
107
|
+
|
|
108
|
+
def _broadcast(self, op, plc=None):
|
|
109
|
+
message = {"op": "status", "plcs": self.states()} if op == "status" else {"op": "meta", "plc": plc}
|
|
110
|
+
for session in self.sessions:
|
|
111
|
+
session.queue.put_nowait(message)
|
|
112
|
+
|
|
113
|
+
# requests, on the event loop
|
|
114
|
+
|
|
115
|
+
def subscribe(self, session, sub_id, names, tier):
|
|
116
|
+
"""Returns the cached values of the subscribed tags, keyed by the names as given."""
|
|
117
|
+
if not isinstance(names, list):
|
|
118
|
+
raise TypeError("tags must be a list")
|
|
119
|
+
tags = {}
|
|
120
|
+
for name in names:
|
|
121
|
+
plc, tag = self.config.qualify(name)
|
|
122
|
+
tags[f"{plc}/{tag}"] = name
|
|
123
|
+
if sub_id in session.subs:
|
|
124
|
+
self.unsubscribe(session, sub_id)
|
|
125
|
+
session.subs[sub_id] = {"tier": tier, "tags": tags}
|
|
126
|
+
snapshot = {}
|
|
127
|
+
for qname, name in tags.items():
|
|
128
|
+
plc, tag = qname.split("/", 1)
|
|
129
|
+
self.refs[qname] += 1
|
|
130
|
+
if self.refs[qname] == 1:
|
|
131
|
+
self.workers[plc].add_tags([tag])
|
|
132
|
+
entry = self.workers[plc].snapshot([tag]).get(tag)
|
|
133
|
+
if entry is not None:
|
|
134
|
+
snapshot[name] = entry
|
|
135
|
+
return snapshot
|
|
136
|
+
|
|
137
|
+
def unsubscribe(self, session, sub_id):
|
|
138
|
+
sub = session.subs.pop(sub_id, None)
|
|
139
|
+
if sub is None:
|
|
140
|
+
return False
|
|
141
|
+
for qname in sub["tags"]:
|
|
142
|
+
self.refs[qname] -= 1
|
|
143
|
+
if self.refs[qname] <= 0:
|
|
144
|
+
del self.refs[qname]
|
|
145
|
+
plc, tag = qname.split("/", 1)
|
|
146
|
+
self.workers[plc].remove_tags([tag])
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def drop(self, session):
|
|
150
|
+
for sub_id in list(session.subs):
|
|
151
|
+
self.unsubscribe(session, sub_id)
|
|
152
|
+
self.sessions.discard(session)
|
|
153
|
+
|
|
154
|
+
async def write(self, session, name, value):
|
|
155
|
+
qname = name
|
|
156
|
+
try:
|
|
157
|
+
plc, tag = self.config.qualify(name)
|
|
158
|
+
qname = f"{plc}/{tag}"
|
|
159
|
+
if not any(fnmatchcase(qname.lower(), p.lower()) for p in session.token.write):
|
|
160
|
+
ok, error = False, "not allowed"
|
|
161
|
+
else:
|
|
162
|
+
ok, error = await asyncio.wrap_future(self.workers[plc].write(tag, value))
|
|
163
|
+
except (ValueError, TypeError) as err:
|
|
164
|
+
ok, error = False, str(err)
|
|
165
|
+
self.audit(session.token.name, qname, value, ok, error)
|
|
166
|
+
return ok, error
|
|
167
|
+
|
|
168
|
+
def audit(self, token, tag, value, ok, error):
|
|
169
|
+
line = {"time": datetime.now(timezone.utc).isoformat(), "token": token, "tag": tag,
|
|
170
|
+
"value": value, "ok": ok, "error": error}
|
|
171
|
+
with open(self.config.audit_log, "a", encoding="utf-8") as file:
|
|
172
|
+
file.write(json.dumps(line, default=str) + "\n")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_app(config, factory=None, mock=False) -> FastAPI:
|
|
176
|
+
hub = Hub(config, factory or (mock_factory if mock else driver_factory))
|
|
177
|
+
|
|
178
|
+
@asynccontextmanager
|
|
179
|
+
async def lifespan(app):
|
|
180
|
+
hub.start(asyncio.get_running_loop())
|
|
181
|
+
yield
|
|
182
|
+
hub.stop()
|
|
183
|
+
|
|
184
|
+
app = FastAPI(title="ciplink-tagserver", lifespan=lifespan)
|
|
185
|
+
app.state.hub = hub
|
|
186
|
+
|
|
187
|
+
@app.get("/health")
|
|
188
|
+
def health():
|
|
189
|
+
return {"plcs": {
|
|
190
|
+
name: {"state": w.state, "last_cycle_ms": w.last_cycle_ms, "last_ok_ts": w.last_ok_ts,
|
|
191
|
+
"reconnects": w.reconnects, "reloads": w.reloads}
|
|
192
|
+
for name, w in hub.workers.items()
|
|
193
|
+
}}
|
|
194
|
+
|
|
195
|
+
@app.get("/stats")
|
|
196
|
+
def stats():
|
|
197
|
+
return {
|
|
198
|
+
"sessions": len(hub.sessions),
|
|
199
|
+
"plcs": {
|
|
200
|
+
name: {"reads_per_cycle": w.reads_per_cycle, "last_cycle_ms": w.last_cycle_ms,
|
|
201
|
+
"cycle_ms": config.cycle_ms, "overruns": w.overruns, "subscriptions": len(w.tags)}
|
|
202
|
+
for name, w in hub.workers.items()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@app.get("/tags")
|
|
207
|
+
def tags(request: Request, plc: str | None = None):
|
|
208
|
+
auth = request.headers.get("authorization", "")
|
|
209
|
+
if not auth.startswith("Bearer ") or hub.authenticate(auth[7:]) is None:
|
|
210
|
+
raise HTTPException(401, "invalid token")
|
|
211
|
+
if plc is None and len(hub.workers) == 1:
|
|
212
|
+
plc = next(iter(hub.workers))
|
|
213
|
+
if plc not in hub.workers:
|
|
214
|
+
raise HTTPException(404, f"unknown PLC {plc!r}")
|
|
215
|
+
worker = hub.workers[plc]
|
|
216
|
+
return {"plc": plc, "state": worker.state, "tags": worker.tag_list}
|
|
217
|
+
|
|
218
|
+
@app.websocket("/ws")
|
|
219
|
+
async def ws_endpoint(ws: WebSocket):
|
|
220
|
+
await ws.accept()
|
|
221
|
+
try:
|
|
222
|
+
hello = await ws.receive_json()
|
|
223
|
+
except (WebSocketDisconnect, ValueError):
|
|
224
|
+
hello = None
|
|
225
|
+
token = None
|
|
226
|
+
if isinstance(hello, dict) and hello.get("op") == "hello" and hello.get("v") == PROTOCOL:
|
|
227
|
+
token = hub.authenticate(hello.get("token"))
|
|
228
|
+
if token is None:
|
|
229
|
+
with suppress(Exception):
|
|
230
|
+
await ws.close(4401)
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
session = Session(token)
|
|
234
|
+
hub.sessions.add(session)
|
|
235
|
+
await ws.send_json({"op": "welcome", "v": PROTOCOL, "plcs": hub.states(), "write": bool(token.write)})
|
|
236
|
+
|
|
237
|
+
async def sender():
|
|
238
|
+
while True:
|
|
239
|
+
await ws.send_json(await session.queue.get())
|
|
240
|
+
|
|
241
|
+
async def do_write(msg_id, name, value):
|
|
242
|
+
ok, error = await hub.write(session, name, value)
|
|
243
|
+
session.queue.put_nowait({"op": "ack", "id": msg_id, "ok": ok, "error": error})
|
|
244
|
+
|
|
245
|
+
send_task = asyncio.create_task(sender())
|
|
246
|
+
write_tasks = set()
|
|
247
|
+
try:
|
|
248
|
+
while True:
|
|
249
|
+
raw = await ws.receive_text()
|
|
250
|
+
msg_id = None
|
|
251
|
+
try:
|
|
252
|
+
msg = json.loads(raw)
|
|
253
|
+
msg_id = msg.get("id")
|
|
254
|
+
op = msg["op"]
|
|
255
|
+
if op == "sub":
|
|
256
|
+
snapshot = hub.subscribe(session, msg["id"], msg["tags"], msg.get("tier", 1))
|
|
257
|
+
session.queue.put_nowait({"op": "ack", "id": msg_id, "ok": True, "error": None})
|
|
258
|
+
if snapshot:
|
|
259
|
+
session.queue.put_nowait({"op": "values", "d": snapshot})
|
|
260
|
+
elif op == "unsub":
|
|
261
|
+
ok = hub.unsubscribe(session, msg["id"])
|
|
262
|
+
session.queue.put_nowait(
|
|
263
|
+
{"op": "ack", "id": msg_id, "ok": ok, "error": None if ok else "unknown id"})
|
|
264
|
+
elif op == "write":
|
|
265
|
+
task = asyncio.create_task(do_write(msg["id"], msg["tag"], msg["value"]))
|
|
266
|
+
write_tasks.add(task)
|
|
267
|
+
task.add_done_callback(write_tasks.discard)
|
|
268
|
+
else:
|
|
269
|
+
raise ValueError(f"unknown op {op!r}")
|
|
270
|
+
except KeyError as err:
|
|
271
|
+
session.queue.put_nowait({"op": "error", "id": msg_id, "error": f"missing field {err}"})
|
|
272
|
+
except (ValueError, TypeError, AttributeError) as err:
|
|
273
|
+
session.queue.put_nowait({"op": "error", "id": msg_id, "error": str(err)})
|
|
274
|
+
except WebSocketDisconnect:
|
|
275
|
+
pass
|
|
276
|
+
finally:
|
|
277
|
+
send_task.cancel()
|
|
278
|
+
hub.drop(session)
|
|
279
|
+
|
|
280
|
+
return app
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Runs the tag server at Windows startup as a Task Scheduler task (no extra software needed).
|
|
2
|
+
|
|
3
|
+
The task runs `ciplink-tagserver service run`, which serves until stopped and starts the server again
|
|
4
|
+
if it ever returns or raises. Installing and removing need an elevated (administrator) terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from xml.sax.saxutils import escape
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
RESTART_DELAY = 5 # seconds between a server exit and the next start
|
|
18
|
+
|
|
19
|
+
TASK_XML = """<?xml version="1.0" encoding="UTF-16"?>
|
|
20
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
21
|
+
<RegistrationInfo>
|
|
22
|
+
<Description>ciplink-tagserver: serves PLC tag values to React apps</Description>
|
|
23
|
+
</RegistrationInfo>
|
|
24
|
+
<Triggers>
|
|
25
|
+
<BootTrigger><Enabled>true</Enabled></BootTrigger>
|
|
26
|
+
</Triggers>
|
|
27
|
+
<Principals>
|
|
28
|
+
<Principal id="Author">
|
|
29
|
+
<UserId>S-1-5-18</UserId>
|
|
30
|
+
<RunLevel>HighestAvailable</RunLevel>
|
|
31
|
+
</Principal>
|
|
32
|
+
</Principals>
|
|
33
|
+
<Settings>
|
|
34
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
35
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
36
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
37
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
38
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
39
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
40
|
+
<Enabled>true</Enabled>
|
|
41
|
+
</Settings>
|
|
42
|
+
<Actions Context="Author">
|
|
43
|
+
<Exec>
|
|
44
|
+
<Command>{command}</Command>
|
|
45
|
+
<Arguments>{arguments}</Arguments>
|
|
46
|
+
<WorkingDirectory>{workdir}</WorkingDirectory>
|
|
47
|
+
</Exec>
|
|
48
|
+
</Actions>
|
|
49
|
+
</Task>
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def task_xml(config: Path, log_file: Path, python: str = sys.executable) -> str:
|
|
54
|
+
arguments = f'-m ciplink_tagserver.cli service run --config "{config}" --log-file "{log_file}"'
|
|
55
|
+
return TASK_XML.format(command=escape(python), arguments=escape(arguments), workdir=escape(str(config.parent)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def schtasks(*args) -> subprocess.CompletedProcess:
|
|
59
|
+
return subprocess.run(["schtasks", *args], capture_output=True, text=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install(name: str, config: Path, log_file: Path) -> int:
|
|
63
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
64
|
+
xml = Path(tmp) / "task.xml"
|
|
65
|
+
xml.write_text(task_xml(config, log_file), encoding="utf-16")
|
|
66
|
+
result = schtasks("/Create", "/TN", name, "/XML", str(xml), "/F")
|
|
67
|
+
if result.returncode != 0:
|
|
68
|
+
print(result.stderr.strip() or result.stdout.strip())
|
|
69
|
+
print("Installing needs an administrator terminal: right-click Terminal, 'Run as administrator'.")
|
|
70
|
+
return result.returncode
|
|
71
|
+
schtasks("/Run", "/TN", name)
|
|
72
|
+
print(f"Installed and started task '{name}'. It starts at every boot; logs go to {log_file}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def remove(name: str) -> int:
|
|
77
|
+
schtasks("/End", "/TN", name)
|
|
78
|
+
result = schtasks("/Delete", "/TN", name, "/F")
|
|
79
|
+
print((result.stdout or result.stderr).strip())
|
|
80
|
+
return result.returncode
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def status(name: str) -> int:
|
|
84
|
+
result = schtasks("/Query", "/TN", name, "/V", "/FO", "LIST")
|
|
85
|
+
print((result.stdout or result.stderr).strip())
|
|
86
|
+
return result.returncode
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def supervise(serve, stop=lambda: False, sleep=time.sleep):
|
|
90
|
+
"""Calls serve() again whenever it returns or raises, until stop() is true. Ctrl+C ends it."""
|
|
91
|
+
while not stop():
|
|
92
|
+
try:
|
|
93
|
+
serve()
|
|
94
|
+
log.warning("tag server stopped, starting it again in %s s", RESTART_DELAY)
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
raise
|
|
97
|
+
except BaseException as err: # SystemExit from uvicorn (e.g. port in use) included
|
|
98
|
+
log.error("tag server failed: %r; starting it again in %s s", err, RESTART_DELAY)
|
|
99
|
+
sleep(RESTART_DELAY)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Turns driver results into JSON-ready cache entries."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
AOI_LOCAL = "AOI local tag, not externally accessible"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def quality(error) -> str:
|
|
9
|
+
if error is None:
|
|
10
|
+
return "good"
|
|
11
|
+
if str(error).startswith("Tag doesn't exist") or AOI_LOCAL in str(error):
|
|
12
|
+
return "missing"
|
|
13
|
+
return "bad"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def jsonable(value):
|
|
17
|
+
"""Dicts for structs/AOIs, lists for arrays, bytes as hex, non-finite floats as strings."""
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
return {str(k): jsonable(v) for k, v in value.items()}
|
|
20
|
+
if isinstance(value, (list, tuple)):
|
|
21
|
+
return [jsonable(v) for v in value]
|
|
22
|
+
if isinstance(value, (bytes, bytearray)):
|
|
23
|
+
return value.hex()
|
|
24
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
25
|
+
return str(value).replace("inf", "Infinity").replace("nan", "NaN")
|
|
26
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
27
|
+
return value
|
|
28
|
+
return str(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_value(tag) -> tuple:
|
|
32
|
+
"""(value, quality) for a driver Tag result; the value is None unless the read succeeded."""
|
|
33
|
+
q = quality(tag.error)
|
|
34
|
+
return (jsonable(tag.value) if q == "good" else None), q
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def type_name(info: dict) -> str:
|
|
38
|
+
"""Data type name of an entry of ``plc.tags``."""
|
|
39
|
+
if info.get("data_type_name"):
|
|
40
|
+
return info["data_type_name"]
|
|
41
|
+
data_type = info.get("data_type")
|
|
42
|
+
return data_type if isinstance(data_type, str) else (data_type or {}).get("name", "")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def tag_list(plc) -> list[dict]:
|
|
46
|
+
"""Tag names with data type and, for AOIs, the readable members, from ``plc.tags``."""
|
|
47
|
+
result = []
|
|
48
|
+
for name, info in plc.tags.items():
|
|
49
|
+
entry = {"name": name, "type": type_name(info)}
|
|
50
|
+
data_type = info.get("data_type")
|
|
51
|
+
if info.get("tag_type") == "struct" and isinstance(data_type, dict) and data_type.get("aoi"):
|
|
52
|
+
entry["aoi_readable"] = list(data_type["aoi"].get("readable", []))
|
|
53
|
+
result.append(entry)
|
|
54
|
+
return result
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""One thread per PLC: owns the driver, polls the subscribed tags, runs the queued writes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import Future
|
|
8
|
+
|
|
9
|
+
from .values import tag_list, to_value
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now_ms() -> int:
|
|
15
|
+
return int(time.time() * 1000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlcWorker:
|
|
19
|
+
backoff = (1, 2, 5, 10) # seconds between reconnect attempts, the last one repeats
|
|
20
|
+
missing_retry = 10 # seconds before a tag the PLC doesn't have is asked for again, unless the tag table reloads
|
|
21
|
+
|
|
22
|
+
def __init__(self, cfg, factory, cycle_ms, on_values=None, on_status=None, on_meta=None):
|
|
23
|
+
self.name = cfg.name
|
|
24
|
+
self.cfg = cfg
|
|
25
|
+
self.factory = factory # cfg -> driver, not yet opened
|
|
26
|
+
self.cycle = cycle_ms / 1000
|
|
27
|
+
self.on_values = on_values or (lambda plc, changes: None) # called from this thread
|
|
28
|
+
self.on_status = on_status or (lambda plc, state: None)
|
|
29
|
+
self.on_meta = on_meta or (lambda plc: None)
|
|
30
|
+
|
|
31
|
+
self.state = "connecting"
|
|
32
|
+
self.lock = threading.Lock() # guards tags and cache
|
|
33
|
+
self.tags = set() # tag names polled each cycle
|
|
34
|
+
self.cache = {} # tag -> [value, quality, t]
|
|
35
|
+
self.missing = {} # tag -> monotonic time to ask for it again; skipped until then
|
|
36
|
+
self.writes = queue.Queue() # (tag, value, Future)
|
|
37
|
+
self.tag_list = [] # for GET /tags, rebuilt here after a connect or reload
|
|
38
|
+
|
|
39
|
+
self.last_cycle_ms = None
|
|
40
|
+
self.last_ok_ts = None
|
|
41
|
+
self.reads_per_cycle = 0
|
|
42
|
+
self.overruns = 0
|
|
43
|
+
self.reconnects = 0
|
|
44
|
+
self.reloads = 0
|
|
45
|
+
self._version = None
|
|
46
|
+
|
|
47
|
+
self._stop = threading.Event()
|
|
48
|
+
self.wait = self._stop.wait # returns True once stopped; tests swap it for a fake clock
|
|
49
|
+
self._thread = threading.Thread(target=self.run, name=f"plc-{self.name}", daemon=True)
|
|
50
|
+
|
|
51
|
+
# called from other threads
|
|
52
|
+
|
|
53
|
+
def start(self):
|
|
54
|
+
self._thread.start()
|
|
55
|
+
|
|
56
|
+
def stop(self, timeout=5):
|
|
57
|
+
self._stop.set()
|
|
58
|
+
if self._thread.is_alive():
|
|
59
|
+
self._thread.join(timeout)
|
|
60
|
+
|
|
61
|
+
def add_tags(self, tags):
|
|
62
|
+
with self.lock:
|
|
63
|
+
self.tags.update(tags)
|
|
64
|
+
|
|
65
|
+
def remove_tags(self, tags):
|
|
66
|
+
with self.lock:
|
|
67
|
+
for tag in tags:
|
|
68
|
+
self.tags.discard(tag)
|
|
69
|
+
self.cache.pop(tag, None)
|
|
70
|
+
self.missing.pop(tag, None)
|
|
71
|
+
|
|
72
|
+
def snapshot(self, tags) -> dict:
|
|
73
|
+
with self.lock:
|
|
74
|
+
return {tag: list(self.cache[tag]) for tag in tags if tag in self.cache}
|
|
75
|
+
|
|
76
|
+
def write(self, tag, value) -> Future:
|
|
77
|
+
"""Queues a write for the next cycle; the Future gets (ok, error). Never retried."""
|
|
78
|
+
future = Future()
|
|
79
|
+
if self.state != "connected":
|
|
80
|
+
future.set_result((False, "PLC not connected"))
|
|
81
|
+
else:
|
|
82
|
+
self.writes.put((tag, value, future))
|
|
83
|
+
return future
|
|
84
|
+
|
|
85
|
+
# the worker thread
|
|
86
|
+
|
|
87
|
+
def run(self):
|
|
88
|
+
attempt = 0
|
|
89
|
+
while not self._stop.is_set():
|
|
90
|
+
plc = None
|
|
91
|
+
try:
|
|
92
|
+
plc = self.factory(self.cfg)
|
|
93
|
+
plc.open()
|
|
94
|
+
self._fail_writes("PLC disconnected") # any left over from before the connect
|
|
95
|
+
self._check_version(plc, connecting=True)
|
|
96
|
+
attempt = 0
|
|
97
|
+
self._set_state("connected")
|
|
98
|
+
self._poll(plc)
|
|
99
|
+
except Exception as err: # CommError, or anything else the driver raised
|
|
100
|
+
log.warning("%s: %r", self.name, err)
|
|
101
|
+
finally:
|
|
102
|
+
if plc is not None:
|
|
103
|
+
try:
|
|
104
|
+
plc.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
if self._stop.is_set():
|
|
108
|
+
break
|
|
109
|
+
self._fail_writes("PLC disconnected")
|
|
110
|
+
self._mark_stale()
|
|
111
|
+
self._set_state("disconnected")
|
|
112
|
+
delay = self.backoff[min(attempt, len(self.backoff) - 1)]
|
|
113
|
+
attempt += 1
|
|
114
|
+
if self.wait(delay):
|
|
115
|
+
break
|
|
116
|
+
self.reconnects += 1
|
|
117
|
+
self._set_state("connecting")
|
|
118
|
+
|
|
119
|
+
def _poll(self, plc):
|
|
120
|
+
while not self._stop.is_set():
|
|
121
|
+
start = time.monotonic()
|
|
122
|
+
with self.lock:
|
|
123
|
+
tags = [tag for tag in self.tags if self.missing.get(tag, 0) <= start]
|
|
124
|
+
if tags:
|
|
125
|
+
results = plc.read(*tags)
|
|
126
|
+
if not isinstance(results, list):
|
|
127
|
+
results = [results]
|
|
128
|
+
t = now_ms()
|
|
129
|
+
changes = {}
|
|
130
|
+
with self.lock:
|
|
131
|
+
for tag, result in zip(tags, results):
|
|
132
|
+
if tag not in self.tags: # unsubscribed during the read
|
|
133
|
+
continue
|
|
134
|
+
value, quality = to_value(result)
|
|
135
|
+
if quality == "missing": # don't ask (and make the driver log) every cycle
|
|
136
|
+
self.missing[tag] = start + self.missing_retry
|
|
137
|
+
else:
|
|
138
|
+
self.missing.pop(tag, None)
|
|
139
|
+
old = self.cache.get(tag)
|
|
140
|
+
if old is None or old[0] != value or old[1] != quality:
|
|
141
|
+
self.cache[tag] = [value, quality, t]
|
|
142
|
+
changes[tag] = [value, quality, t]
|
|
143
|
+
if changes:
|
|
144
|
+
self.on_values(self.name, changes)
|
|
145
|
+
self.reads_per_cycle = len(tags)
|
|
146
|
+
self._check_version(plc)
|
|
147
|
+
self._run_writes(plc)
|
|
148
|
+
self.last_ok_ts = now_ms()
|
|
149
|
+
elapsed = time.monotonic() - start
|
|
150
|
+
self.last_cycle_ms = round(elapsed * 1000, 1)
|
|
151
|
+
if elapsed > self.cycle:
|
|
152
|
+
self.overruns += 1
|
|
153
|
+
elif self.wait(self.cycle - elapsed):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
def _run_writes(self, plc):
|
|
157
|
+
while True:
|
|
158
|
+
try:
|
|
159
|
+
tag, value, future = self.writes.get_nowait()
|
|
160
|
+
except queue.Empty:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
result = plc.write(tag, value)
|
|
164
|
+
except Exception as err:
|
|
165
|
+
future.set_result((False, str(err)))
|
|
166
|
+
raise
|
|
167
|
+
future.set_result((result.error is None, result.error))
|
|
168
|
+
|
|
169
|
+
def _check_version(self, plc, connecting=False):
|
|
170
|
+
"""Counts a reload and tells the clients when the driver's tag table version changed."""
|
|
171
|
+
version = getattr(plc, "_tag_table_version", None)
|
|
172
|
+
changed = version != self._version
|
|
173
|
+
if changed and self._version is not None:
|
|
174
|
+
self.reloads += 1
|
|
175
|
+
log.info("%s: tag table reloaded (version %s -> %s)", self.name, self._version, version)
|
|
176
|
+
self.on_meta(self.name)
|
|
177
|
+
self._version = version
|
|
178
|
+
if changed or connecting:
|
|
179
|
+
self.tag_list = tag_list(plc)
|
|
180
|
+
with self.lock:
|
|
181
|
+
self.missing.clear() # a new or renamed tag may exist now
|
|
182
|
+
|
|
183
|
+
def _fail_writes(self, error):
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
_, _, future = self.writes.get_nowait()
|
|
187
|
+
except queue.Empty:
|
|
188
|
+
return
|
|
189
|
+
future.set_result((False, error))
|
|
190
|
+
|
|
191
|
+
def _mark_stale(self):
|
|
192
|
+
changes = {}
|
|
193
|
+
with self.lock:
|
|
194
|
+
for tag, entry in self.cache.items():
|
|
195
|
+
if entry[1] != "stale":
|
|
196
|
+
entry[1] = "stale"
|
|
197
|
+
changes[tag] = list(entry)
|
|
198
|
+
if changes:
|
|
199
|
+
self.on_values(self.name, changes)
|
|
200
|
+
|
|
201
|
+
def _set_state(self, state):
|
|
202
|
+
if state != self.state:
|
|
203
|
+
self.state = state
|
|
204
|
+
log.info("%s: %s", self.name, state)
|
|
205
|
+
self.on_status(self.name, state)
|
|
@@ -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,14 @@
|
|
|
1
|
+
ciplink_tagserver/__init__.py,sha256=9pt2yaFG4fA6D-2teY8riih2HmkycSzQ0SWomRFAS38,119
|
|
2
|
+
ciplink_tagserver/cli.py,sha256=e1j0uzm4oUfz_J1jyyYUhh62aek6nBUQtwpsHCLnCJo,5216
|
|
3
|
+
ciplink_tagserver/config.py,sha256=j1Kyym_3iLuy7-xvQ_E6E5Ul_6_ETPbeGuE2NHWV4Pk,2386
|
|
4
|
+
ciplink_tagserver/mock.py,sha256=GGLf_YWBjhTOgVoGaxJ3wfeL4rfrxw0XSZxNQbaVgbw,2447
|
|
5
|
+
ciplink_tagserver/server.py,sha256=mHACflLR5KTRKtjoiBNJby8XrCl3hMnLODc8X1Nv8Ek,10163
|
|
6
|
+
ciplink_tagserver/service.py,sha256=qyAs7x2zWK3HW6dAsQHZkySWiEUO71jCVKUbDl9otsY,3727
|
|
7
|
+
ciplink_tagserver/values.py,sha256=7ax9kvGsHnS1uBWaR1dWseZoR-g_F1IlJpotl0F9tSQ,1954
|
|
8
|
+
ciplink_tagserver/worker.py,sha256=X6AZwmTp_hFfhXAJXyx8UIS6MxGu4TiyuCVq0efcETc,7660
|
|
9
|
+
ciplink_tagserver-0.1.0.dist-info/licenses/LICENSE,sha256=pmQpfOP_f1yO5vIdYN6ZWk8aMO4VokchyjiG2-vyDnY,1073
|
|
10
|
+
ciplink_tagserver-0.1.0.dist-info/METADATA,sha256=jLSZWvnYCwU2wR0_d3ZkfoRyjNLbUlP4cpR4BUmha3Y,9062
|
|
11
|
+
ciplink_tagserver-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
ciplink_tagserver-0.1.0.dist-info/entry_points.txt,sha256=OcI2xY7Nkl9OKKCLyS_3HYy6EQ3S02-WlXHpnzrHXgU,65
|
|
13
|
+
ciplink_tagserver-0.1.0.dist-info/top_level.txt,sha256=IG6yOAQWTOGwoVObfY4yKAMS5oFOke79E8hK4u3t7Nw,18
|
|
14
|
+
ciplink_tagserver-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
ciplink_tagserver
|