pymscada 0.1.11b9__py3-none-any.whl → 0.2.0rc1__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.
Potentially problematic release.
This version of pymscada might be problematic. Click here for more details.
- pymscada/checkout.py +87 -89
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/openweather.yaml +1 -1
- pymscada/demo/{pymscada-io-accuweather.service → pymscada-io-openweather.service} +2 -2
- pymscada/history.py +36 -4
- pymscada/iodrivers/openweather.py +110 -48
- pymscada/main.py +1 -1
- pymscada/module_config.py +14 -10
- pymscada/opnotes.py +54 -13
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/protocol_constants.py +51 -33
- pymscada/tag.py +18 -0
- {pymscada-0.1.11b9.dist-info → pymscada-0.2.0rc1.dist-info}/METADATA +7 -6
- {pymscada-0.1.11b9.dist-info → pymscada-0.2.0rc1.dist-info}/RECORD +24 -21
- {pymscada-0.1.11b9.dist-info → pymscada-0.2.0rc1.dist-info}/WHEEL +2 -1
- {pymscada-0.1.11b9.dist-info → pymscada-0.2.0rc1.dist-info}/entry_points.txt +0 -3
- pymscada-0.2.0rc1.dist-info/top_level.txt +1 -0
- {pymscada-0.1.11b9.dist-info/licenses → pymscada-0.2.0rc1.dist-info}/LICENSE +0 -0
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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()
|
|
Binary file
|
pymscada/demo/openweather.yaml
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[Unit]
|
|
2
|
-
Description=pymscada -
|
|
2
|
+
Description=pymscada - Open Weather 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__
|
|
8
|
+
ExecStart=__PYMSCADA__ openweatherclient --config __DIR__/config/openweather.yaml
|
|
9
9
|
Restart=always
|
|
10
10
|
RestartSec=5
|
|
11
11
|
User=__USER__
|
pymscada/history.py
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
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
|
+
from typing import TypedDict, Optional
|
|
7
29
|
from pymscada.bus_client import BusClient
|
|
8
|
-
from pymscada.tag import Tag, TYPES
|
|
30
|
+
from pymscada.tag import Tag, TagInfo, TYPES
|
|
9
31
|
|
|
10
32
|
|
|
11
33
|
ITEM_SIZE = 16 # Q + q, Q or d
|
|
@@ -14,6 +36,16 @@ CHUNK_SIZE = ITEM_COUNT * ITEM_SIZE
|
|
|
14
36
|
FILE_CHUNKS = 64
|
|
15
37
|
|
|
16
38
|
|
|
39
|
+
class Request(TypedDict, total=False):
|
|
40
|
+
"""Type definition for request dictionary."""
|
|
41
|
+
tagname: str
|
|
42
|
+
start_ms: Optional[int] # Allow web client to use native ms
|
|
43
|
+
start_us: Optional[int] # Native for pymscada server
|
|
44
|
+
end_ms: Optional[int]
|
|
45
|
+
end_us: Optional[int]
|
|
46
|
+
__rta_id__: Optional[int] # Empty for a change that must be broadcast
|
|
47
|
+
|
|
48
|
+
|
|
17
49
|
def tag_for_history(tagname: str, tag: dict):
|
|
18
50
|
"""Correct tag dictionary in place to be suitable for web client."""
|
|
19
51
|
tag['name'] = tagname
|
|
@@ -189,7 +221,7 @@ class History():
|
|
|
189
221
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
190
222
|
|
|
191
223
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
192
|
-
path: str = 'history', tag_info:
|
|
224
|
+
path: str = 'history', tag_info: TagInfo = {},
|
|
193
225
|
rta_tag: str = '__history__') -> None:
|
|
194
226
|
"""
|
|
195
227
|
Connect to bus_ip:bus_port, store and provide a value history.
|
|
@@ -217,7 +249,7 @@ class History():
|
|
|
217
249
|
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
218
250
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
219
251
|
|
|
220
|
-
def rta_cb(self, request):
|
|
252
|
+
def rta_cb(self, request: Request):
|
|
221
253
|
"""Respond to bus requests for data to publish on rta."""
|
|
222
254
|
if 'start_ms' in request:
|
|
223
255
|
request['start_us'] = request['start_ms'] * 1000
|
|
@@ -41,82 +41,144 @@ class OpenWeatherClient:
|
|
|
41
41
|
|
|
42
42
|
def update_tags(self, location, data, suffix):
|
|
43
43
|
"""Update tags for forecast weather."""
|
|
44
|
+
if 'dt' not in data:
|
|
45
|
+
logging.error(f'No timestamp in data for {location}, skipping update')
|
|
46
|
+
return
|
|
44
47
|
for parameter in self.parameters:
|
|
45
48
|
tagname = f"{location}_{parameter}{suffix}"
|
|
46
|
-
if parameter == 'Temp':
|
|
47
|
-
value = data['main']['temp']
|
|
48
|
-
elif parameter == 'WindSpeed':
|
|
49
|
-
value = data['wind']['speed']
|
|
50
|
-
elif parameter == 'WindDir':
|
|
51
|
-
value = data['wind'].get('deg', 0)
|
|
52
|
-
elif parameter == 'Rain':
|
|
53
|
-
value = data.get('rain', {}).get('1h', 0)
|
|
54
49
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
if parameter == 'Temp':
|
|
51
|
+
main_data = data.get('main', {})
|
|
52
|
+
value = main_data.get('temp', 0)
|
|
53
|
+
elif parameter == 'WindSpeed':
|
|
54
|
+
wind_data = data.get('wind', {})
|
|
55
|
+
value = wind_data.get('speed', 0)
|
|
56
|
+
elif parameter == 'WindDir':
|
|
57
|
+
wind_data = data.get('wind', {})
|
|
58
|
+
value = wind_data.get('deg', 0)
|
|
59
|
+
elif parameter == 'Rain':
|
|
60
|
+
rain_data = data.get('rain', {})
|
|
61
|
+
value = rain_data.get('1h', 0)
|
|
62
|
+
else:
|
|
63
|
+
logging.warning(f'Unknown parameter {parameter} for {tagname}')
|
|
64
|
+
continue
|
|
65
|
+
time_us = int(data['dt'] * 1_000_000)
|
|
66
|
+
self.tags[tagname].value = value, time_us, self.map_bus
|
|
67
|
+
logging.debug(f'Updated {tagname} = {value} at timestamp {data["dt"]}')
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logging.error(
|
|
70
|
+
f'Error updating {tagname}: {type(e).__name__} - {str(e)}'
|
|
71
|
+
)
|
|
58
72
|
|
|
59
73
|
async def handle_response(self):
|
|
60
74
|
"""Handle responses from the API."""
|
|
61
75
|
while True:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
try:
|
|
77
|
+
location, data = await self.queue.get()
|
|
78
|
+
logging.debug(f'Processing data for {location}')
|
|
79
|
+
|
|
80
|
+
if 'dt' in data: # Current weather data
|
|
81
|
+
self.update_tags(location, data, '')
|
|
82
|
+
elif 'list' in data: # Forecast data
|
|
83
|
+
now = int(time())
|
|
84
|
+
for forecast in data['list']:
|
|
85
|
+
hours_ahead = int((forecast['dt'] - now) / 3600)
|
|
86
|
+
if hours_ahead in self.times:
|
|
87
|
+
suffix = f'_{hours_ahead:02d}'
|
|
88
|
+
self.update_tags(location, forecast, suffix)
|
|
89
|
+
|
|
90
|
+
self.queue.task_done()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logging.error(f'Error handling response: {type(e).__name__} - {str(e)}')
|
|
73
93
|
|
|
74
94
|
async def fetch_current_data(self):
|
|
75
95
|
"""Fetch current weather data for all locations."""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
try:
|
|
97
|
+
if self.session is None:
|
|
98
|
+
self.session = aiohttp.ClientSession()
|
|
99
|
+
|
|
100
|
+
for location, coords in self.locations.items():
|
|
101
|
+
base_params = {
|
|
102
|
+
'lat': coords.get('lat'),
|
|
103
|
+
'lon': coords.get('lon'),
|
|
104
|
+
'appid': self.api_key,
|
|
105
|
+
'units': self.units
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Validate required parameters
|
|
109
|
+
if not all(base_params.values()):
|
|
110
|
+
logging.error(
|
|
111
|
+
f'Missing required parameters for {location}: '
|
|
112
|
+
f'{[k for k, v in base_params.items() if not v]}'
|
|
113
|
+
)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
async with self.session.get(
|
|
118
|
+
self.current_url,
|
|
119
|
+
params=base_params,
|
|
120
|
+
proxy=self.proxy,
|
|
121
|
+
timeout=30 # Add timeout
|
|
122
|
+
) as resp:
|
|
123
|
+
if resp.status == 200:
|
|
124
|
+
data = await resp.json()
|
|
125
|
+
logging.debug(
|
|
126
|
+
f'Received current weather data for {location}'
|
|
127
|
+
)
|
|
128
|
+
await self.queue.put((location, data))
|
|
129
|
+
else:
|
|
130
|
+
error_text = await resp.text()
|
|
131
|
+
logging.error(
|
|
132
|
+
f'OpenWeather API error for {location}: '
|
|
133
|
+
f'Status: {resp.status}, Response: {error_text[:200]}'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except asyncio.TimeoutError:
|
|
137
|
+
logging.error(f'Timeout fetching data for {location}')
|
|
138
|
+
except aiohttp.ClientError as e:
|
|
139
|
+
logging.error(
|
|
140
|
+
f'Network error for {location}: {type(e).__name__} - {str(e)}'
|
|
141
|
+
)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logging.error(
|
|
144
|
+
f'Unexpected error for {location}: {type(e).__name__} - {str(e)}'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logging.error(f'Fatal error in fetch_current_data: {type(e).__name__} - {str(e)}')
|
|
93
149
|
|
|
94
150
|
async def fetch_forecast_data(self):
|
|
95
151
|
"""Fetch forecast weather data for all locations."""
|
|
96
|
-
logging.info('fetching forecast')
|
|
97
152
|
if self.session is None:
|
|
98
153
|
self.session = aiohttp.ClientSession()
|
|
99
154
|
for location, coords in self.locations.items():
|
|
100
|
-
base_params = {
|
|
101
|
-
'
|
|
155
|
+
base_params = {
|
|
156
|
+
'lat': coords['lat'],
|
|
157
|
+
'lon': coords['lon'],
|
|
158
|
+
'appid': self.api_key,
|
|
159
|
+
'units': self.units
|
|
160
|
+
}
|
|
102
161
|
try:
|
|
103
162
|
async with self.session.get(self.forecast_url,
|
|
104
163
|
params=base_params, proxy=self.proxy) as resp:
|
|
105
164
|
if resp.status == 200:
|
|
106
|
-
|
|
165
|
+
data = await resp.json()
|
|
166
|
+
logging.info(f'Queue forecast {location} {data}')
|
|
167
|
+
await self.queue.put((location, data))
|
|
107
168
|
else:
|
|
108
|
-
logging.
|
|
109
|
-
f'
|
|
169
|
+
logging.error(f'OpenWeather forecast API error for '
|
|
170
|
+
f'{location}: Status:{resp.status}, '
|
|
171
|
+
f'Response:{await resp.text()}')
|
|
110
172
|
except Exception as e:
|
|
111
|
-
logging.
|
|
112
|
-
f'{
|
|
173
|
+
logging.error(f'OpenWeather forecast API error for {location}: '
|
|
174
|
+
f'Exception:{type(e).__name__}, Message:{str(e)}')
|
|
113
175
|
|
|
114
176
|
async def poll(self):
|
|
115
177
|
"""Poll OpenWeather APIs every 10 minutes."""
|
|
116
178
|
now = int(time())
|
|
117
179
|
if now % 600 == 0: # Every 10 minutes
|
|
118
180
|
asyncio.create_task(self.fetch_current_data())
|
|
119
|
-
if now %
|
|
181
|
+
if now % 3600 == 60: # Every 3 hours, offset by 1 minute
|
|
120
182
|
asyncio.create_task(self.fetch_forecast_data())
|
|
121
183
|
|
|
122
184
|
async def start(self):
|
pymscada/main.py
CHANGED
|
@@ -39,7 +39,7 @@ async def run():
|
|
|
39
39
|
if not options.verbose:
|
|
40
40
|
root_logger.setLevel(logging.WARNING)
|
|
41
41
|
factory = ModuleFactory()
|
|
42
|
-
module = factory.create_module(options
|
|
42
|
+
module = factory.create_module(options)
|
|
43
43
|
if module is not None:
|
|
44
44
|
if hasattr(module, 'start'):
|
|
45
45
|
await module.start()
|
pymscada/module_config.py
CHANGED
|
@@ -75,9 +75,10 @@ def create_module_registry():
|
|
|
75
75
|
ModuleDefinition(
|
|
76
76
|
name='checkout',
|
|
77
77
|
help='create example config files',
|
|
78
|
-
module_class='pymscada.checkout:
|
|
78
|
+
module_class='pymscada.checkout:Checkout',
|
|
79
79
|
config=False,
|
|
80
80
|
tags=False,
|
|
81
|
+
await_future=False,
|
|
81
82
|
epilog=dedent("""
|
|
82
83
|
To add to systemd:
|
|
83
84
|
su -
|
|
@@ -156,12 +157,12 @@ def create_module_registry():
|
|
|
156
157
|
like to see correctly typed values and set values."""),
|
|
157
158
|
extra_args=[
|
|
158
159
|
ModuleArgument(
|
|
159
|
-
('-p', '--port'),
|
|
160
|
+
('-p', '--bus-port'),
|
|
160
161
|
{'action': 'store', 'type': int, 'default': 1324,
|
|
161
162
|
'help': 'connect to port (default: 1324)'}
|
|
162
163
|
),
|
|
163
164
|
ModuleArgument(
|
|
164
|
-
('-i', '--ip'),
|
|
165
|
+
('-i', '--bus-ip'),
|
|
165
166
|
{'action': 'store', 'default': 'localhost',
|
|
166
167
|
'help': 'connect to ip address (default: localhost)'}
|
|
167
168
|
)
|
|
@@ -208,9 +209,11 @@ class ModuleFactory:
|
|
|
208
209
|
parser.add_argument(*arg.args, **arg.kwargs)
|
|
209
210
|
return parser
|
|
210
211
|
|
|
211
|
-
def create_module(self,
|
|
212
|
+
def create_module(self, options: argparse.Namespace):
|
|
212
213
|
"""Create a module instance based on configuration and options."""
|
|
213
|
-
|
|
214
|
+
if options.module_name not in self.modules:
|
|
215
|
+
raise ValueError(f'{options.module_name} does not exist')
|
|
216
|
+
module_def = self.modules[options.module_name]
|
|
214
217
|
logging.info(f'Python Mobile SCADA {version("pymscada")} '
|
|
215
218
|
f'starting {module_def.name}')
|
|
216
219
|
# Import the module class only when needed
|
|
@@ -220,13 +223,14 @@ class ModuleFactory:
|
|
|
220
223
|
actual_class = getattr(module, class_name)
|
|
221
224
|
else:
|
|
222
225
|
actual_class = module_def.module_class
|
|
223
|
-
|
|
224
226
|
kwargs = {}
|
|
225
227
|
if module_def.config:
|
|
226
|
-
kwargs.update(Config(options.config))
|
|
228
|
+
kwargs.update(Config(options.config))
|
|
227
229
|
if module_def.tags:
|
|
228
230
|
kwargs['tag_info'] = dict(Config(options.tags))
|
|
229
|
-
if
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
if module_def.extra_args:
|
|
232
|
+
for arg in module_def.extra_args:
|
|
233
|
+
arg_name = arg.args[-1].lstrip('-').replace('-', '_')
|
|
234
|
+
if hasattr(options, arg_name):
|
|
235
|
+
kwargs[arg_name] = getattr(options, arg_name)
|
|
232
236
|
return actual_class(**kwargs)
|
pymscada/opnotes.py
CHANGED
|
@@ -14,8 +14,8 @@ class OpNotes:
|
|
|
14
14
|
"""
|
|
15
15
|
Connect to bus_ip:bus_port, serve and update operator notes database.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Open an Operator notes table, creating if necessary. Provide additions,
|
|
18
|
+
updates, deletions and history requests via the rta_tag.
|
|
19
19
|
|
|
20
20
|
Event loop must be running.
|
|
21
21
|
"""
|
|
@@ -25,19 +25,55 @@ class OpNotes:
|
|
|
25
25
|
self.connection = sqlite3.connect(db)
|
|
26
26
|
self.table = table
|
|
27
27
|
self.cursor = self.connection.cursor()
|
|
28
|
+
self._init_table()
|
|
29
|
+
self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
|
|
30
|
+
self.rta = Tag(rta_tag, dict)
|
|
31
|
+
self.rta.value = {}
|
|
32
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
33
|
+
|
|
34
|
+
def _init_table(self):
|
|
35
|
+
"""Initialize or upgrade the database table schema."""
|
|
28
36
|
query = (
|
|
29
37
|
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
30
38
|
'(id INTEGER PRIMARY KEY ASC, '
|
|
31
39
|
'date_ms INTEGER, '
|
|
32
40
|
'site TEXT, '
|
|
33
41
|
'by TEXT, '
|
|
34
|
-
'note TEXT
|
|
42
|
+
'note TEXT, '
|
|
43
|
+
'abnormal INTEGER)'
|
|
35
44
|
)
|
|
36
45
|
self.cursor.execute(query)
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
self.cursor.execute(f"PRAGMA table_info({self.table})")
|
|
47
|
+
columns = {col[1]: col[2] for col in self.cursor.fetchall()}
|
|
48
|
+
if 'abnormal' not in columns:
|
|
49
|
+
# Add abnormal as INTEGER from original schema
|
|
50
|
+
logging.warning(f'Upgrading {self.table} schema to include '
|
|
51
|
+
'abnormal INTEGER, CANNOT revert automatically!')
|
|
52
|
+
self.cursor.execute(
|
|
53
|
+
f'ALTER TABLE {self.table} ADD COLUMN abnormal INTEGER')
|
|
54
|
+
elif columns['abnormal'].upper() == 'BOOLEAN':
|
|
55
|
+
# Change abnormal from BOOLEAN to INTEGER
|
|
56
|
+
logging.warning(f'Upgrading {self.table} abnormal from BOOLEAN to '
|
|
57
|
+
'INTEGER, CANNOT revert automatically!')
|
|
58
|
+
self.cursor.execute(
|
|
59
|
+
f'CREATE TABLE {self.table}_new '
|
|
60
|
+
'(id INTEGER PRIMARY KEY ASC, '
|
|
61
|
+
'date_ms INTEGER, '
|
|
62
|
+
'site TEXT, '
|
|
63
|
+
'by TEXT, '
|
|
64
|
+
'note TEXT, '
|
|
65
|
+
'abnormal INTEGER)'
|
|
66
|
+
)
|
|
67
|
+
self.cursor.execute(
|
|
68
|
+
f'INSERT INTO {self.table}_new '
|
|
69
|
+
f'SELECT id, date_ms, site, by, note, '
|
|
70
|
+
f'CASE WHEN abnormal THEN 1 ELSE 0 END '
|
|
71
|
+
f'FROM {self.table}'
|
|
72
|
+
)
|
|
73
|
+
self.cursor.execute(f'DROP TABLE {self.table}')
|
|
74
|
+
self.cursor.execute(
|
|
75
|
+
f'ALTER TABLE {self.table}_new RENAME TO {self.table}'
|
|
76
|
+
)
|
|
41
77
|
|
|
42
78
|
def rta_cb(self, request):
|
|
43
79
|
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
@@ -48,8 +84,10 @@ class OpNotes:
|
|
|
48
84
|
logging.info(f'add {request}')
|
|
49
85
|
with self.connection:
|
|
50
86
|
self.cursor.execute(
|
|
51
|
-
f'INSERT INTO {self.table}
|
|
52
|
-
'
|
|
87
|
+
f'INSERT INTO {self.table} '
|
|
88
|
+
'(date_ms, site, by, note, abnormal) '
|
|
89
|
+
'VALUES(:date_ms, :site, :by, :note, :abnormal) '
|
|
90
|
+
'RETURNING *;',
|
|
53
91
|
request)
|
|
54
92
|
res = self.cursor.fetchone()
|
|
55
93
|
self.rta.value = {
|
|
@@ -57,7 +95,8 @@ class OpNotes:
|
|
|
57
95
|
'date_ms': res[1],
|
|
58
96
|
'site': res[2],
|
|
59
97
|
'by': res[3],
|
|
60
|
-
'note': res[4]
|
|
98
|
+
'note': res[4],
|
|
99
|
+
'abnormal': res[5]
|
|
61
100
|
}
|
|
62
101
|
except sqlite3.IntegrityError as error:
|
|
63
102
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
@@ -67,14 +106,15 @@ class OpNotes:
|
|
|
67
106
|
with self.connection:
|
|
68
107
|
self.cursor.execute(
|
|
69
108
|
f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
|
|
70
|
-
':site, :by, :note) RETURNING *;', request)
|
|
109
|
+
':site, :by, :note, :abnormal) RETURNING *;', request)
|
|
71
110
|
res = self.cursor.fetchone()
|
|
72
111
|
self.rta.value = {
|
|
73
112
|
'id': res[0],
|
|
74
113
|
'date_ms': res[1],
|
|
75
114
|
'site': res[2],
|
|
76
115
|
'by': res[3],
|
|
77
|
-
'note': res[4]
|
|
116
|
+
'note': res[4],
|
|
117
|
+
'abnormal': res[5]
|
|
78
118
|
}
|
|
79
119
|
except sqlite3.IntegrityError as error:
|
|
80
120
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
@@ -101,7 +141,8 @@ class OpNotes:
|
|
|
101
141
|
'date_ms': res[1],
|
|
102
142
|
'site': res[2],
|
|
103
143
|
'by': res[3],
|
|
104
|
-
'note': res[4]
|
|
144
|
+
'note': res[4],
|
|
145
|
+
'abnormal': res[5]
|
|
105
146
|
}
|
|
106
147
|
except sqlite3.IntegrityError as error:
|
|
107
148
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
Binary file
|
pymscada/protocol_constants.py
CHANGED
|
@@ -1,44 +1,62 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Protocol description and protocol constants.
|
|
3
3
|
|
|
4
|
-
Bus holds a tag forever,
|
|
5
|
-
< 1000 forever, otherwise wipes periodically. So assign tagnames once, at the
|
|
6
|
-
start of your program; use RTA as an update messenger, share whole structures
|
|
7
|
-
rarely.
|
|
4
|
+
Bus holds a tag forever, assigning a new ID for each new tagname.
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
- data size of 8-bit char
|
|
6
|
+
Bus protocol message format:
|
|
7
|
+
- version: 8-bit unsigned int == 0x01
|
|
8
|
+
- command: 8-bit unsigned int
|
|
9
|
+
- tag_id: 16-bit unsigned int (0 is valid for ID requests)
|
|
10
|
+
- size: 16-bit unsigned int
|
|
11
|
+
- time_us: 64-bit unsigned int, UTC microseconds
|
|
12
|
+
- data: variable length bytes of size
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
- CMD.ID
|
|
20
|
-
-
|
|
21
|
-
- CMD.
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- CMD.
|
|
28
|
-
|
|
29
|
-
- CMD.
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
- CMD.
|
|
14
|
+
Commands:
|
|
15
|
+
- CMD.ID: Query/inform tag ID
|
|
16
|
+
- Request: data is tagname as bytes
|
|
17
|
+
- Reply: CMD.ID with assigned tag_id and tagname as data
|
|
18
|
+
- Error: CMD.ERR if tagname undefined
|
|
19
|
+
|
|
20
|
+
- CMD.SET: Set a tag value
|
|
21
|
+
- Data format: TYPE byte + typed value
|
|
22
|
+
- No reply
|
|
23
|
+
- Error: CMD.ERR if tag_id invalid
|
|
24
|
+
|
|
25
|
+
- CMD.GET: Get a tag value
|
|
26
|
+
- Request: empty data
|
|
27
|
+
- Reply: CMD.SET with current value
|
|
28
|
+
- Error: CMD.ERR if tag_id invalid
|
|
29
|
+
|
|
30
|
+
- CMD.RTA: Request to author
|
|
31
|
+
- Request: JSON encoded request
|
|
32
|
+
- Reply: Comes from target client, not server
|
|
33
|
+
- Error: CMD.ERR if tag_id invalid or target gone
|
|
34
|
+
|
|
35
|
+
- CMD.SUB: Subscribe to tag updates
|
|
36
|
+
- Request: empty data
|
|
37
|
+
- Reply: CMD.SET with current value
|
|
38
|
+
- Error: CMD.ERR if tag_id invalid
|
|
39
|
+
|
|
40
|
+
- CMD.UNSUB: Unsubscribe from tag
|
|
41
|
+
- No reply
|
|
42
|
+
- Error: CMD.ERR if tag_id invalid
|
|
43
|
+
|
|
44
|
+
- CMD.LIST: List tags
|
|
45
|
+
- Empty data: tags newer than time_us
|
|
46
|
+
- ^text: tags starting with text
|
|
47
|
+
- text$: tags ending with text
|
|
48
|
+
- text: tags containing text
|
|
49
|
+
- Reply: CMD.LIST with space-separated tagnames
|
|
50
|
+
|
|
51
|
+
- CMD.LOG: Log message
|
|
52
|
+
- Data: Message to log (max 300 bytes)
|
|
53
|
+
- Updates __bus__ tag with client address and message
|
|
54
|
+
|
|
55
|
+
Large messages are split into MAX_LEN chunks. Final chunk size < MAX_LEN.
|
|
38
56
|
"""
|
|
39
57
|
|
|
40
58
|
# Tuning constants
|
|
41
|
-
MAX_LEN = 65535 - 14 #
|
|
59
|
+
MAX_LEN = 65535 - 14 # Maximum data size per message
|
|
42
60
|
|
|
43
61
|
from enum import IntEnum
|
|
44
62
|
|
pymscada/tag.py
CHANGED
|
@@ -7,6 +7,7 @@ bus_id is python id(), 0 is null pointer in c, 0 is local bus.
|
|
|
7
7
|
import time
|
|
8
8
|
import array
|
|
9
9
|
import logging
|
|
10
|
+
from typing import TypedDict, Union, Optional, Type, List
|
|
10
11
|
|
|
11
12
|
TYPES = {
|
|
12
13
|
'int': int,
|
|
@@ -310,3 +311,20 @@ class Tag(metaclass=UniqueTag):
|
|
|
310
311
|
self.values = array.array('d')
|
|
311
312
|
else:
|
|
312
313
|
raise TypeError(f"shard invalid {self.name} not int, float")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TagInfo(TypedDict, total=False):
|
|
317
|
+
"""Type definition for tag information dictionary."""
|
|
318
|
+
name: str
|
|
319
|
+
id: Optional[int]
|
|
320
|
+
desc: str
|
|
321
|
+
type: Union[str, Type[int], Type[float], Type[str], Type[list],
|
|
322
|
+
Type[dict], Type[bytes]]
|
|
323
|
+
multi: Optional[List[str]]
|
|
324
|
+
min: Optional[Union[float, int]]
|
|
325
|
+
max: Optional[Union[float, int]]
|
|
326
|
+
deadband: Optional[Union[float, int]]
|
|
327
|
+
units: Optional[str]
|
|
328
|
+
dp: Optional[int]
|
|
329
|
+
format: Optional[str]
|
|
330
|
+
init: Optional[Union[int, float, str]]
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0rc1
|
|
4
4
|
Summary: Shared tag value SCADA with python backup and Angular UI
|
|
5
|
-
Author-
|
|
5
|
+
Author-email: Jamie Walton <jamie@walton.net.nz>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamie0walton/pymscada
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/jamie0walton/pymscada/issues
|
|
7
9
|
Classifier: Programming Language :: Python :: 3
|
|
8
10
|
Classifier: Programming Language :: JavaScript
|
|
9
11
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
10
12
|
Classifier: Operating System :: OS Independent
|
|
11
13
|
Classifier: Environment :: Console
|
|
12
14
|
Classifier: Development Status :: 1 - Planning
|
|
13
|
-
Project-URL: Homepage, https://github.com/jamie0walton/pymscada
|
|
14
|
-
Project-URL: Bug Tracker, https://github.com/jamie0walton/pymscada/issues
|
|
15
15
|
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
16
18
|
Requires-Dist: PyYAML>=6.0.1
|
|
17
19
|
Requires-Dist: aiohttp>=3.8.5
|
|
18
|
-
Requires-Dist: pymscada-html
|
|
20
|
+
Requires-Dist: pymscada-html==0.2.0rc1
|
|
19
21
|
Requires-Dist: cerberus>=1.3.5
|
|
20
22
|
Requires-Dist: pycomm3>=1.2.14
|
|
21
23
|
Requires-Dist: pysnmplib>=5.0.24
|
|
22
|
-
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
24
25
|
# pymscada
|
|
25
26
|
#### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
pymscada-0.1.11b9.dist-info/METADATA,sha256=_Vl7Sc7VlQZgKxlhqBtIRwQ6i6kuux42oMrXRmzYhZ0,2349
|
|
2
|
-
pymscada-0.1.11b9.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
|
|
3
|
-
pymscada-0.1.11b9.dist-info/entry_points.txt,sha256=j_UgZmqFhNquuFC2M8g5-8X9FCpp2RaDb7NrExzkj1c,72
|
|
4
|
-
pymscada-0.1.11b9.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
5
1
|
pymscada/__init__.py,sha256=NV_cIIwe66Ugp8ns426rtfJIIyskWbqwImD9p_5p0bQ,739
|
|
6
2
|
pymscada/__main__.py,sha256=WcyVlrYOoDdktJhOoyubTOycMwpayksFdxwelRU5xpQ,272
|
|
7
3
|
pymscada/bus_client.py,sha256=ZUCynJtEvmHruzLS6ZAzp-_0dv1A1u_POgemb38kHuc,8989
|
|
8
4
|
pymscada/bus_server.py,sha256=eD4Fz4Sv4EIu2vney_d0jAryiCk5eoH5NQA-dAZRTqA,12029
|
|
9
|
-
pymscada/checkout.py,sha256=
|
|
5
|
+
pymscada/checkout.py,sha256=RLuCMTEuUI7pp1hIRAUPbo8xYFta8TjArelx0SD4gOY,3897
|
|
10
6
|
pymscada/config.py,sha256=vwGxieaJBYXiHNQEOYVDFaPuGmnUlCnbNm_W9bugKlc,1851
|
|
11
7
|
pymscada/console.py,sha256=b4gm7cuhYKGFNtHoxygWkrqiN42mU8DM4KUi-Q74M4U,8793
|
|
8
|
+
pymscada/files.py,sha256=MisnKoWvkffPMSj6sGVmm-4fh866x4UX3t9LJg4oCfk,2400
|
|
9
|
+
pymscada/history.py,sha256=p3dvrOcGJhQXtM0vfFI4PmEZFnW_-SuAk18yvosaucc,10697
|
|
10
|
+
pymscada/main.py,sha256=d6EnK4-tEcvM5AqMHYhvqlnSh-E_wd0Tuxk-kXYSiDw,1854
|
|
11
|
+
pymscada/misc.py,sha256=0Cj6OFhQonyhyk9x0BG5MiS-6EPk_w6zvavt8o_Hlf0,622
|
|
12
|
+
pymscada/module_config.py,sha256=3-15qhCLsi_xR4i0cy2XZ74w5n0ZcqW2nPcX_Qtc6fU,8720
|
|
13
|
+
pymscada/opnotes.py,sha256=5SjAqycWypLNidXjXF2xHc7n4xinflTxJTKafB7WN-Q,7083
|
|
14
|
+
pymscada/periodic.py,sha256=MLlL93VLvFqBBgjO1Us1t0aLHTZ5BFdW0B__G02T1nQ,1235
|
|
15
|
+
pymscada/protocol_constants.py,sha256=lPJ4JEgFJ_puJjTym83EJIOw3UTUFbuFMwg3ohyUAGY,2414
|
|
16
|
+
pymscada/samplers.py,sha256=t0IscgsCm5YByioOZ6aOKMO_guDFS_wxnJSiOGKI4Nw,2583
|
|
17
|
+
pymscada/tag.py,sha256=GINy8gE1TPLFnuJLVVSui4Ke3IQlOGRy6_ePAlGlvj4,10683
|
|
18
|
+
pymscada/validate.py,sha256=fPMlP6RscYuTIgdEJjJ0ZZI0OyVSat1lpqg254wqpdE,13140
|
|
19
|
+
pymscada/www_server.py,sha256=rV1Vsk3J1wBhFMBCnK33SziNuTGgVwNc5zLjQQFxJ-s,12021
|
|
12
20
|
pymscada/demo/README.md,sha256=iNcVbCTkq-d4agLV-979lNRaqf_hbJCn3OFzY-6qfU8,880
|
|
13
21
|
pymscada/demo/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
|
|
14
22
|
pymscada/demo/accuweather.yaml,sha256=Fk4rV0S8jCau0173QCzKW8TdUbc4crYVi0aD8fPLNgU,322
|
|
@@ -19,17 +27,17 @@ pymscada/demo/logixclient.yaml,sha256=G_NlJhBYwT1a9ceHDgO6fCNKFmBM2pVO_t9Xa1NqlR
|
|
|
19
27
|
pymscada/demo/modbus_plc.py,sha256=3zZHHbyrdxyryEHBeNIw-fpcDGcS1MaJiqEwQDr6zWI,2397
|
|
20
28
|
pymscada/demo/modbusclient.yaml,sha256=geeCsUJZkkEj7jjXR_Yk6R5zA5Ta9IczrHsARz7ZgXY,1099
|
|
21
29
|
pymscada/demo/modbusserver.yaml,sha256=67_mED6jXgtnzlDIky9Cg4j-nXur06iz9ve3JUwSyG8,1133
|
|
22
|
-
pymscada/demo/openweather.yaml,sha256=
|
|
30
|
+
pymscada/demo/openweather.yaml,sha256=n8aPc_Ar6uiM-XbrEbBydABxFYm2uv_49dGo8u7DI8Q,433
|
|
23
31
|
pymscada/demo/opnotes.yaml,sha256=gdT8DKaAV4F6u9trLCPyBgf449wYaP_FF8GCbjXm9-k,105
|
|
24
32
|
pymscada/demo/ping.yaml,sha256=fm3eUdR2BwnPI_lU_V07qgmDxjSoPP6lPazYB6ZgpVg,149
|
|
25
33
|
pymscada/demo/pymscada-bus.service,sha256=F3ViriRXyMKdCY3tHa3wXAnv2Fo2_16-EScTLsYnSOA,261
|
|
26
34
|
pymscada/demo/pymscada-demo-modbus_plc.service,sha256=EtbWDwqAs4nLNLKScUiHcUWU1b6_tRBeAAVGi9q95hY,320
|
|
27
35
|
pymscada/demo/pymscada-files.service,sha256=iLOfbl4SCxAwYHT20XCGHU0BJsUVicNHjHzKS8xIdgA,326
|
|
28
36
|
pymscada/demo/pymscada-history.service,sha256=61c5RqOmJ13Dl1yyRfsChKOdXp2W-xfYyCOYoJHLkh8,386
|
|
29
|
-
pymscada/demo/pymscada-io-accuweather.service,sha256=M_dnPYwmvTpYGF5AbMStbYL6VCWgOFR50nRRpfqvpBo,358
|
|
30
37
|
pymscada/demo/pymscada-io-logixclient.service,sha256=mn4UzkiOfYqHvgfTFSkUeoPFQQXZboet5I0m7-L5SAY,348
|
|
31
38
|
pymscada/demo/pymscada-io-modbusclient.service,sha256=eTgNdK10dJCs2lLPhmHBh-3j6Ltx2oyU_MNl2f3xnhg,348
|
|
32
39
|
pymscada/demo/pymscada-io-modbusserver.service,sha256=g7Rzm6zGLq_qvTJRL_pcLl4Ps7CNIa2toeGhPNp_oEc,348
|
|
40
|
+
pymscada/demo/pymscada-io-openweather.service,sha256=SQnZ-cq1V3qvZY7EgR_Vx36vCOw1ipfGoLoutHsxtNk,359
|
|
33
41
|
pymscada/demo/pymscada-io-ping.service,sha256=Fm8qR4IVq0NupEvHLGONXGwjjQsx5VqaBYPewhg7-k4,329
|
|
34
42
|
pymscada/demo/pymscada-io-snmpclient.service,sha256=Rsm8uiwnoGx-1MkXqYgtj4UP9-r7AEEeB9yoR0y0oVA,343
|
|
35
43
|
pymscada/demo/pymscada-opnotes.service,sha256=TlrTRgP3rzrlXT8isAGT_Wy38ScDjT1VvnlgW84XiS8,354
|
|
@@ -37,8 +45,7 @@ pymscada/demo/pymscada-wwwserver.service,sha256=7Qy2wsMmFEsQn-b5mgAcsrAQZgXynkv8
|
|
|
37
45
|
pymscada/demo/snmpclient.yaml,sha256=z8iACrFvMftYUtqGrRjPZYZTpn7aOXI-Kp675NAM8cU,2013
|
|
38
46
|
pymscada/demo/tags.yaml,sha256=9xydsQriKT0lNAW533rz-FMVgoedn6Lwc50AnNig7-k,2733
|
|
39
47
|
pymscada/demo/wwwserver.yaml,sha256=mmwvSLUXUDCIPaHeCJdCETAp9Cc4wb5CuK_aGv01KWk,2759
|
|
40
|
-
pymscada/
|
|
41
|
-
pymscada/history.py,sha256=G079gHfzasmGtI5ANS8MdETD4bdZg5vHE_yTKk7atHw,9504
|
|
48
|
+
pymscada/demo/__pycache__/__init__.cpython-311.pyc,sha256=tpxZoW429YA-2mbwzOlhBmbSTcbvTJqgKCfDRMrhEJE,195
|
|
42
49
|
pymscada/iodrivers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
50
|
pymscada/iodrivers/accuweather.py,sha256=p_OYJtCrtbSQYjgf06Yk3Qc9wGpkx8ogn81XNGd19fE,3842
|
|
44
51
|
pymscada/iodrivers/logix_client.py,sha256=eqmiYLYUBHbr7wTljomGIZVNvXe-5WleGKfzwcHXO8w,2829
|
|
@@ -46,24 +53,20 @@ pymscada/iodrivers/logix_map.py,sha256=ljjBAMJcw199v1V5u0Yfl38U6zbZzba5mdY4I3Zvd
|
|
|
46
53
|
pymscada/iodrivers/modbus_client.py,sha256=DIGrEPz_Bwwj9CEeog5fQqiAu1UMV7xVL6KxlKgXNPs,9592
|
|
47
54
|
pymscada/iodrivers/modbus_map.py,sha256=af2J3CGSeYQ4mSy8rNsERp9z7fRgRUYk3it5Mrc_IQA,7255
|
|
48
55
|
pymscada/iodrivers/modbus_server.py,sha256=VqvjOJ4-LOVaD1jOK22raXhrCshJEaWlMxLvn5xMnFc,6570
|
|
49
|
-
pymscada/iodrivers/openweather.py,sha256=
|
|
56
|
+
pymscada/iodrivers/openweather.py,sha256=Z9gYvgBlO4379K51gcblEIZ_O5ZY5MlCZ7NG9dX9Y_4,8359
|
|
50
57
|
pymscada/iodrivers/ping_client.py,sha256=UOQgUfoIcYqy5VvKyJ8XGHHjeSRTfjmrhyWEojhIZQk,4188
|
|
51
58
|
pymscada/iodrivers/ping_map.py,sha256=EbOteqfEYKIOMqPymROJ4now2If-ekEj6jnM5hthoSA,1403
|
|
52
59
|
pymscada/iodrivers/snmp_client.py,sha256=66-IDzddeKcSnqOzNXIZ8wuuAqhIxZjyLNrDwDvHCvw,2708
|
|
53
60
|
pymscada/iodrivers/snmp_map.py,sha256=sDdIR5ZPAETpozDfBt_XQiZ-f4t99UCPlzj7BxFxQyM,2369
|
|
54
|
-
pymscada/main.py,sha256=XtASmPZoQMuzDCHlH3P9XwgYskeLtNdl-hc1tDI7ljc,1875
|
|
55
|
-
pymscada/misc.py,sha256=0Cj6OFhQonyhyk9x0BG5MiS-6EPk_w6zvavt8o_Hlf0,622
|
|
56
|
-
pymscada/module_config.py,sha256=Sq9DDND0WotxwdY7mRHG8G-WNPYqWp9fxvx38--kwr0,8462
|
|
57
|
-
pymscada/opnotes.py,sha256=pxjFgy4uMnAmJcfrk8BX4Gl5j0z4fFb5waXcqI6UJ_M,5133
|
|
58
61
|
pymscada/pdf/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
|
|
59
62
|
pymscada/pdf/one.pdf,sha256=eoJ45DrAjVZrwmwdA_EAz1fwmT44eRnt_tkc2pmMrKY,1488
|
|
60
63
|
pymscada/pdf/two.pdf,sha256=TAuW5yLU1_wfmTH_I5ezHwY0pxhCVuZh3ixu0kwmJwE,1516
|
|
61
|
-
pymscada/
|
|
62
|
-
pymscada/protocol_constants.py,sha256=ooGmBM7WGWWdN10FObdjzcYieK8WN7Zy6qVxtD93LMk,1963
|
|
63
|
-
pymscada/samplers.py,sha256=t0IscgsCm5YByioOZ6aOKMO_guDFS_wxnJSiOGKI4Nw,2583
|
|
64
|
-
pymscada/tag.py,sha256=Oxh70q2MrPAEI94v4QsWt4gD8QP6BlfzNv9xXeeUFys,10103
|
|
64
|
+
pymscada/pdf/__pycache__/__init__.cpython-311.pyc,sha256=4KTfXrV9bGDbTIEv-zgIj_LvzLbVTj77lEC1wzMh9e0,194
|
|
65
65
|
pymscada/tools/snmp_client2.py,sha256=pdn5dYyEv4q-ubA0zQ8X-3tQDYxGC7f7Xexa7QPaL40,1675
|
|
66
66
|
pymscada/tools/walk.py,sha256=OgpprUbKLhEWMvJGfU1ckUt_PFEpwZVOD8HucCgzmOc,1625
|
|
67
|
-
pymscada/
|
|
68
|
-
pymscada/
|
|
69
|
-
pymscada-0.
|
|
67
|
+
pymscada-0.2.0rc1.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
68
|
+
pymscada-0.2.0rc1.dist-info/METADATA,sha256=fizfH7pRV5twgKOLBboB9eeU-wjZxoANsfoHX2ZsXfU,2371
|
|
69
|
+
pymscada-0.2.0rc1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
70
|
+
pymscada-0.2.0rc1.dist-info/entry_points.txt,sha256=2UJBi8jrqujnerrcXcq4F8GHJYVDt26sacXl94t3sd8,56
|
|
71
|
+
pymscada-0.2.0rc1.dist-info/top_level.txt,sha256=LxIB-zrtgObJg0fgdGZXBkmNKLDYHfaH1Hw2YP2ZMms,9
|
|
72
|
+
pymscada-0.2.0rc1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymscada
|
|
File without changes
|