pymscada 0.1.0a6__tar.gz → 0.1.1a2__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.
Potentially problematic release.
This version of pymscada might be problematic. Click here for more details.
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/PKG-INFO +2 -2
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/pyproject.toml +2 -2
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/bus_client.py +15 -11
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/bus_server.py +16 -10
- pymscada-0.1.1a2/src/pymscada/console.py +230 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/files.yaml +1 -0
- pymscada-0.1.1a2/src/pymscada/demo/opnotes.yaml +5 -0
- pymscada-0.1.1a2/src/pymscada/demo/pymscada-opnotes.service +16 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/wwwserver.yaml +2 -5
- pymscada-0.1.1a2/src/pymscada/files.py +75 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/history.py +18 -17
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/logix_client.py +1 -1
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/modbus_client.py +2 -1
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/modbus_map.py +4 -1
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/modbus_server.py +2 -1
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/ping_client.py +1 -1
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/snmp_client.py +3 -3
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/snmp_map.py +1 -1
- pymscada-0.1.1a2/src/pymscada/main.py +297 -0
- pymscada-0.1.1a2/src/pymscada/opnotes.py +108 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/protocol_constants.py +11 -8
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/tag.py +22 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/www_server.py +47 -52
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/bus_echo.py +4 -4
- pymscada-0.1.1a2/tests/test_assets/db.sqlite +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_bus_server.py +13 -19
- pymscada-0.1.0a6/tests/test_tag_history.py → pymscada-0.1.1a2/tests/test_history.py +14 -14
- pymscada-0.1.1a2/tests/test_opnotes.py +89 -0
- pymscada-0.1.0a6/src/pymscada/console.py +0 -33
- pymscada-0.1.0a6/src/pymscada/files.py +0 -56
- pymscada-0.1.0a6/src/pymscada/main.py +0 -179
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/LICENSE +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/README.md +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/__init__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/__main__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/checkout.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/config.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/misc.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/periodic.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/samplers.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/src/pymscada/validate.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/__init__.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/iodrivers/test_logix.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/iodrivers/test_modbus.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/busserver.yaml +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/hist_tag_0_0.dat +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/hist_tag_0_10_2.dat +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/hist_tag_0_15.dat +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/hist_tag_0_26.dat +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_assets/hist_tag_0_50.dat +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_config.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_misc.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_periodic.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_samplers.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_tag.py +0 -0
- {pymscada-0.1.0a6 → pymscada-0.1.1a2}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1a2
|
|
4
4
|
Summary: Shared tag value SCADA with python backup and Angular UI
|
|
5
5
|
Author-Email: Jamie Walton <jamie@walton.net.nz>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -13,7 +13,7 @@ Classifier: Development Status :: 1 - Planning
|
|
|
13
13
|
Requires-Python: >=3.9
|
|
14
14
|
Requires-Dist: PyYAML>=6.0.1
|
|
15
15
|
Requires-Dist: aiohttp>=3.8.5
|
|
16
|
-
Requires-Dist: pymscada-html==0.1.
|
|
16
|
+
Requires-Dist: pymscada-html==0.1.0
|
|
17
17
|
Requires-Dist: cerberus>=1.3.5
|
|
18
18
|
Requires-Dist: pycomm3>=1.2.14
|
|
19
19
|
Requires-Dist: pysnmplib>=5.0.24
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymscada"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1a2"
|
|
4
4
|
description = "Shared tag value SCADA with python backup and Angular UI"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Jamie Walton", email = "jamie@walton.net.nz" },
|
|
@@ -8,7 +8,7 @@ authors = [
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"PyYAML>=6.0.1",
|
|
10
10
|
"aiohttp>=3.8.5",
|
|
11
|
-
"pymscada-html==0.1.
|
|
11
|
+
"pymscada-html==0.1.0",
|
|
12
12
|
"cerberus>=1.3.5",
|
|
13
13
|
"pycomm3>=1.2.14",
|
|
14
14
|
"pysnmplib>=5.0.24",
|
|
@@ -16,16 +16,18 @@ class BusClient:
|
|
|
16
16
|
fails, die.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
def __init__(self, ip: str = '127.0.0.1', port: int = 1324, tag_info=None
|
|
19
|
+
def __init__(self, ip: str = '127.0.0.1', port: int = 1324, tag_info=None,
|
|
20
|
+
module: str = '_unset_'):
|
|
20
21
|
"""Create bus server."""
|
|
21
22
|
self.ip = ip
|
|
22
23
|
self.port = port
|
|
23
24
|
self.read_task = None
|
|
24
25
|
self.tag_info = tag_info
|
|
26
|
+
self.module = module
|
|
25
27
|
self.tag_by_id: dict[int, Tag] = {}
|
|
26
28
|
self.tag_by_name: dict[str, Tag] = {}
|
|
27
29
|
self.to_publish: dict[str, Tag] = {}
|
|
28
|
-
self.
|
|
30
|
+
self.rta_handlers: dict[str, object] = {}
|
|
29
31
|
self.pending = {}
|
|
30
32
|
|
|
31
33
|
def publish(self, tag: Tag):
|
|
@@ -61,20 +63,20 @@ class BusClient:
|
|
|
61
63
|
return
|
|
62
64
|
self.write(pc.CMD_SET, tag.id, tag.time_us, data)
|
|
63
65
|
|
|
64
|
-
def
|
|
66
|
+
def add_callback_rta(self, tagname, handler):
|
|
65
67
|
"""Collect callback handlers."""
|
|
66
68
|
if callable(handler):
|
|
67
|
-
self.
|
|
69
|
+
self.rta_handlers[tagname] = handler
|
|
68
70
|
else:
|
|
69
|
-
logging.error(f'invalid
|
|
71
|
+
logging.error(f'invalid RTA handler for {tagname}')
|
|
70
72
|
|
|
71
|
-
def
|
|
73
|
+
def rta(self, tagname: str, request: dict):
|
|
72
74
|
"""Send a Request Set message."""
|
|
73
75
|
time_us = int(time.time() * 1e6)
|
|
74
76
|
jsonstr = json.dumps(request).encode()
|
|
75
77
|
size = len(jsonstr)
|
|
76
78
|
data = struct.pack(f'>B{size}s', pc.TYPE_JSON, jsonstr)
|
|
77
|
-
self.write(pc.
|
|
79
|
+
self.write(pc.CMD_RTA, self.tag_by_name[tagname].id, time_us, data)
|
|
78
80
|
|
|
79
81
|
def write(self, command: pc.COMMANDS, tag_id: int, time_us: int,
|
|
80
82
|
data: bytes):
|
|
@@ -106,6 +108,7 @@ class BusClient:
|
|
|
106
108
|
self.reader, self.writer = await asyncio.open_connection(
|
|
107
109
|
self.ip, self.port)
|
|
108
110
|
self.addr = self.writer.get_extra_info('sockname')
|
|
111
|
+
self.write(pc.CMD_LOG, 0, 0, f'{self.module} connected'.encode())
|
|
109
112
|
logging.info(f'connected {self.addr}')
|
|
110
113
|
for tag in Tag.get_all_tags().values():
|
|
111
114
|
self.add_tag(tag)
|
|
@@ -127,7 +130,8 @@ class BusClient:
|
|
|
127
130
|
try:
|
|
128
131
|
head = await self.reader.readexactly(14)
|
|
129
132
|
_, cmd, tag_id, size, time_us = struct.unpack('>BBHHQ', head)
|
|
130
|
-
except (ConnectionResetError, asyncio.IncompleteReadError
|
|
133
|
+
except (ConnectionResetError, asyncio.IncompleteReadError,
|
|
134
|
+
asyncio.CancelledError):
|
|
131
135
|
break
|
|
132
136
|
if size == 0:
|
|
133
137
|
self.process(cmd, tag_id, time_us, None)
|
|
@@ -200,14 +204,14 @@ class BusClient:
|
|
|
200
204
|
logging.warning(f'process error {tag.name} {tag.type} {value}')
|
|
201
205
|
return
|
|
202
206
|
tag.value = data, time_us, id(self)
|
|
203
|
-
elif cmd == pc.
|
|
207
|
+
elif cmd == pc.CMD_RTA:
|
|
204
208
|
data = struct.unpack_from(f'!{len(value) - 1}s', value, offset=1
|
|
205
209
|
)[0].decode()
|
|
206
210
|
data = json.loads(data)
|
|
207
211
|
try:
|
|
208
|
-
self.
|
|
212
|
+
self.rta_handlers[tag.name](data)
|
|
209
213
|
except KeyError:
|
|
210
|
-
logging.warning(f'unhandled
|
|
214
|
+
logging.warning(f'unhandled RTA for {tag.name} {data}')
|
|
211
215
|
else:
|
|
212
216
|
raise SystemExit(f'Invalid message {cmd}')
|
|
213
217
|
|
|
@@ -141,9 +141,10 @@ class BusConnection():
|
|
|
141
141
|
class BusServer:
|
|
142
142
|
"""Serve Tags on ip:port, echoing changes to any subscribers."""
|
|
143
143
|
|
|
144
|
-
__slots__ = ('ip', 'port', 'server', 'connections')
|
|
144
|
+
__slots__ = ('ip', 'port', 'server', 'connections', 'bus_tag')
|
|
145
145
|
|
|
146
|
-
def __init__(self, ip: str = '127.0.0.1', port: int = 1324
|
|
146
|
+
def __init__(self, ip: str = '127.0.0.1', port: int = 1324,
|
|
147
|
+
bus_tag: str = '__bus__'):
|
|
147
148
|
"""
|
|
148
149
|
Serve Tags on ip:port, echoing changes to any subscribers.
|
|
149
150
|
|
|
@@ -157,10 +158,10 @@ class BusServer:
|
|
|
157
158
|
self.port = port
|
|
158
159
|
self.server = None
|
|
159
160
|
self.connections: dict[int, BusConnection] = {}
|
|
160
|
-
bus_tag = BusTag(
|
|
161
|
-
bus_tag.value = b'\x03started' # \x03 is string type
|
|
162
|
-
bus_tag.time_us = int(time.time() * 1e6)
|
|
163
|
-
bus_tag.from_bus = 0
|
|
161
|
+
self.bus_tag = BusTag(bus_tag.encode())
|
|
162
|
+
self.bus_tag.value = b'\x03started' # \x03 is string type
|
|
163
|
+
self.bus_tag.time_us = int(time.time() * 1e6)
|
|
164
|
+
self.bus_tag.from_bus = 0
|
|
164
165
|
|
|
165
166
|
def publish(self, tag: BusTag, bus_id):
|
|
166
167
|
"""Update subcribers with tag value change."""
|
|
@@ -184,22 +185,22 @@ class BusServer:
|
|
|
184
185
|
self.connections[bus_id].write(
|
|
185
186
|
pc.CMD_ERR, tag_id, time_us,
|
|
186
187
|
f"SET KeyError {tag_id}".encode())
|
|
187
|
-
elif cmd == pc.
|
|
188
|
+
elif cmd == pc.CMD_RTA:
|
|
188
189
|
try:
|
|
189
190
|
tag = BusTags._tag_by_id[tag_id]
|
|
190
191
|
except KeyError:
|
|
191
192
|
self.connections[bus_id].write(
|
|
192
193
|
pc.CMD_ERR, tag_id, time_us,
|
|
193
|
-
f"
|
|
194
|
+
f"RTA KeyError {tag_id}".encode())
|
|
194
195
|
try:
|
|
195
196
|
self.connections[tag.from_bus].write(
|
|
196
|
-
pc.
|
|
197
|
+
pc.CMD_RTA, tag_id, tag.time_us, data)
|
|
197
198
|
except KeyError:
|
|
198
199
|
logging.warning(f'likely busclient for {tag.name} is gone')
|
|
199
200
|
except Exception as e:
|
|
200
201
|
self.connections[bus_id].write(
|
|
201
202
|
pc.CMD_ERR, tag_id, time_us,
|
|
202
|
-
f"
|
|
203
|
+
f"RTA {tag_id} {e}".encode())
|
|
203
204
|
"""Reply comes from another BusClient, not the Server."""
|
|
204
205
|
elif cmd == pc.CMD_SUB:
|
|
205
206
|
try:
|
|
@@ -260,6 +261,11 @@ class BusServer:
|
|
|
260
261
|
tagname_list.append(tag.name)
|
|
261
262
|
self.connections[bus_id].write(
|
|
262
263
|
pc.CMD_LIST, 0, time_us, b' '.join(tagname_list))
|
|
264
|
+
elif cmd == pc.CMD_LOG:
|
|
265
|
+
if len(data) > 300:
|
|
266
|
+
logging.warning(f'process: log message too long from {bus_id}')
|
|
267
|
+
else:
|
|
268
|
+
logging.warning(data.decode())
|
|
263
269
|
else: # consider disconnecting
|
|
264
270
|
logging.warn(f'invalid message {cmd}')
|
|
265
271
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Interactive console."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import termios
|
|
6
|
+
import tty
|
|
7
|
+
from pymscada.bus_client import BusClient
|
|
8
|
+
from pymscada.tag import Tag, tag_for_web
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EC:
|
|
12
|
+
"""Escape codes."""
|
|
13
|
+
|
|
14
|
+
backspace = b'\x7f'
|
|
15
|
+
enter = b'\r'
|
|
16
|
+
tab = b'\t'
|
|
17
|
+
up = b'\x1b[A'
|
|
18
|
+
down = b'\x1b[B'
|
|
19
|
+
right = b'\x1b[C'
|
|
20
|
+
left = b'\x1b[D'
|
|
21
|
+
end = b'\x1b[F'
|
|
22
|
+
home = b'\x1b[H'
|
|
23
|
+
cr_clr = b'\x1b[2K\x1b[G' # clear line and move start
|
|
24
|
+
clear_line = b'\x1b[2K'
|
|
25
|
+
mv_left = b'\x1b[1000D'
|
|
26
|
+
move_cursor_up = b'\x1b[1A'
|
|
27
|
+
insert_line = b'\x1b[1L'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KeypressReaderProtocol(asyncio.Protocol):
|
|
31
|
+
"""Handle key presses, one at a time."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, edit_line, process_command):
|
|
34
|
+
"""Set up line buffers and pointers."""
|
|
35
|
+
self.edit_line = edit_line
|
|
36
|
+
self.process_command = process_command
|
|
37
|
+
self.lines = []
|
|
38
|
+
self.history = None # not showing history
|
|
39
|
+
self.line = None # not editing a line (yet)
|
|
40
|
+
self.cursor = 0 # cursor position in line
|
|
41
|
+
self.stash = None # nothing stashed
|
|
42
|
+
|
|
43
|
+
def data_received(self, data):
|
|
44
|
+
"""Got keypress, update edit line, send to writer."""
|
|
45
|
+
if len(data) == 1:
|
|
46
|
+
if data == EC.backspace:
|
|
47
|
+
self.line = self.line[:self.cursor-1] + self.line[self.cursor:]
|
|
48
|
+
if self.cursor > 0:
|
|
49
|
+
self.cursor -= 1
|
|
50
|
+
elif data == EC.enter:
|
|
51
|
+
self.stash = None
|
|
52
|
+
if self.lines:
|
|
53
|
+
if self.line != self.lines[-1]:
|
|
54
|
+
self.lines.append(self.line)
|
|
55
|
+
else:
|
|
56
|
+
self.lines.append(self.line)
|
|
57
|
+
self.edit_line(None, 0)
|
|
58
|
+
self.process_command(self.line)
|
|
59
|
+
self.line = None
|
|
60
|
+
self.cursor = 0
|
|
61
|
+
self.history = None
|
|
62
|
+
return
|
|
63
|
+
elif self.line is None:
|
|
64
|
+
self.line = data
|
|
65
|
+
self.cursor = 1
|
|
66
|
+
else:
|
|
67
|
+
self.line = (self.line[:self.cursor] + data
|
|
68
|
+
+ self.line[self.cursor:])
|
|
69
|
+
self.cursor += 1
|
|
70
|
+
elif data == EC.left:
|
|
71
|
+
if self.cursor > 0:
|
|
72
|
+
self.cursor -= 1
|
|
73
|
+
elif data == EC.right:
|
|
74
|
+
if self.cursor < len(self.line):
|
|
75
|
+
self.cursor += 1
|
|
76
|
+
elif data == EC.up:
|
|
77
|
+
if not self.lines:
|
|
78
|
+
return
|
|
79
|
+
if self.history is None:
|
|
80
|
+
self.stash = self.line # might be None
|
|
81
|
+
self.history = len(self.lines)
|
|
82
|
+
self.history -= 1
|
|
83
|
+
if self.history < 0:
|
|
84
|
+
self.history = 0
|
|
85
|
+
self.line = self.lines[self.history]
|
|
86
|
+
self.cursor = len(self.line)
|
|
87
|
+
elif data == EC.down:
|
|
88
|
+
if not self.lines or self.history is None:
|
|
89
|
+
return
|
|
90
|
+
self.history += 1
|
|
91
|
+
if self.history == len(self.lines):
|
|
92
|
+
self.line = self.stash
|
|
93
|
+
self.history = None
|
|
94
|
+
else:
|
|
95
|
+
self.line = self.lines[self.history]
|
|
96
|
+
if self.line is None:
|
|
97
|
+
self.line = b''
|
|
98
|
+
self.cursor = len(self.line)
|
|
99
|
+
self.edit_line(self.line, self.cursor)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConsoleReader:
|
|
103
|
+
"""Read key presses for console."""
|
|
104
|
+
|
|
105
|
+
def __init__(self):
|
|
106
|
+
"""Save terminal state and init stdin."""
|
|
107
|
+
self.fd = sys.stdin.fileno()
|
|
108
|
+
self.old_attr = termios.tcgetattr(self.fd)
|
|
109
|
+
tty.setraw(self.fd)
|
|
110
|
+
|
|
111
|
+
async def start_connection(self, edit_line, process):
|
|
112
|
+
"""Connect protocol."""
|
|
113
|
+
self.transport, self.protocol = \
|
|
114
|
+
await asyncio.get_event_loop().connect_read_pipe(
|
|
115
|
+
lambda: KeypressReaderProtocol(edit_line, process),
|
|
116
|
+
sys.stdin)
|
|
117
|
+
|
|
118
|
+
def __del__(self):
|
|
119
|
+
"""Reset the terminal."""
|
|
120
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old_attr)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ConsoleWriter:
|
|
124
|
+
"""Writer to group logging and console text."""
|
|
125
|
+
|
|
126
|
+
def __init__(self):
|
|
127
|
+
"""Init."""
|
|
128
|
+
self.edit = None
|
|
129
|
+
self.cursor = 0
|
|
130
|
+
self.fd = sys.stdout.fileno()
|
|
131
|
+
self.old_attr = termios.tcgetattr(self.fd)
|
|
132
|
+
|
|
133
|
+
def write(self, data: bytes):
|
|
134
|
+
"""Stream writer, primarily for logging."""
|
|
135
|
+
ln = EC.cr_clr + data + b'\r\n'
|
|
136
|
+
if self.edit is not None:
|
|
137
|
+
ln += EC.cr_clr + self.edit + EC.mv_left
|
|
138
|
+
if self.cursor > 0:
|
|
139
|
+
ln += b'\x1b[' + str(self.cursor).encode() + b'C'
|
|
140
|
+
sys.stdout.buffer.write(ln)
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
|
|
143
|
+
def edit_line(self, edit: bytes, cursor: int):
|
|
144
|
+
"""Update the edit line and cursor position."""
|
|
145
|
+
self.edit = edit
|
|
146
|
+
if self.edit is None:
|
|
147
|
+
sys.stdout.buffer.write(b'\r\n')
|
|
148
|
+
sys.stdout.flush()
|
|
149
|
+
return
|
|
150
|
+
self.cursor = cursor
|
|
151
|
+
ln = EC.cr_clr + self.edit + EC.mv_left
|
|
152
|
+
if self.cursor > 0:
|
|
153
|
+
ln += b'\x1b[' + str(self.cursor).encode() + b'C'
|
|
154
|
+
sys.stdout.buffer.write(ln)
|
|
155
|
+
sys.stdout.flush()
|
|
156
|
+
|
|
157
|
+
def __del__(self):
|
|
158
|
+
"""Reset the terminal."""
|
|
159
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old_attr)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Console:
|
|
163
|
+
"""Provide a text console to interact with a Bus."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
166
|
+
tag_info: dict = {}):
|
|
167
|
+
"""
|
|
168
|
+
Connect to bus_ip:bus_port and provide console interaction with a Bus.
|
|
169
|
+
|
|
170
|
+
Event loop must be running.
|
|
171
|
+
"""
|
|
172
|
+
self.reader = ConsoleReader()
|
|
173
|
+
self.writer = ConsoleWriter()
|
|
174
|
+
logging.basicConfig(stream=self.writer)
|
|
175
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Console')
|
|
176
|
+
self.tags: dict[str, Tag] = {}
|
|
177
|
+
for tagname, tag in tag_info.items():
|
|
178
|
+
tag_for_web(tagname, tag)
|
|
179
|
+
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
180
|
+
self.quit = asyncio.Event()
|
|
181
|
+
|
|
182
|
+
def write_tag(self, tag: Tag):
|
|
183
|
+
"""Append or insert tag value through writer."""
|
|
184
|
+
ln = f'{tag.name} {tag.value}'.encode()
|
|
185
|
+
self.writer.write(ln)
|
|
186
|
+
|
|
187
|
+
def process(self, command: bytes):
|
|
188
|
+
"""Execute command."""
|
|
189
|
+
cmd, var, val = (command.split(b' ') + [None] * 3)[:3]
|
|
190
|
+
if var is not None:
|
|
191
|
+
var = var.decode()
|
|
192
|
+
if var is None:
|
|
193
|
+
tagnames = self.tags.keys()
|
|
194
|
+
else:
|
|
195
|
+
tagnames = [x for x in self.tags.keys() if var in x]
|
|
196
|
+
if cmd in [b'q', b'quit']:
|
|
197
|
+
self.quit.set() # does not close connection tidily
|
|
198
|
+
elif cmd in [b'g', b'get']:
|
|
199
|
+
for tagname in tagnames:
|
|
200
|
+
self.write_tag(self.tags[tagname])
|
|
201
|
+
elif cmd == b'set':
|
|
202
|
+
pass
|
|
203
|
+
elif cmd in [b's', b'sub']:
|
|
204
|
+
for tagname in tagnames:
|
|
205
|
+
self.tags[tagname].add_callback(self.write_tag)
|
|
206
|
+
self.write_tag(self.tags[tagname])
|
|
207
|
+
elif cmd in [b'u', b'unsub']:
|
|
208
|
+
for tagname in tagnames:
|
|
209
|
+
if self.write_tag in self.tags[tagname].pub:
|
|
210
|
+
self.tags[tagname].del_callback(self.write_tag)
|
|
211
|
+
elif cmd in [b'l', b'list']:
|
|
212
|
+
ln = ' '.join(tagnames).encode()
|
|
213
|
+
self.writer.write(ln)
|
|
214
|
+
elif cmd in [b'w', b'watch']:
|
|
215
|
+
pass
|
|
216
|
+
elif cmd in [b'h', b'help']:
|
|
217
|
+
self.writer.write(
|
|
218
|
+
b'list <match> or l <match>\r\n'
|
|
219
|
+
b'get <match> or g <match>\r\n'
|
|
220
|
+
b'set tagname value\r\n'
|
|
221
|
+
b'sub <match> or s <match>\r\n'
|
|
222
|
+
b'unsub <match> or u <match>\r\n'
|
|
223
|
+
b'watch <systemd name> or w <systemd name>\r\n'
|
|
224
|
+
b'---------------------------------------------')
|
|
225
|
+
|
|
226
|
+
async def start(self):
|
|
227
|
+
"""Start polling, does not return until finished."""
|
|
228
|
+
await self.busclient.start()
|
|
229
|
+
await self.reader.start_connection(self.writer.edit_line, self.process)
|
|
230
|
+
await self.quit.wait() # Idle wait until user quits the console
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - history
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ opnotes --config __DIR__/config/opnotes.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=mscada
|
|
12
|
+
Group=mscada
|
|
13
|
+
KillSignal=SIGINT
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Serve files through an RTA tag."""
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from pymscada.bus_client import BusClient
|
|
5
|
+
from pymscada.periodic import Periodic
|
|
6
|
+
from pymscada.tag import Tag
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
RATE = 10
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Files():
|
|
13
|
+
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
16
|
+
path: str = '', files: list = None,
|
|
17
|
+
rta_tag: str = '__files__') -> None:
|
|
18
|
+
"""
|
|
19
|
+
Connect to bus_ip:bus_port, serve and update files.
|
|
20
|
+
|
|
21
|
+
TODO
|
|
22
|
+
Write something.
|
|
23
|
+
|
|
24
|
+
Event loop must be running.
|
|
25
|
+
"""
|
|
26
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Files')
|
|
27
|
+
self.path = Path(path)
|
|
28
|
+
self.files = files
|
|
29
|
+
for file in self.files:
|
|
30
|
+
if 'desc' not in file:
|
|
31
|
+
file['desc'] = ''
|
|
32
|
+
if 'mode' not in file:
|
|
33
|
+
file['mode'] = 'ro'
|
|
34
|
+
file['_path'] = self.path.joinpath(file['path'])
|
|
35
|
+
file['st_mtime_ns'] = 0
|
|
36
|
+
self.rta = Tag(rta_tag, dict)
|
|
37
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
38
|
+
self.scan_files()
|
|
39
|
+
|
|
40
|
+
def scan_files(self):
|
|
41
|
+
"""Scan folders for files."""
|
|
42
|
+
update = False
|
|
43
|
+
for file in self.files:
|
|
44
|
+
stat = file['_path'].parent.stat()
|
|
45
|
+
if stat.st_mtime_ns <= file['st_mtime_ns']:
|
|
46
|
+
continue
|
|
47
|
+
update = True
|
|
48
|
+
file['st_mtime_ns'] = stat.st_mtime_ns
|
|
49
|
+
if not update:
|
|
50
|
+
return
|
|
51
|
+
info = []
|
|
52
|
+
for file in self.files:
|
|
53
|
+
path = file['_path']
|
|
54
|
+
for fn in sorted(Path(path.parent).glob(path.name)):
|
|
55
|
+
logging.info(fn)
|
|
56
|
+
info.append({'path': file['path'].split('/')[0],
|
|
57
|
+
'name': fn.name,
|
|
58
|
+
'desc': file['desc'],
|
|
59
|
+
'mode': file['mode']})
|
|
60
|
+
self.rta.value = {'__rta_id__': 0, 'dat': info}
|
|
61
|
+
|
|
62
|
+
def rta_cb(self, tag):
|
|
63
|
+
"""Respond to bus requests for data to publish on rta."""
|
|
64
|
+
logging.info(f'files rta_cb {tag.name} {tag.value}')
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
async def check_files(self):
|
|
68
|
+
"""Check to see if any files have changed."""
|
|
69
|
+
self.scan_files()
|
|
70
|
+
|
|
71
|
+
async def start(self):
|
|
72
|
+
"""Async startup."""
|
|
73
|
+
await self.busclient.start()
|
|
74
|
+
self.periodic = Periodic(self.check_files, RATE)
|
|
75
|
+
await self.periodic.start()
|
|
@@ -65,7 +65,7 @@ class TagHistory():
|
|
|
65
65
|
|
|
66
66
|
def __init__(self, tagname: str, tagtype, path: str,
|
|
67
67
|
min=None, max=None, deadband=None):
|
|
68
|
-
"""Create persistent tag store. Respond to
|
|
68
|
+
"""Create persistent tag store. Respond to RTA."""
|
|
69
69
|
self.name = tagname
|
|
70
70
|
if type(tagtype) is type:
|
|
71
71
|
self.type = tagtype
|
|
@@ -189,17 +189,18 @@ class History():
|
|
|
189
189
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
190
190
|
|
|
191
191
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
192
|
-
path: str = 'history', tag_info: dict = {}
|
|
192
|
+
path: str = 'history', tag_info: dict = {},
|
|
193
|
+
rta_tag: str = '__history__') -> None:
|
|
193
194
|
"""
|
|
194
195
|
Connect to bus_ip:bus_port, store and provide a value history.
|
|
195
196
|
|
|
196
197
|
History files are binary files named <tagname>_<time_us>.dat. On
|
|
197
|
-
receipt of a request via
|
|
198
|
-
via
|
|
198
|
+
receipt of a request via RTA message, History will send the data
|
|
199
|
+
via rta_tag.value which you can watch with a tag.add_callback.
|
|
199
200
|
|
|
200
201
|
Event loop must be running.
|
|
201
202
|
"""
|
|
202
|
-
self.busclient = BusClient(bus_ip, bus_port)
|
|
203
|
+
self.busclient = BusClient(bus_ip, bus_port, module='History')
|
|
203
204
|
self.path = path
|
|
204
205
|
self.tags: dict[str, Tag] = {}
|
|
205
206
|
self.hist_tags: dict[str, TagHistory] = {}
|
|
@@ -212,24 +213,24 @@ class History():
|
|
|
212
213
|
deadband=tag['deadband'])
|
|
213
214
|
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
214
215
|
self.tags[tagname].add_callback(self.hist_tags[tagname].callback)
|
|
215
|
-
self.
|
|
216
|
-
self.
|
|
217
|
-
self.busclient.
|
|
216
|
+
self.rta = Tag(rta_tag, bytes)
|
|
217
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
218
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
218
219
|
|
|
219
|
-
def
|
|
220
|
-
"""Respond to bus requests for data to publish on
|
|
220
|
+
def rta_cb(self, request):
|
|
221
|
+
"""Respond to bus requests for data to publish on rta."""
|
|
221
222
|
if 'start_ms' in request:
|
|
222
223
|
request['start_us'] = request['start_ms'] * 1000
|
|
223
224
|
request['end_us'] = request['end_ms'] * 1000
|
|
224
|
-
|
|
225
|
-
if '
|
|
226
|
-
|
|
225
|
+
rta_id = 0
|
|
226
|
+
if '__rta_id__' in request:
|
|
227
|
+
rta_id = request['__rta_id__']
|
|
227
228
|
tagname = request['tagname']
|
|
228
229
|
start_time = time.asctime(time.localtime(
|
|
229
230
|
request['start_us'] / 1000000))
|
|
230
231
|
end_time = time.asctime(time.localtime(
|
|
231
232
|
request['end_us'] / 1000000))
|
|
232
|
-
logging.info(f"
|
|
233
|
+
logging.info(f"RTA {tagname} {start_time} {end_time}")
|
|
233
234
|
try:
|
|
234
235
|
data = self.hist_tags[request['tagname']].read_bytes(
|
|
235
236
|
request['start_us'], request['end_us'])
|
|
@@ -240,11 +241,11 @@ class History():
|
|
|
240
241
|
packtype = 1
|
|
241
242
|
elif tagtype == float:
|
|
242
243
|
packtype = 2
|
|
243
|
-
self.
|
|
244
|
+
self.rta.value = pack('>HHH', rta_id, tagid, packtype) + data
|
|
244
245
|
logging.info(f'sent {len(data)} bytes for {request["tagname"]}')
|
|
245
|
-
self.
|
|
246
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
246
247
|
except Exception as e:
|
|
247
|
-
logging.error(f'history
|
|
248
|
+
logging.error(f'history rta_cb {e}')
|
|
248
249
|
|
|
249
250
|
async def start(self):
|
|
250
251
|
"""Async startup."""
|
|
@@ -60,7 +60,7 @@ class LogixClient:
|
|
|
60
60
|
"""
|
|
61
61
|
self.busclient = None
|
|
62
62
|
if bus_ip is not None:
|
|
63
|
-
self.busclient = BusClient(bus_ip, bus_port)
|
|
63
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Logix Client')
|
|
64
64
|
self.mapping = LogixMaps(tags)
|
|
65
65
|
self.connections: list[LogixClientConnector] = []
|
|
66
66
|
for rtu in rtus:
|
|
@@ -249,7 +249,8 @@ class ModbusClient:
|
|
|
249
249
|
"""
|
|
250
250
|
self.busclient = None
|
|
251
251
|
if bus_ip is not None:
|
|
252
|
-
self.busclient = BusClient(bus_ip, bus_port
|
|
252
|
+
self.busclient = BusClient(bus_ip, bus_port,
|
|
253
|
+
module='Modbus Client')
|
|
253
254
|
self.mapping = ModbusMaps(tags)
|
|
254
255
|
self.connections: list[ModbusClientConnector] = []
|
|
255
256
|
for rtu in rtus:
|
|
@@ -139,7 +139,10 @@ class ModbusMaps():
|
|
|
139
139
|
"""Make the maps."""
|
|
140
140
|
for tagname, v in self.tags.items():
|
|
141
141
|
dtype = v['type']
|
|
142
|
-
|
|
142
|
+
try:
|
|
143
|
+
addr = v['read']
|
|
144
|
+
except KeyError:
|
|
145
|
+
addr = v['addr']
|
|
143
146
|
map = ModbusMap(tagname, dtype, addr, self.data, self.value_chg)
|
|
144
147
|
size = DTYPES[dtype][3]
|
|
145
148
|
name, unit, file, word = addr.split(':')
|
|
@@ -161,7 +161,8 @@ class ModbusServer:
|
|
|
161
161
|
"""
|
|
162
162
|
self.busclient = None
|
|
163
163
|
if bus_ip is not None:
|
|
164
|
-
self.busclient = BusClient(bus_ip, bus_port
|
|
164
|
+
self.busclient = BusClient(bus_ip, bus_port,
|
|
165
|
+
module='Modbus Server')
|
|
165
166
|
self.mapping = ModbusMaps(tags)
|
|
166
167
|
self.connections: list[ModbusServerConnector] = []
|
|
167
168
|
for rtu in rtus:
|
|
@@ -104,7 +104,7 @@ class PingClient:
|
|
|
104
104
|
"""
|
|
105
105
|
self.busclient = None
|
|
106
106
|
if bus_ip is not None:
|
|
107
|
-
self.busclient = BusClient(bus_ip, bus_port)
|
|
107
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Ping Client')
|
|
108
108
|
self.mapping = PingMaps(tags)
|
|
109
109
|
self.pinger = PingClientConnector(mapping=self.mapping)
|
|
110
110
|
|