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.
@@ -0,0 +1,246 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import datetime
4
+
5
+ class WitsAPIClient:
6
+ def __init__(self, url, client_id, client_secret):
7
+ self.client_id = client_id
8
+ self.client_secret = client_secret
9
+ self.base_url = url
10
+ self.session = None
11
+
12
+ async def __aenter__(self):
13
+ """Create session and get token on entry"""
14
+ self.session = aiohttp.ClientSession()
15
+ return self
16
+
17
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
18
+ """Close session on exit"""
19
+ if self.session:
20
+ await self.session.close()
21
+
22
+ async def get_token(self):
23
+ """Get a new OAuth token"""
24
+ auth_url = f"{self.base_url}/login/oauth2/token"
25
+ data = {
26
+ "grant_type": "client_credentials",
27
+ "client_id": self.client_id,
28
+ "client_secret": self.client_secret
29
+ }
30
+
31
+ headers = {
32
+ "Content-Type": "application/x-www-form-urlencoded"
33
+ }
34
+
35
+ try:
36
+ async with self.session.post(auth_url, data=data, headers=headers) as response:
37
+ if response.status == 200:
38
+ result = await response.json()
39
+ self.session.headers.update({
40
+ "Authorization": f"Bearer {result['access_token']}"
41
+ })
42
+ return result["access_token"]
43
+ else:
44
+ error_text = await response.text()
45
+ return None
46
+ except Exception as e:
47
+ return None
48
+
49
+ async def get(self, endpoint):
50
+ """Make a GET request to the WITS API"""
51
+ url = f"{self.base_url}/{endpoint}"
52
+ try:
53
+ async with self.session.get(url) as response:
54
+ if response.status == 200:
55
+ result = await response.json()
56
+ return result
57
+ else:
58
+ error_text = await response.text()
59
+ return None
60
+ except Exception as e:
61
+ return None
62
+
63
+ async def get_schedules(self):
64
+ """Get list of schedules for which pricing data is available"""
65
+ endpoint = "api/market-prices/v1/schedules"
66
+ return await self.get(endpoint)
67
+
68
+ async def get_nodes(self):
69
+ """Get list of GXP/GIP nodes supported by this API"""
70
+ endpoint = "api/market-prices/v1/nodes"
71
+ return await self.get(endpoint)
72
+
73
+ async def get_schedule_prices(self, schedule='RTD', market_type='E', nodes=None,
74
+ back=None, forward=None, from_date=None, to_date=None,
75
+ island=None, offset=0):
76
+ """Get prices for a single schedule
77
+ Args:
78
+ schedule: Schedule type (e.g. 'RTD' for Real Time Dispatch)
79
+ market_type: 'E' for energy prices, 'R' for reserve prices
80
+ nodes: List of node IDs to filter by
81
+ back: Number of trading periods to look back (1-48)
82
+ forward: Number of trading periods to look ahead (1-48)
83
+ from_date: Start datetime (RFC3339 format)
84
+ to_date: End datetime (RFC3339 format)
85
+ island: Filter by island ('NI' or 'SI')
86
+ offset: Pagination offset
87
+ """
88
+ endpoint = f"api/market-prices/v1/schedules/{schedule}/prices"
89
+ params = {
90
+ 'marketType': market_type,
91
+ 'offset': offset
92
+ }
93
+ if nodes:
94
+ params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
95
+ if back:
96
+ params['back'] = min(back, 48)
97
+ if forward:
98
+ params['forward'] = min(forward, 48)
99
+ if from_date:
100
+ params['from'] = from_date
101
+ if to_date:
102
+ params['to'] = to_date
103
+ if island:
104
+ params['island'] = island
105
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
106
+ return await self.get(f"{endpoint}?{query}")
107
+
108
+ async def get_multi_schedule_prices(self, schedules, market_type='E', nodes=None,
109
+ back=None, forward=None, from_date=None,
110
+ to_date=None, island=None, offset=0):
111
+ """Get prices across multiple schedules
112
+ Args:
113
+ schedules: List of schedule types
114
+ market_type: 'E' for energy prices, 'R' for reserve prices
115
+ nodes: List of node IDs to filter by
116
+ back: Number of trading periods to look back (1-48)
117
+ forward: Number of trading periods to look ahead (1-48)
118
+ from_date: Start datetime (RFC3339 format)
119
+ to_date: End datetime (RFC3339 format)
120
+ island: Filter by island ('NI' or 'SI')
121
+ offset: Pagination offset
122
+ """
123
+ endpoint = "api/market-prices/v1/prices"
124
+ params = {
125
+ 'schedules': ','.join(schedules) if isinstance(schedules, list) else schedules,
126
+ 'marketType': market_type,
127
+ 'offset': offset
128
+ }
129
+ if nodes:
130
+ params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
131
+ if back:
132
+ params['back'] = min(back, 48)
133
+ if forward:
134
+ params['forward'] = min(forward, 48)
135
+ if from_date:
136
+ params['from'] = from_date
137
+ if to_date:
138
+ params['to'] = to_date
139
+ if island:
140
+ params['island'] = island
141
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
142
+ return await self.get(f"{endpoint}?{query}")
143
+
144
+ def parse_prices(self, response):
145
+ """Parse API response into structured price dictionary
146
+ Returns dict in format:
147
+ {node: {trading_time_utc: {schedule: [price, last_run_utc]}}}
148
+ """
149
+ if not response:
150
+ return {}
151
+ prices = {}
152
+ for schedule_data in response:
153
+ schedule = schedule_data['schedule']
154
+ if 'prices' not in schedule_data:
155
+ continue
156
+ for price in schedule_data['prices']:
157
+ node = price['node']
158
+ trading_time = int(datetime.datetime.fromisoformat(
159
+ price['tradingDateTime'].replace('Z', '+00:00')
160
+ ).timestamp())
161
+ last_run = int(datetime.datetime.fromisoformat(
162
+ price['lastRunTime'].replace('Z', '+00:00')
163
+ ).timestamp())
164
+
165
+ if node not in prices:
166
+ prices[node] = {}
167
+ if trading_time not in prices[node]:
168
+ prices[node][trading_time] = {}
169
+ prices[node][trading_time][schedule] = [price['price'], last_run]
170
+
171
+ # Create RTD_forecast schedule
172
+ for node in prices:
173
+ for trading_time in prices[node]:
174
+ if 'RTD' in prices[node][trading_time]:
175
+ prices[node][trading_time]['RTD_forecast'] = prices[node][trading_time]['RTD']
176
+ else:
177
+ # Find most recent schedule by last run time
178
+ latest_schedule = None
179
+ latest_last_run = 0
180
+ for schedule in prices[node][trading_time]:
181
+ if prices[node][trading_time][schedule][1] > latest_last_run:
182
+ latest_last_run = prices[node][trading_time][schedule][1]
183
+ latest_schedule = schedule
184
+ if latest_schedule:
185
+ prices[node][trading_time]['RTD_forecast'] = \
186
+ prices[node][trading_time][latest_schedule]
187
+
188
+ return prices
189
+
190
+ def print_prices(self, prices):
191
+ """Print prices in structured format with time information"""
192
+ now = datetime.datetime.now(datetime.timezone.utc)
193
+ now_ts = now.timestamp()
194
+ for node in sorted(prices.keys()):
195
+ print(f" - {node}:")
196
+ for trading_time in sorted(prices[node].keys()):
197
+ time_diff = trading_time - now_ts
198
+ # For future times on 30 minute boundaries, show half-hour intervals
199
+ if time_diff > 0 and trading_time % 1800 == 0:
200
+ half_hours = int(time_diff / 1800)
201
+ time_str = f"(+{half_hours})"
202
+ else:
203
+ # For past times or non-30min intervals, show actual time
204
+ dt = datetime.datetime.fromtimestamp(trading_time,
205
+ datetime.timezone.utc)
206
+ time_str = f"({dt.strftime('%Y-%m-%d %H:%M:%S')})"
207
+
208
+ print(f" - Trading Time UTC: {trading_time} {time_str}")
209
+ for schedule in sorted(prices[node][trading_time].keys()):
210
+ if schedule in ['RTD', 'PRSS', 'PRSL', 'RTD_forecast']:
211
+ price, last_run = prices[node][trading_time][schedule]
212
+ last_run_dt = datetime.datetime.fromtimestamp(
213
+ last_run, datetime.timezone.utc)
214
+ print(f" {schedule:12} Price: {price:8.2f}, "
215
+ f"Last Run: {last_run_dt.strftime('%H:%M:%S')}")
216
+
217
+
218
+ async def main(config):
219
+ async with WitsAPIClient(url=config['url'],client_id=config['client_id'],
220
+ client_secret=config['client_secret']) as client:
221
+ token = await client.get_token()
222
+ if token:
223
+ multi_prices = await client.get_multi_schedule_prices(
224
+ schedules=config['schedules'],
225
+ nodes=config['gxp_list'],
226
+ back=config['back'],
227
+ forward=config['forward']
228
+ )
229
+ if multi_prices:
230
+ prices_dict = client.parse_prices(multi_prices)
231
+ client.print_prices(prices_dict)
232
+ await asyncio.sleep(1)
233
+
234
+
235
+ CONFIG = {
236
+ 'url': 'https://api.electricityinfo.co.nz',
237
+ 'gxp_list': ['MAT1101', 'CYD2201', 'BEN2201'],
238
+ 'schedules': ['RTD', 'PRSS', 'PRSL'],
239
+ 'back': 2,
240
+ 'forward': 72,
241
+ 'client_id': 'xx',
242
+ 'client_secret': 'xx'
243
+ }
244
+
245
+ if __name__ == "__main__":
246
+ asyncio.run(main(CONFIG))
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
@@ -5,19 +5,18 @@ from textwrap import dedent
5
5
  from importlib.metadata import version
6
6
  import logging
7
7
  from pymscada.config import Config
8
- from pymscada.console import Console
9
8
 
10
9
  class ModuleArgument:
11
10
  def __init__(self, args: tuple[str, ...], kwargs: dict[str, Any]):
12
11
  self.args = args
13
- self.kwargs = kwargs
12
+ self.kwargs = kwargs
14
13
 
15
14
  class ModuleDefinition:
16
15
  """Defines a module's configuration and behavior."""
17
- def __init__(self, name: str, help: str, module_class: Type[Any], *,
16
+ def __init__(self, name: str, help: str, module_class: Any, *,
18
17
  config: bool = True, tags: bool = True,
19
18
  epilog: Optional[str] = None,
20
- extra_args: list[ModuleArgument] = None,
19
+ extra_args: Optional[list[ModuleArgument]] = None,
21
20
  await_future: bool = True):
22
21
  self.name = name
23
22
  self.help = help
@@ -59,6 +58,17 @@ def create_module_registry():
59
58
  module_class='pymscada.opnotes:OpNotes',
60
59
  tags=False
61
60
  ),
61
+ ModuleDefinition(
62
+ name='alarms',
63
+ help='alarms',
64
+ module_class='pymscada.alarms:Alarms'
65
+ ),
66
+ ModuleDefinition(
67
+ name='callout',
68
+ help='alarm callout notifications',
69
+ module_class='pymscada.callout:Callout',
70
+ tags=False
71
+ ),
62
72
  ModuleDefinition(
63
73
  name='validate',
64
74
  help='validate config files',
@@ -75,9 +85,10 @@ def create_module_registry():
75
85
  ModuleDefinition(
76
86
  name='checkout',
77
87
  help='create example config files',
78
- module_class='pymscada.checkout:checkout',
88
+ module_class='pymscada.checkout:Checkout',
79
89
  config=False,
80
90
  tags=False,
91
+ await_future=False,
81
92
  epilog=dedent("""
82
93
  To add to systemd:
83
94
  su -
@@ -145,6 +156,18 @@ def create_module_registry():
145
156
  module_class='pymscada.iodrivers.snmp_client:SnmpClient',
146
157
  tags=False
147
158
  ),
159
+ ModuleDefinition(
160
+ name='witsapi',
161
+ help='poll WITS GXP pricing real time dispatch and forecast',
162
+ module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
163
+ tags=False,
164
+ epilog=dedent("""
165
+ WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
166
+ or as environment variables:
167
+ vi ~/.bashrc
168
+ export WITS_CLIENT_ID='your_client_id'
169
+ export WITS_CLIENT_SECRET='your_client_secret'""")
170
+ ),
148
171
  ModuleDefinition(
149
172
  name='console',
150
173
  help='interactive bus console',
@@ -156,12 +179,12 @@ def create_module_registry():
156
179
  like to see correctly typed values and set values."""),
157
180
  extra_args=[
158
181
  ModuleArgument(
159
- ('-p', '--port'),
182
+ ('-p', '--bus-port'),
160
183
  {'action': 'store', 'type': int, 'default': 1324,
161
184
  'help': 'connect to port (default: 1324)'}
162
185
  ),
163
186
  ModuleArgument(
164
- ('-i', '--ip'),
187
+ ('-i', '--bus-ip'),
165
188
  {'action': 'store', 'default': 'localhost',
166
189
  'help': 'connect to ip address (default: localhost)'}
167
190
  )
@@ -208,9 +231,11 @@ class ModuleFactory:
208
231
  parser.add_argument(*arg.args, **arg.kwargs)
209
232
  return parser
210
233
 
211
- def create_module(self, module_name: str, options: argparse.Namespace):
234
+ def create_module(self, options: argparse.Namespace):
212
235
  """Create a module instance based on configuration and options."""
213
- module_def = self.modules[module_name]
236
+ if options.module_name not in self.modules:
237
+ raise ValueError(f'{options.module_name} does not exist')
238
+ module_def = self.modules[options.module_name]
214
239
  logging.info(f'Python Mobile SCADA {version("pymscada")} '
215
240
  f'starting {module_def.name}')
216
241
  # Import the module class only when needed
@@ -220,13 +245,14 @@ class ModuleFactory:
220
245
  actual_class = getattr(module, class_name)
221
246
  else:
222
247
  actual_class = module_def.module_class
223
-
224
248
  kwargs = {}
225
249
  if module_def.config:
226
- kwargs.update(Config(options.config))
250
+ kwargs.update(Config(options.config))
227
251
  if module_def.tags:
228
252
  kwargs['tag_info'] = dict(Config(options.tags))
229
- if module_name == 'console':
230
- return Console(options.ip, options.port,
231
- kwargs.get('tag_info',{}))
253
+ if module_def.extra_args:
254
+ for arg in module_def.extra_args:
255
+ arg_name = arg.args[-1].lstrip('-').replace('-', '_')
256
+ if hasattr(options, arg_name):
257
+ kwargs[arg_name] = getattr(options, arg_name)
232
258
  return actual_class(**kwargs)
pymscada/opnotes.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Operator Notes."""
2
2
  import logging
3
3
  import sqlite3 # note that sqlite3 has blocking calls
4
+ import socket
4
5
  from pymscada.bus_client import BusClient
5
6
  from pymscada.tag import Tag
6
7
 
@@ -8,36 +9,95 @@ from pymscada.tag import Tag
8
9
  class OpNotes:
9
10
  """Connect to bus_ip:bus_port, store and provide Operator Notes."""
10
11
 
11
- def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
12
- db: str = None, rta_tag: str = '__opnotes__',
13
- table: str = 'opnotes') -> None:
12
+ def __init__(
13
+ self,
14
+ bus_ip: str = '127.0.0.1',
15
+ bus_port: int = 1324,
16
+ db: str | None = None,
17
+ table: str = 'opnotes',
18
+ rta_tag: str = '__opnotes__'
19
+ ) -> None:
14
20
  """
15
21
  Connect to bus_ip:bus_port, serve and update operator notes database.
16
22
 
17
- TODO
18
- Write something.
23
+ Open an Operator notes table, creating if necessary. Provide additions,
24
+ updates, deletions and history requests via the rta_tag.
19
25
 
20
26
  Event loop must be running.
27
+
28
+ For testing only: bus_ip can be None to skip connection.
21
29
  """
22
30
  if db is None:
23
31
  raise SystemExit('OpNotes db must be defined')
32
+ if bus_ip is None:
33
+ logging.warning('OpNotes has bus_ip=None, only use for testing')
34
+ else:
35
+ try:
36
+ socket.gethostbyname(bus_ip)
37
+ except socket.gaierror as e:
38
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
39
+ if not isinstance(bus_port, int):
40
+ raise TypeError('bus_port must be an integer')
41
+ if not 1024 <= bus_port <= 65535:
42
+ raise ValueError('bus_port must be between 1024 and 65535')
43
+ if not isinstance(rta_tag, str) or not rta_tag:
44
+ raise ValueError('rta_tag must be a non-empty string')
45
+ if not isinstance(table, str) or not table:
46
+ raise ValueError('table must be a non-empty string')
47
+
24
48
  logging.warning(f'OpNotes {bus_ip} {bus_port} {db} {rta_tag}')
25
49
  self.connection = sqlite3.connect(db)
26
50
  self.table = table
27
51
  self.cursor = self.connection.cursor()
52
+ self._init_table()
53
+ self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
54
+ self.rta = Tag(rta_tag, dict)
55
+ self.rta.value = {}
56
+ self.busclient.add_callback_rta(rta_tag, self.rta_cb)
57
+
58
+ def _init_table(self):
59
+ """Initialize or upgrade the database table schema."""
28
60
  query = (
29
61
  'CREATE TABLE IF NOT EXISTS ' + self.table +
30
62
  '(id INTEGER PRIMARY KEY ASC, '
31
63
  'date_ms INTEGER, '
32
64
  'site TEXT, '
33
65
  'by TEXT, '
34
- 'note TEXT)'
66
+ 'note TEXT, '
67
+ 'abnormal INTEGER)'
35
68
  )
36
69
  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)
70
+ self.cursor.execute(f"PRAGMA table_info({self.table})")
71
+ columns = {col[1]: col[2] for col in self.cursor.fetchall()}
72
+ if 'abnormal' not in columns:
73
+ # Add abnormal as INTEGER from original schema
74
+ logging.warning(f'Upgrading {self.table} schema to include '
75
+ 'abnormal INTEGER, CANNOT revert automatically!')
76
+ self.cursor.execute(
77
+ f'ALTER TABLE {self.table} ADD COLUMN abnormal INTEGER')
78
+ elif columns['abnormal'].upper() == 'BOOLEAN':
79
+ # Change abnormal from BOOLEAN to INTEGER
80
+ logging.warning(f'Upgrading {self.table} abnormal from BOOLEAN to '
81
+ 'INTEGER, CANNOT revert automatically!')
82
+ self.cursor.execute(
83
+ f'CREATE TABLE {self.table}_new '
84
+ '(id INTEGER PRIMARY KEY ASC, '
85
+ 'date_ms INTEGER, '
86
+ 'site TEXT, '
87
+ 'by TEXT, '
88
+ 'note TEXT, '
89
+ 'abnormal INTEGER)'
90
+ )
91
+ self.cursor.execute(
92
+ f'INSERT INTO {self.table}_new '
93
+ f'SELECT id, date_ms, site, by, note, '
94
+ f'CASE WHEN abnormal THEN 1 ELSE 0 END '
95
+ f'FROM {self.table}'
96
+ )
97
+ self.cursor.execute(f'DROP TABLE {self.table}')
98
+ self.cursor.execute(
99
+ f'ALTER TABLE {self.table}_new RENAME TO {self.table}'
100
+ )
41
101
 
42
102
  def rta_cb(self, request):
43
103
  """Respond to Request to Author and publish on rta_tag as needed."""
@@ -48,8 +108,10 @@ class OpNotes:
48
108
  logging.info(f'add {request}')
49
109
  with self.connection:
50
110
  self.cursor.execute(
51
- f'INSERT INTO {self.table} (date_ms, site, by, note) '
52
- 'VALUES(:date_ms, :site, :by, :note) RETURNING *;',
111
+ f'INSERT INTO {self.table} '
112
+ '(date_ms, site, by, note, abnormal) '
113
+ 'VALUES(:date_ms, :site, :by, :note, :abnormal) '
114
+ 'RETURNING *;',
53
115
  request)
54
116
  res = self.cursor.fetchone()
55
117
  self.rta.value = {
@@ -57,7 +119,8 @@ class OpNotes:
57
119
  'date_ms': res[1],
58
120
  'site': res[2],
59
121
  'by': res[3],
60
- 'note': res[4]
122
+ 'note': res[4],
123
+ 'abnormal': res[5]
61
124
  }
62
125
  except sqlite3.IntegrityError as error:
63
126
  logging.warning(f'OpNotes rta_cb {error}')
@@ -67,14 +130,15 @@ class OpNotes:
67
130
  with self.connection:
68
131
  self.cursor.execute(
69
132
  f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
70
- ':site, :by, :note) RETURNING *;', request)
133
+ ':site, :by, :note, :abnormal) RETURNING *;', request)
71
134
  res = self.cursor.fetchone()
72
135
  self.rta.value = {
73
136
  'id': res[0],
74
137
  'date_ms': res[1],
75
138
  'site': res[2],
76
139
  'by': res[3],
77
- 'note': res[4]
140
+ 'note': res[4],
141
+ 'abnormal': res[5]
78
142
  }
79
143
  except sqlite3.IntegrityError as error:
80
144
  logging.warning(f'OpNotes rta_cb {error}')
@@ -101,7 +165,8 @@ class OpNotes:
101
165
  'date_ms': res[1],
102
166
  'site': res[2],
103
167
  'by': res[3],
104
- 'note': res[4]
168
+ 'note': res[4],
169
+ 'abnormal': res[5]
105
170
  }
106
171
  except sqlite3.IntegrityError as error:
107
172
  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
@@ -18,28 +18,6 @@ TYPES = {
18
18
  }
19
19
 
20
20
 
21
- def tag_for_web(tagname: str, tag: dict):
22
- """Correct tag dictionary in place to be suitable for web client."""
23
- tag['name'] = tagname
24
- tag['id'] = None
25
- if 'desc' not in tag:
26
- tag['desc'] = tag.name
27
- if 'multi' in tag:
28
- tag['type'] = 'int'
29
- else:
30
- if 'type' not in tag:
31
- tag['type'] = 'float'
32
- else:
33
- if tag['type'] not in TYPES:
34
- tag['type'] = 'str'
35
- if tag['type'] == 'int':
36
- tag['dp'] = 0
37
- elif tag['type'] == 'float' and 'dp' not in tag:
38
- tag['dp'] = 2
39
- elif tag['type'] == 'str' and 'dp' in tag:
40
- del tag['dp']
41
-
42
-
43
21
  class UniqueTag(type):
44
22
  """Super Tag class only create unique tags for unique tag names."""
45
23