pymscada 0.1.0a5__tar.gz → 0.1.1__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 (85) hide show
  1. {pymscada-0.1.0a5 → pymscada-0.1.1}/PKG-INFO +2 -2
  2. {pymscada-0.1.0a5 → pymscada-0.1.1}/pyproject.toml +2 -3
  3. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/bus_client.py +15 -11
  4. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/bus_server.py +16 -10
  5. pymscada-0.1.1/src/pymscada/console.py +243 -0
  6. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/files.yaml +1 -0
  7. pymscada-0.1.1/src/pymscada/demo/opnotes.yaml +5 -0
  8. pymscada-0.1.1/src/pymscada/demo/pymscada-opnotes.service +16 -0
  9. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/wwwserver.yaml +2 -5
  10. pymscada-0.1.1/src/pymscada/files.py +75 -0
  11. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/history.py +18 -17
  12. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/logix_client.py +1 -1
  13. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/modbus_client.py +2 -1
  14. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/modbus_map.py +4 -1
  15. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/modbus_server.py +2 -1
  16. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/ping_client.py +1 -1
  17. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/snmp_client.py +3 -3
  18. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/snmp_map.py +1 -1
  19. pymscada-0.1.1/src/pymscada/main.py +297 -0
  20. pymscada-0.1.1/src/pymscada/opnotes.py +108 -0
  21. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/protocol_constants.py +11 -8
  22. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/tag.py +22 -0
  23. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/www_server.py +47 -52
  24. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/bus_echo.py +4 -4
  25. pymscada-0.1.1/tests/test_assets/db.sqlite +0 -0
  26. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_bus_server.py +13 -19
  27. pymscada-0.1.0a5/tests/test_tag_history.py → pymscada-0.1.1/tests/test_history.py +14 -14
  28. pymscada-0.1.1/tests/test_opnotes.py +89 -0
  29. pymscada-0.1.0a5/src/pymscada/console.py +0 -33
  30. pymscada-0.1.0a5/src/pymscada/files.py +0 -56
  31. pymscada-0.1.0a5/src/pymscada/main.py +0 -179
  32. {pymscada-0.1.0a5 → pymscada-0.1.1}/LICENSE +0 -0
  33. {pymscada-0.1.0a5 → pymscada-0.1.1}/README.md +0 -0
  34. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/__init__.py +0 -0
  35. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/__main__.py +0 -0
  36. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/checkout.py +0 -0
  37. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/config.py +0 -0
  38. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/README.md +0 -0
  39. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/__init__.py +0 -0
  40. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/bus.yaml +0 -0
  41. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/history.yaml +0 -0
  42. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/logixclient.yaml +0 -0
  43. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/modbus_plc.py +0 -0
  44. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/modbusclient.yaml +0 -0
  45. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/modbusserver.yaml +0 -0
  46. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/ping.yaml +0 -0
  47. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-bus.service +0 -0
  48. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  49. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-files.service +0 -0
  50. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-history.service +0 -0
  51. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  52. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  53. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  54. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  55. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  56. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  57. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/snmpclient.yaml +0 -0
  58. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/demo/tags.yaml +0 -0
  59. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/__init__.py +0 -0
  60. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/logix_map.py +0 -0
  61. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/iodrivers/ping_map.py +0 -0
  62. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/misc.py +0 -0
  63. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/pdf/__init__.py +0 -0
  64. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/pdf/one.pdf +0 -0
  65. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/pdf/two.pdf +0 -0
  66. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/periodic.py +0 -0
  67. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/samplers.py +0 -0
  68. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/tools/snmp_client2.py +0 -0
  69. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/tools/walk.py +0 -0
  70. {pymscada-0.1.0a5 → pymscada-0.1.1}/src/pymscada/validate.py +0 -0
  71. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/__init__.py +0 -0
  72. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/iodrivers/test_logix.py +0 -0
  73. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/iodrivers/test_modbus.py +0 -0
  74. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/busserver.yaml +0 -0
  75. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/hist_tag_0_0.dat +0 -0
  76. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/hist_tag_0_10_2.dat +0 -0
  77. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/hist_tag_0_15.dat +0 -0
  78. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/hist_tag_0_26.dat +0 -0
  79. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_assets/hist_tag_0_50.dat +0 -0
  80. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_config.py +0 -0
  81. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_misc.py +0 -0
  82. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_periodic.py +0 -0
  83. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_samplers.py +0 -0
  84. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_tag.py +0 -0
  85. {pymscada-0.1.0a5 → pymscada-0.1.1}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pymscada
3
- Version: 0.1.0a5
3
+ Version: 0.1.1
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.0
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.0a5"
3
+ version = "0.1.1"
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.0",
11
+ "pymscada-html==0.1.0",
12
12
  "cerberus>=1.3.5",
13
13
  "pycomm3>=1.2.14",
14
14
  "pysnmplib>=5.0.24",
@@ -43,7 +43,6 @@ test = [
43
43
  "flake8-docstrings>=1.7.0",
44
44
  "pytest-asyncio>=0.21.1",
45
45
  "pytest-cov>=4.1.0",
46
- "pdm>=2.11.1",
47
46
  ]
48
47
  pdm = []
49
48
 
@@ -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.rqs_handlers: dict[str, object] = {}
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 add_callback_rqs(self, tagname, handler):
66
+ def add_callback_rta(self, tagname, handler):
65
67
  """Collect callback handlers."""
66
68
  if callable(handler):
67
- self.rqs_handlers[tagname] = handler
69
+ self.rta_handlers[tagname] = handler
68
70
  else:
69
- logging.error(f'invalid RQS handler for {tagname}')
71
+ logging.error(f'invalid RTA handler for {tagname}')
70
72
 
71
- def rqs(self, tagname: str, request: dict):
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.CMD_RQS, self.tag_by_name[tagname].id, time_us, data)
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.CMD_RQS:
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.rqs_handlers[tag.name](data)
212
+ self.rta_handlers[tag.name](data)
209
213
  except KeyError:
210
- logging.warning(f'unhandled RQS for {tag.name} {data}')
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(b'__bus__')
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.CMD_RQS:
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"RQS KeyError {tag_id}".encode())
194
+ f"RTA KeyError {tag_id}".encode())
194
195
  try:
195
196
  self.connections[tag.from_bus].write(
196
- pc.CMD_RQS, tag_id, tag.time_us, data)
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"RQS {tag_id} {e}".encode())
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,243 @@
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_start = b'\x1b[G'
26
+ mv_left = b'\x1b[1000D'
27
+
28
+
29
+ class CustomHandler(logging.StreamHandler):
30
+ """Control the cursor position."""
31
+
32
+ def emit(self, record):
33
+ """Write to the console adding a carriage return."""
34
+ try:
35
+ msg = self.format(record)
36
+ self.stream.write(msg + '\r\n')
37
+ self.stream.flush()
38
+ except Exception:
39
+ self.handleError(record)
40
+
41
+
42
+ class KeypressProtocol(asyncio.Protocol):
43
+ """Handle key presses, one at a time."""
44
+
45
+ def __init__(self, edit_line, process_command):
46
+ """Set up line buffers and pointers."""
47
+ self.edit_line = edit_line
48
+ self.process_command = process_command
49
+ self.lines = []
50
+ self.history = None # not showing history
51
+ self.line = None # not editing a line (yet)
52
+ self.cursor = 0 # cursor position in line
53
+ self.stash = None # nothing stashed
54
+ self.connection_lost_future = asyncio.Future()
55
+
56
+ def data_received(self, data):
57
+ """Got keypress, update edit line, send to writer."""
58
+ if len(data) == 1:
59
+ if data == EC.backspace:
60
+ self.line = self.line[:self.cursor-1] + self.line[self.cursor:]
61
+ if self.cursor > 0:
62
+ self.cursor -= 1
63
+ elif data == EC.enter:
64
+ self.stash = None
65
+ if self.lines:
66
+ if self.line != self.lines[-1]:
67
+ self.lines.append(self.line)
68
+ else:
69
+ self.lines.append(self.line)
70
+ self.edit_line(None, 0)
71
+ self.process_command(self.line)
72
+ self.line = None
73
+ self.cursor = 0
74
+ self.history = None
75
+ return
76
+ elif self.line is None:
77
+ self.line = data
78
+ self.cursor = 1
79
+ else:
80
+ self.line = (self.line[:self.cursor] + data
81
+ + self.line[self.cursor:])
82
+ self.cursor += 1
83
+ elif data == EC.left:
84
+ if self.cursor > 0:
85
+ self.cursor -= 1
86
+ elif data == EC.right:
87
+ if self.cursor < len(self.line):
88
+ self.cursor += 1
89
+ elif data == EC.up:
90
+ if not self.lines:
91
+ return
92
+ if self.history is None:
93
+ self.stash = self.line # might be None
94
+ self.history = len(self.lines)
95
+ self.history -= 1
96
+ if self.history < 0:
97
+ self.history = 0
98
+ self.line = self.lines[self.history]
99
+ self.cursor = len(self.line)
100
+ elif data == EC.down:
101
+ if not self.lines or self.history is None:
102
+ return
103
+ self.history += 1
104
+ if self.history == len(self.lines):
105
+ self.line = self.stash
106
+ self.history = None
107
+ else:
108
+ self.line = self.lines[self.history]
109
+ if self.line is None:
110
+ self.line = b''
111
+ self.cursor = len(self.line)
112
+ self.edit_line(self.line, self.cursor)
113
+
114
+ def connection_lost(self, exc):
115
+ """Let parent know protocol transport has disconnected."""
116
+ self.connection_lost_future.set_result(True)
117
+
118
+
119
+ class ConsoleWriter:
120
+ """Writer to group logging and console text."""
121
+
122
+ def __init__(self):
123
+ """Init."""
124
+ self.edit = None
125
+ self.cursor = 0
126
+
127
+ def write(self, data: bytes):
128
+ """Stream writer, primarily for logging."""
129
+ ln = EC.cr_clr + data + b'\r\n'
130
+ if self.edit is not None:
131
+ ln += EC.cr_clr + self.edit + EC.mv_left
132
+ if self.cursor > 0:
133
+ ln += b'\x1b[' + str(self.cursor).encode() + b'C'
134
+ sys.stdout.buffer.write(ln)
135
+ sys.stdout.flush()
136
+
137
+ def edit_line(self, edit: bytes, cursor: int):
138
+ """Update the edit line and cursor position."""
139
+ self.edit = edit
140
+ if self.edit is None:
141
+ sys.stdout.buffer.write(b'\r\n')
142
+ sys.stdout.flush()
143
+ return
144
+ self.cursor = cursor
145
+ ln = EC.cr_clr + self.edit + EC.mv_left
146
+ if self.cursor > 0:
147
+ ln += b'\x1b[' + str(self.cursor).encode() + b'C'
148
+ sys.stdout.buffer.write(ln)
149
+ sys.stdout.flush()
150
+
151
+
152
+ class Console:
153
+ """Provide a text console to interact with a Bus."""
154
+
155
+ def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
156
+ tag_info: dict = {}):
157
+ """
158
+ Connect to bus_ip:bus_port and provide console interaction with a Bus.
159
+
160
+ Event loop must be running.
161
+ """
162
+ self.fdin = sys.stdin.fileno()
163
+ self.fdout = sys.stdout.fileno()
164
+ self.fdin_attr = termios.tcgetattr(self.fdin)
165
+ self.fdout_attr = termios.tcgetattr(self.fdout)
166
+ tty.setraw(self.fdin)
167
+ self.writer = ConsoleWriter()
168
+ self.protocol = None
169
+ self.transport = None
170
+ # all to add '\r\n' to the logging output
171
+ logger = logging.getLogger()
172
+ handler = CustomHandler()
173
+ handler.setFormatter(logging.Formatter(
174
+ '%(levelname)s:%(name)s:%(message)s'))
175
+ logger.handlers.clear()
176
+ logger.addHandler(handler)
177
+ self.busclient = BusClient(bus_ip, bus_port, module='Console')
178
+ self.tags: dict[str, Tag] = {}
179
+ for tagname, tag in tag_info.items():
180
+ tag_for_web(tagname, tag)
181
+ self.tags[tagname] = Tag(tagname, tag['type'])
182
+
183
+ def write_tag(self, tag: Tag):
184
+ """Append or insert tag value through writer."""
185
+ ln = f'{tag.name} {tag.value}'.encode()
186
+ self.writer.write(ln)
187
+
188
+ def process(self, command: bytes):
189
+ """Execute command."""
190
+ if command is None:
191
+ return
192
+ cmd, var, val = (command.split(b' ') + [None] * 3)[:3]
193
+ tagnames = self.tags.keys()
194
+ if var is not None:
195
+ tagname = var.decode()
196
+ tagnames = [x for x in self.tags.keys() if tagname in x]
197
+ if cmd in [b'q', b'quit']:
198
+ self.transport.close()
199
+ elif cmd in [b'g', b'get']:
200
+ for tagname in tagnames:
201
+ self.write_tag(self.tags[tagname])
202
+ elif cmd == b'set' and tagname in self.tags:
203
+ try:
204
+ typed_val = self.tags[tagname].type(val.decode())
205
+ self.tags[tagname].value = typed_val
206
+ except ValueError as e:
207
+ logging.warning(f'error setting {tagname}: {e}')
208
+ elif cmd in [b's', b'sub']:
209
+ for tagname in tagnames:
210
+ self.tags[tagname].add_callback(self.write_tag)
211
+ self.write_tag(self.tags[tagname])
212
+ elif cmd in [b'u', b'unsub']:
213
+ for tagname in tagnames:
214
+ if self.write_tag in self.tags[tagname].pub:
215
+ self.tags[tagname].del_callback(self.write_tag)
216
+ elif cmd in [b'l', b'list']:
217
+ ln = ' '.join(tagnames).encode()
218
+ self.writer.write(ln)
219
+ elif cmd in [b'w', b'watch']:
220
+ pass
221
+ elif cmd in [b'h', b'help']:
222
+ self.writer.write(
223
+ b'list <match> or l <match>\r\n'
224
+ b'get <match> or g <match>\r\n'
225
+ b'set tagname value\r\n'
226
+ b'sub <match> or s <match>\r\n'
227
+ b'unsub <match> or u <match>\r\n'
228
+ b'watch <systemd name> or w <systemd name>\r\n'
229
+ b'---------------------------------------------')
230
+
231
+ async def start(self):
232
+ """Start polling, does not return until finished."""
233
+ await self.busclient.start()
234
+ try:
235
+ self.protocol = \
236
+ KeypressProtocol(self.writer.edit_line, self.process)
237
+ self.transport, _ = \
238
+ await asyncio.get_event_loop().connect_read_pipe(
239
+ lambda: self.protocol, sys.stdin)
240
+ await self.protocol.connection_lost_future
241
+ finally:
242
+ termios.tcsetattr(self.fdout, termios.TCSADRAIN, self.fdout_attr)
243
+ termios.tcsetattr(self.fdin, termios.TCSADRAIN, self.fdin_attr)
@@ -1,5 +1,6 @@
1
1
  bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
+ path: /home/mscada/pymscada
3
4
  files:
4
5
  - path: pdf/one.pdf
5
6
  desc: PDF
@@ -0,0 +1,5 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ rta_tag: __opnotes__
4
+ db: /home/mscada/pymscada/pymscada.sqlite
5
+ table: opnotes
@@ -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
@@ -2,11 +2,8 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  ip: 0.0.0.0
4
4
  port: 8324
5
- get_path:
6
- paths:
7
- - history
8
- - config
9
- - pdf
5
+ get_path: /home/mscada/Documents/angmscada/dist/angmscada
6
+ serve_path: /home/mscada/pymscada
10
7
  pages:
11
8
  - name: Default Main
12
9
  parent:
@@ -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 RQS."""
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 = {}) -> None:
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 RQS message, History will send the data
198
- via __history__.value which you can watch with a tag.add_callback.
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.rqs = Tag('__history__', bytes)
216
- self.rqs.value = b'\x00\x00\x00\x00\x00\x00'
217
- self.busclient.add_callback_rqs('__history__', self.rqs_cb)
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 rqs_cb(self, request):
220
- """Respond to bus requests for data to publish on rqs."""
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
- rqs_id = 0
225
- if '__rqs_id__' in request:
226
- rqs_id = request['__rqs_id__']
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"RQS {tagname} {start_time} {end_time}")
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.rqs.value = pack('>HHH', rqs_id, tagid, packtype) + data
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.rqs.value = b'\x00\x00\x00\x00\x00\x00'
246
+ self.rta.value = b'\x00\x00\x00\x00\x00\x00'
246
247
  except Exception as e:
247
- logging.error(f'history rqs_cb {e}')
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
- addr = v['read']
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(':')