pymscada 0.1.11b10__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 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()
@@ -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:
@@ -1,11 +1,11 @@
1
1
  [Unit]
2
- Description=pymscada - AccuWeather client
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__ accuweatherclient --config __DIR__/config/accuweather.yaml
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: dict = {},
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
- self.tags[tagname].value = value
56
- except KeyError:
57
- logging.warning(f'{tagname} not found setting weather value')
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
- location, data = await self.queue.get()
63
- now = int(time())
64
- if 'dt' in data:
65
- self.update_tags(location, data, '')
66
- elif 'list' in data:
67
- for forecast in data['list']:
68
- hours_ahead = int((forecast['dt'] - now) / 3600)
69
- if hours_ahead not in self.times:
70
- continue
71
- suffix = f'_{hours_ahead:02d}'
72
- self.update_tags(location, forecast, suffix)
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
- logging.info('fetching current')
77
- if self.session is None:
78
- self.session = aiohttp.ClientSession()
79
- for location, coords in self.locations.items():
80
- base_params = {'lat': coords['lat'], 'lon': coords['lon'],
81
- 'appid': self.api_key, 'units': self.units }
82
- try:
83
- async with self.session.get(self.current_url,
84
- params=base_params, proxy=self.proxy) as resp:
85
- if resp.status == 200:
86
- self.queue.put_nowait((location, await resp.json()))
87
- else:
88
- logging.warning('OpenWeather current API error for '
89
- f'{location}: {resp.status}')
90
- except Exception as e:
91
- logging.warning('OpenWeather current API error for '
92
- f'{location}: {e}')
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 = {'lat': coords['lat'], 'lon': coords['lon'],
101
- 'appid': self.api_key, 'units': self.units }
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
- self.queue.put_nowait((location, await resp.json()))
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.warning('OpenWeather forecast API error '
109
- f'for {location}: {resp.status}')
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.warning('OpenWeather forecast API error for '
112
- f'{location}: {e}')
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 % 10800 == 60: # Every 3 hours, offset by 1 minute
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.module_name, 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: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, module_name: str, options: argparse.Namespace):
212
+ def create_module(self, options: argparse.Namespace):
212
213
  """Create a module instance based on configuration and options."""
213
- module_def = self.modules[module_name]
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 module_name == 'console':
230
- return Console(options.ip, options.port,
231
- kwargs.get('tag_info',{}))
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
- TODO
18
- Write something.
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.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
38
- self.rta = Tag(rta_tag, dict)
39
- self.rta.value = {}
40
- self.busclient.add_callback_rta(rta_tag, self.rta_cb)
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} (date_ms, site, by, note) '
52
- 'VALUES(:date_ms, :site, :by, :note) RETURNING *;',
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}')
@@ -1,44 +1,62 @@
1
1
  """
2
2
  Protocol description and protocol constants.
3
3
 
4
- Bus holds a tag forever, assigns a tag id forever, holds a tag value with len
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
- - version 16-bit unsigned int == 0x01
10
- - command 16-bit unsigned int
11
- - tag_id 32-bit unsigned int 0 is not a valid tag_id
12
- - size 32-bit unsigned int
13
- - if size == 0xff continuation mandatory
14
- - size 0x00 completes an empty continuation
15
- - time_us 128-bit unsigned int, UTC microseconds
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
- command
19
- - CMD.ID data is tagname
20
- - reply: CMD_ID with tag_id and data as tagname
21
- - CMD.SET id, data is typed or json packed
22
- - no reply
23
- - CMD.UNSUB id
24
- - no reply
25
- - CMD.GET id
26
- - CMD.RTA id, data is request to author
27
- - CMD.SUB id
28
- - reply: SET id and value, value may be None
29
- - CMD.LIST
30
- - size == 0x00
31
- - tags with values newer than time_us
32
- - size > 0x00
33
- - ^text matches start of tagname
34
- - text$ matches start of tagname
35
- - text matches anywhere in tagname
36
- - reply: LIST data as space separated tagnames
37
- - CMD.LOG data to logging.warning
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 # TODO fix server(?) when 3
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.1.11b10
3
+ Version: 0.2.0rc1
4
4
  Summary: Shared tag value SCADA with python backup and Angular UI
5
- Author-Email: Jamie Walton <jamie@walton.net.nz>
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>=0.1.10b5
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.11b10.dist-info/METADATA,sha256=eBGZjmCSP1HPBnfBlpacMN1Q2VdGFm0_52G2FU-TlLg,2350
2
- pymscada-0.1.11b10.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
- pymscada-0.1.11b10.dist-info/entry_points.txt,sha256=j_UgZmqFhNquuFC2M8g5-8X9FCpp2RaDb7NrExzkj1c,72
4
- pymscada-0.1.11b10.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=1Al29nVqCebupYvMgcY5boz772PtsVp1MVbYaQajV5Q,3373
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=eFaRc7JxkEwRswCtmJiypsfR5xgQR-R-UtwvfwDHw-w,430
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/files.py,sha256=MisnKoWvkffPMSj6sGVmm-4fh866x4UX3t9LJg4oCfk,2400
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=w_OSL5UG30IBTuaR6Exmao7XJae_hKo1JM04XT8Ke7M,5580
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/periodic.py,sha256=MLlL93VLvFqBBgjO1Us1t0aLHTZ5BFdW0B__G02T1nQ,1235
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/validate.py,sha256=fPMlP6RscYuTIgdEJjJ0ZZI0OyVSat1lpqg254wqpdE,13140
68
- pymscada/www_server.py,sha256=rV1Vsk3J1wBhFMBCnK33SziNuTGgVwNc5zLjQQFxJ-s,12021
69
- pymscada-0.1.11b10.dist-info/RECORD,,
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,,
@@ -1,4 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: pdm-backend (2.4.3)
2
+ Generator: setuptools (75.6.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
+
@@ -1,5 +1,2 @@
1
1
  [console_scripts]
2
2
  pymscada = pymscada.__main__:cmd_line
3
-
4
- [gui_scripts]
5
-
@@ -0,0 +1 @@
1
+ pymscada