pymscada 0.1.11b10__py3-none-any.whl → 0.2.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.
pymscada/checkout.py CHANGED
@@ -6,100 +6,98 @@ import sys
6
6
  from pymscada.config import get_demo_files, get_pdf_files
7
7
 
8
8
 
9
- PATH = {
10
- '__PYTHON__': Path(f'{sys.exec_prefix}/bin/python').absolute(),
11
- '__PYMSCADA__': Path(sys.argv[0]).absolute(),
12
- '__DIR__': Path('.').absolute(),
13
- '__HOME__': Path.home().absolute(),
14
- '__USER__': getpass.getuser()
15
- }
16
- if sys.platform == "win32":
17
- PATH = {
18
- '__PYTHON__': Path(f'{sys.exec_prefix}/python.exe').absolute(),
19
- '__PYMSCADA__': Path(sys.argv[0]).absolute(),
20
- '__DIR__': Path('.').absolute(),
21
- '__HOME__': Path.home(),
22
- '__USER__': getpass.getuser()
23
- }
9
+ class Checkout:
10
+ """Create and manage configuration files."""
11
+
12
+ def __init__(self, **kwargs):
13
+ """Initialize paths and settings."""
14
+ self.path = {
15
+ '__PYTHON__': Path(f'{sys.exec_prefix}/bin/python').absolute(),
16
+ '__PYMSCADA__': Path(sys.argv[0]).absolute(),
17
+ '__DIR__': Path('.').absolute(),
18
+ '__HOME__': Path.home().absolute(),
19
+ '__USER__': getpass.getuser()
20
+ }
21
+ if sys.platform == "win32":
22
+ self.path['__PYTHON__'] = Path(f'{sys.exec_prefix}/python.exe').absolute()
23
+
24
+ self.overwrite = kwargs.get('overwrite', False)
25
+ self.diff = kwargs.get('diff', False)
24
26
 
25
- def make_history():
26
- """Make the history folder if missing."""
27
- history_dir = PATH['__DIR__'].joinpath('history')
28
- if not history_dir.exists():
29
- print("making 'history' folder")
30
- history_dir.mkdir()
27
+ def make_history(self):
28
+ """Make the history folder if missing."""
29
+ history_dir = self.path['__DIR__'].joinpath('history')
30
+ if not history_dir.exists():
31
+ print("making 'history' folder")
32
+ history_dir.mkdir()
31
33
 
34
+ def make_pdf(self):
35
+ """Make the pdf folder if missing."""
36
+ pdf_dir = self.path['__DIR__'].joinpath('pdf')
37
+ if not pdf_dir.exists():
38
+ print('making pdf dir')
39
+ pdf_dir.mkdir()
40
+ for pdf_file in get_pdf_files():
41
+ target = pdf_dir.joinpath(pdf_file.name)
42
+ target.write_bytes(pdf_file.read_bytes())
32
43
 
33
- def make_pdf():
34
- """Make the pdf folder if missing."""
35
- pdf_dir = PATH['__DIR__'].joinpath('pdf')
36
- if not pdf_dir.exists():
37
- print('making pdf dir')
38
- pdf_dir.mkdir()
39
- for pdf_file in get_pdf_files():
40
- target = pdf_dir.joinpath(pdf_file.name)
41
- target.write_bytes(pdf_file.read_bytes())
44
+ def make_config(self):
45
+ """Make the config folder, if missing, and copy files in."""
46
+ config_dir = self.path['__DIR__'].joinpath('config')
47
+ if not config_dir.exists():
48
+ print('making config dir')
49
+ config_dir.mkdir()
50
+ for config_file in get_demo_files():
51
+ target = config_dir.joinpath(config_file.name)
52
+ rt = 'Creating '
53
+ if target.exists():
54
+ if self.overwrite:
55
+ rt = 'Replacing '
56
+ target.unlink()
57
+ else:
58
+ continue
59
+ print(f'{rt} {target}')
60
+ rd_bytes = config_file.read_bytes()
61
+ if target.name.lower() != 'readme.md':
62
+ for k, v in self.path.items():
63
+ rd_bytes = rd_bytes.replace(k.encode(), str(v).encode())
64
+ target.write_bytes(rd_bytes)
42
65
 
66
+ def read_with_subst(self, file: Path):
67
+ """Read the file and replace DIR markers."""
68
+ rd = file.read_bytes().decode()
69
+ for k, v in self.path.items():
70
+ rd = rd.replace(k, str(v))
71
+ lines = rd.splitlines()
72
+ return lines
43
73
 
44
- def make_config(overwrite: bool):
45
- """Make the config folder, if missing, and copy files in."""
46
- config_dir = PATH['__DIR__'].joinpath('config')
47
- if not config_dir.exists():
48
- print('making config dir')
49
- config_dir.mkdir()
50
- for config_file in get_demo_files():
51
- target = config_dir.joinpath(config_file.name)
52
- rt = 'Creating '
53
- if target.exists():
54
- if overwrite:
55
- rt = 'Replacing '
56
- target.unlink()
74
+ def compare_config(self):
75
+ """Compare old and new config."""
76
+ config_dir = self.path['__DIR__'].joinpath('config')
77
+ if not config_dir.exists():
78
+ print('No config dir, are you in the right directory')
79
+ return
80
+ for config_file in get_demo_files():
81
+ target = config_dir.joinpath(config_file.name)
82
+ if target.exists():
83
+ new_lines = self.read_with_subst(config_file)
84
+ old_lines = self.read_with_subst(target)
85
+ diff = list(difflib.unified_diff(old_lines, new_lines,
86
+ fromfile=str(target), tofile=str(config_file)))
87
+ if len(diff):
88
+ print('\n'.join(diff), '\n')
57
89
  else:
58
- continue
59
- print(f'{rt} {target}')
60
- rd_bytes = config_file.read_bytes()
61
- if target.name.lower() != 'readme.md':
62
- for k, v in PATH.items():
63
- rd_bytes = rd_bytes.replace(k.encode(), str(v).encode())
64
- target.write_bytes(rd_bytes)
90
+ print(f'\n--- MISSING FILE\n\n+++ {config_file}')
65
91
 
66
-
67
- def read_with_subst(file: Path):
68
- """Read the file and replace DIR markers."""
69
- rd = file.read_bytes().decode()
70
- for k, v in PATH.items():
71
- rd = rd.replace(k, str(v))
72
- lines = rd.splitlines()
73
- return lines
74
-
75
-
76
- def compare_config():
77
- """Compare old and new config."""
78
- config_dir = PATH['__DIR__'].joinpath('config')
79
- if not config_dir.exists():
80
- print('No config dir, are you in the right directory')
81
- return
82
- for config_file in get_demo_files():
83
- target = config_dir.joinpath(config_file.name)
84
- if target.exists():
85
- new_lines = read_with_subst(config_file)
86
- old_lines = read_with_subst(target)
87
- diff = list(difflib.unified_diff(old_lines, new_lines,
88
- fromfile=str(target), tofile=str(config_file)))
89
- if len(diff):
90
- print('\n'.join(diff), '\n')
92
+ async def start(self):
93
+ """Execute checkout process."""
94
+ for name in ['__PYTHON__', '__PYMSCADA__', '__DIR__', '__HOME__']:
95
+ if not self.path[name].exists():
96
+ raise SystemExit(f'{self.path[name]} is missing')
97
+
98
+ if self.diff:
99
+ self.compare_config()
91
100
  else:
92
- print(f'\n--- MISSING FILE\n\n+++ {config_file}')
93
-
94
-
95
- def checkout(overwrite=False, diff=False):
96
- """Do it."""
97
- for name in ['__PYTHON__', '__PYMSCADA__', '__DIR__', '__HOME__']:
98
- if not PATH[name].exists():
99
- raise SystemExit(f'{PATH[name]} is missing')
100
- if diff:
101
- compare_config()
102
- else:
103
- make_history()
104
- make_pdf()
105
- make_config(overwrite)
101
+ self.make_history()
102
+ self.make_pdf()
103
+ self.make_config()
pymscada/console.py CHANGED
@@ -3,7 +3,8 @@ import asyncio
3
3
  import logging
4
4
  import sys
5
5
  from pymscada.bus_client import BusClient
6
- from pymscada.tag import Tag, tag_for_web
6
+ from pymscada.tag import Tag
7
+ from pymscada.www_server import standardise_tag_info
7
8
  try:
8
9
  import termios
9
10
  import tty
@@ -153,7 +154,7 @@ class Console:
153
154
  """Provide a text console to interact with a Bus."""
154
155
 
155
156
  def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
156
- tag_info: dict = {}):
157
+ tag_info: dict = {}) -> None:
157
158
  """
158
159
  Connect to bus_ip:bus_port and provide console interaction with a Bus.
159
160
 
@@ -177,7 +178,7 @@ class Console:
177
178
  self.busclient = BusClient(bus_ip, bus_port, module='Console')
178
179
  self.tags: dict[str, Tag] = {}
179
180
  for tagname, tag in tag_info.items():
180
- tag_for_web(tagname, tag)
181
+ standardise_tag_info(tagname, tag)
181
182
  self.tags[tagname] = Tag(tagname, tag['type'])
182
183
 
183
184
  def write_tag(self, tag: Tag):
@@ -0,0 +1,5 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ rta_tag: __alarms__
4
+ db: __HOME__/config/pymscada-alarms.sqlite
5
+ table: alarms
@@ -0,0 +1,17 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ rta_tag: __callout__
4
+ alarms_tag: __alarms__
5
+ ack_tag: SI_Alarm_Ack
6
+ status_tag: SO_Alarm_Status
7
+ callees:
8
+ - name: A name
9
+ sms: A number
10
+ group: System
11
+ - name: B name
12
+ sms: B number
13
+ groups:
14
+ - name: Aniwhenua Station
15
+ group:
16
+ - name: Aniwhenua System
17
+ group: System
@@ -2,7 +2,7 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- api_key: ${OPENWEATHER_API_KEY}
5
+ api_key: ${OPENWEATHERMAP_API_KEY}
6
6
  units: metric
7
7
  locations:
8
8
  Murupara:
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=pymscada - alarms
3
+ BindsTo=pymscada-bus.service
4
+ After=pymscada-bus.service
5
+
6
+ [Service]
7
+ WorkingDirectory=__DIR__
8
+ ExecStart=__PYMSCADA__ alarms --config __DIR__/config/alarms.yaml --tags __DIR__/config/tags.yaml
9
+ Restart=always
10
+ RestartSec=5
11
+ User=__USER__
12
+ Group=__USER__
13
+ KillSignal=SIGINT
14
+
15
+ [Install]
16
+ WantedBy=multi-user.target
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=pymscada - callout
3
+ BindsTo=pymscada-bus.service
4
+ After=pymscada-bus.service
5
+
6
+ [Service]
7
+ WorkingDirectory=__DIR__
8
+ ExecStart=__PYMSCADA__ callout --config __DIR__/config/callout.yaml --tags __DIR__/config/tags.yaml
9
+ Restart=always
10
+ RestartSec=5
11
+ User=__USER__
12
+ Group=__USER__
13
+ KillSignal=SIGINT
14
+
15
+ [Install]
16
+ WantedBy=multi-user.target
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=pymscada - Open Weather client
3
+ BindsTo=pymscada-bus.service
4
+ After=pymscada-bus.service
5
+
6
+ [Service]
7
+ WorkingDirectory=__DIR__
8
+ ExecStart=__PYMSCADA__ openweatherclient --config __DIR__/config/openweather.yaml
9
+ Restart=always
10
+ RestartSec=5
11
+ User=__USER__
12
+ Group=__USER__
13
+
14
+ [Install]
15
+ WantedBy=multi-user.target
@@ -1,11 +1,11 @@
1
1
  [Unit]
2
- Description=pymscada - AccuWeather client
2
+ Description=pymscada - WITS client
3
3
  BindsTo=pymscada-bus.service
4
4
  After=pymscada-bus.service
5
5
 
6
6
  [Service]
7
7
  WorkingDirectory=__DIR__
8
- ExecStart=__PYMSCADA__ accuweatherclient --config __DIR__/config/accuweather.yaml
8
+ ExecStart=__PYMSCADA__ witsapiclient --config __DIR__/config/witsapi.yaml
9
9
  Restart=always
10
10
  RestartSec=5
11
11
  User=__USER__
pymscada/demo/tags.yaml CHANGED
@@ -113,10 +113,14 @@ localhost_ping:
113
113
  desc: Ping time to localhost
114
114
  type: float
115
115
  units: ms
116
+ alarm: '> 500 for 300'
117
+ group: System
116
118
  google_ping:
117
119
  desc: Ping time to google
118
120
  type: float
119
121
  units: ms
122
+ alarm: '> 100 for 30'
123
+ group: System
120
124
  Murupara_Temp:
121
125
  desc: Murupara Temperature
122
126
  type: float
@@ -0,0 +1,17 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ proxy:
4
+ api:
5
+ url: 'https://api.electricityinfo.co.nz'
6
+ client_id: ${WITS_CLIENT_ID}
7
+ client_secret: ${WITS_CLIENT_SECRET}
8
+ gxp_list:
9
+ - MAT1101
10
+ - CYD2201
11
+ - BEN2201
12
+ back: 1
13
+ forward: 12
14
+ tags:
15
+ - MAT1101_RTD
16
+ - CYD2201_RTD
17
+ - BEN2201_RTD
pymscada/files.py CHANGED
@@ -10,13 +10,13 @@ class Files():
10
10
  """Connect to bus_ip:bus_port, store and provide a value history."""
11
11
 
12
12
  def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
13
- path: str = '', files: list = None,
13
+ path: str = '', files: list[dict] = [],
14
14
  rta_tag: str = '__files__') -> None:
15
15
  """
16
16
  Connect to bus_ip:bus_port, serve and update files.
17
17
 
18
- TODO
19
- Write something.
18
+ Scans paths in files at the root defined by path. These should
19
+ be readable by wwwserver so that download links work.
20
20
 
21
21
  Event loop must be running.
22
22
  """
pymscada/history.py CHANGED
@@ -1,9 +1,32 @@
1
- """Store and provide history."""
1
+ """Store and provide history.
2
+
3
+ History File Structure
4
+ ---------------------
5
+ History files are binary files stored as <tagname>_<time_us>.dat where time_us
6
+ is the microsecond timestamp of the first entry in that file.
7
+
8
+ Each file contains a series of fixed-size records (16 bytes each):
9
+ - For integer tags: 8 bytes timestamp (uint64) + 8 bytes value (int64)
10
+ - For float tags: 8 bytes timestamp (uint64) + 8 bytes value (double)
11
+
12
+ Files are organized in chunks:
13
+ - Each chunk is 1024 records (16KB)
14
+ - Each file contains up to 64 chunks (1MB)
15
+ - New files are created when:
16
+ 1. Current file reaches max size (64 chunks)
17
+ 2. Manual flush() is called
18
+ 3. Application shutdown
19
+
20
+ Timestamps are stored as microseconds since epoch in network byte order (big-endian).
21
+ Values are also stored in network byte order.
22
+ """
2
23
  import atexit
3
24
  import logging
4
25
  from pathlib import Path
5
26
  from struct import pack, pack_into, unpack_from, error
6
27
  import time
28
+ import socket
29
+ from typing import TypedDict, Optional
7
30
  from pymscada.bus_client import BusClient
8
31
  from pymscada.tag import Tag, TYPES
9
32
 
@@ -14,11 +37,12 @@ CHUNK_SIZE = ITEM_COUNT * ITEM_SIZE
14
37
  FILE_CHUNKS = 64
15
38
 
16
39
 
17
- def tag_for_history(tagname: str, tag: dict):
18
- """Correct tag dictionary in place to be suitable for web client."""
40
+ def standardise_tag_info(tagname: str, tag: dict):
41
+ """Correct tag dictionary in place to be suitable for modules."""
19
42
  tag['name'] = tagname
20
43
  tag['id'] = None
21
44
  if 'desc' not in tag:
45
+ logging.warning(f"Tag {tagname} has no description, using name")
22
46
  tag['desc'] = tag['name']
23
47
  if 'multi' in tag:
24
48
  tag['type'] = int
@@ -38,6 +62,16 @@ def tag_for_history(tagname: str, tag: dict):
38
62
  tag['deadband'] = None
39
63
 
40
64
 
65
+ class Request(TypedDict, total=False):
66
+ """Type definition for request dictionary."""
67
+ tagname: str
68
+ start_ms: Optional[int] # Allow web client to use native ms
69
+ start_us: Optional[int] # Native for pymscada server
70
+ end_ms: Optional[int]
71
+ end_us: Optional[int]
72
+ __rta_id__: Optional[int] # Empty for a change that must be broadcast
73
+
74
+
41
75
  def get_tag_hist_files(path: Path, tagname: str) -> dict[int, Path]:
42
76
  """Parse path for history files matching tagname."""
43
77
  files_us = {}
@@ -188,9 +222,14 @@ class TagHistory():
188
222
  class History():
189
223
  """Connect to bus_ip:bus_port, store and provide a value history."""
190
224
 
191
- def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
192
- path: str = 'history', tag_info: dict = {},
193
- rta_tag: str = '__history__') -> None:
225
+ def __init__(
226
+ self,
227
+ bus_ip: str = '127.0.0.1',
228
+ bus_port: int = 1324,
229
+ path: str = 'history',
230
+ tag_info: dict[str, dict] = {},
231
+ rta_tag: str | None = '__history__'
232
+ ) -> None:
194
233
  """
195
234
  Connect to bus_ip:bus_port, store and provide a value history.
196
235
 
@@ -200,12 +239,29 @@ class History():
200
239
 
201
240
  Event loop must be running.
202
241
  """
242
+ if not isinstance(bus_ip, str):
243
+ raise ValueError("bus_ip must be a string")
244
+ try:
245
+ socket.gethostbyname(bus_ip)
246
+ except socket.gaierror:
247
+ raise ValueError(f"Invalid bus_ip: {bus_ip}")
248
+ if not isinstance(bus_port, int):
249
+ raise ValueError("bus_port must be an integer")
250
+ if not 1024 <= bus_port <= 65535:
251
+ raise ValueError(f"bus_port must be between 1024 and 65535")
252
+ if not isinstance(path, str):
253
+ raise ValueError("path must be a string")
254
+ if not isinstance(tag_info, dict):
255
+ raise ValueError("tag_info must be a dictionary")
256
+ if not isinstance(rta_tag, (str, type(None))):
257
+ raise ValueError("rta_tag must be a string or None")
258
+
203
259
  self.busclient = BusClient(bus_ip, bus_port, module='History')
204
260
  self.path = path
205
261
  self.tags: dict[str, Tag] = {}
206
262
  self.hist_tags: dict[str, TagHistory] = {}
207
263
  for tagname, tag in tag_info.items():
208
- tag_for_history(tagname, tag)
264
+ standardise_tag_info(tagname, tag)
209
265
  if tag['type'] not in [float, int]:
210
266
  continue
211
267
  self.hist_tags[tagname] = TagHistory(
@@ -217,7 +273,7 @@ class History():
217
273
  self.rta.value = b'\x00\x00\x00\x00\x00\x00'
218
274
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
219
275
 
220
- def rta_cb(self, request):
276
+ def rta_cb(self, request: Request):
221
277
  """Respond to bus requests for data to publish on rta."""
222
278
  if 'start_ms' in request:
223
279
  request['start_us'] = request['start_ms'] * 1000