pymscada 0.1.11b10__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pymscada might be problematic. Click here for more details.

Files changed (114) hide show
  1. pymscada-0.2.0/MANIFEST.in +2 -0
  2. {pymscada-0.1.11b10 → pymscada-0.2.0}/PKG-INFO +9 -7
  3. {pymscada-0.1.11b10 → pymscada-0.2.0}/pyproject.toml +20 -24
  4. pymscada-0.2.0/setup.cfg +4 -0
  5. pymscada-0.2.0/src/pymscada/alarms.py +353 -0
  6. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/bus_client.py +6 -5
  7. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/bus_server.py +14 -2
  8. pymscada-0.2.0/src/pymscada/callout.py +206 -0
  9. pymscada-0.2.0/src/pymscada/checkout.py +103 -0
  10. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/console.py +4 -3
  11. pymscada-0.2.0/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  12. pymscada-0.2.0/src/pymscada/demo/alarms.yaml +5 -0
  13. pymscada-0.2.0/src/pymscada/demo/callout.yaml +17 -0
  14. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/openweather.yaml +1 -1
  15. pymscada-0.2.0/src/pymscada/demo/pymscada-alarms.service +16 -0
  16. pymscada-0.2.0/src/pymscada/demo/pymscada-callout.service +16 -0
  17. pymscada-0.2.0/src/pymscada/demo/pymscada-io-openweather.service +15 -0
  18. pymscada-0.1.11b10/src/pymscada/demo/pymscada-io-accuweather.service → pymscada-0.2.0/src/pymscada/demo/pymscada-io-witsapi.service +2 -2
  19. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/tags.yaml +4 -0
  20. pymscada-0.2.0/src/pymscada/demo/witsapi.yaml +17 -0
  21. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/files.py +3 -3
  22. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/history.py +64 -8
  23. pymscada-0.2.0/src/pymscada/iodrivers/openweather.py +209 -0
  24. pymscada-0.2.0/src/pymscada/iodrivers/witsapi.py +217 -0
  25. pymscada-0.2.0/src/pymscada/iodrivers/witsapi_POC.py +246 -0
  26. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/main.py +1 -1
  27. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/module_config.py +40 -14
  28. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/opnotes.py +81 -16
  29. pymscada-0.2.0/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  30. pymscada-0.2.0/src/pymscada/protocol_constants.py +84 -0
  31. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/tag.py +0 -22
  32. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/www_server.py +72 -17
  33. pymscada-0.2.0/src/pymscada.egg-info/PKG-INFO +63 -0
  34. pymscada-0.2.0/src/pymscada.egg-info/SOURCES.txt +97 -0
  35. pymscada-0.2.0/src/pymscada.egg-info/dependency_links.txt +1 -0
  36. pymscada-0.2.0/src/pymscada.egg-info/entry_points.txt +2 -0
  37. pymscada-0.2.0/src/pymscada.egg-info/requires.txt +6 -0
  38. pymscada-0.2.0/src/pymscada.egg-info/top_level.txt +1 -0
  39. pymscada-0.2.0/tests/test_alarms.py +198 -0
  40. pymscada-0.2.0/tests/test_callout.py +145 -0
  41. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_history.py +55 -0
  42. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_opnotes.py +5 -4
  43. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_periodic.py +2 -2
  44. pymscada-0.1.11b10/src/pymscada/checkout.py +0 -105
  45. pymscada-0.1.11b10/src/pymscada/iodrivers/openweather.py +0 -128
  46. pymscada-0.1.11b10/src/pymscada/protocol_constants.py +0 -66
  47. pymscada-0.1.11b10/tests/__init__.py +0 -1
  48. pymscada-0.1.11b10/tests/bus_echo.py +0 -48
  49. pymscada-0.1.11b10/tests/iodrivers/test_logix.py +0 -132
  50. pymscada-0.1.11b10/tests/iodrivers/test_modbus.py +0 -170
  51. pymscada-0.1.11b10/tests/test_assets/busserver.yaml +0 -2
  52. pymscada-0.1.11b10/tests/test_assets/db.sqlite +0 -0
  53. pymscada-0.1.11b10/tests/test_assets/hist_tag_0_0.dat +0 -0
  54. pymscada-0.1.11b10/tests/test_assets/hist_tag_0_10_2.dat +0 -0
  55. pymscada-0.1.11b10/tests/test_assets/hist_tag_0_15.dat +0 -0
  56. pymscada-0.1.11b10/tests/test_assets/hist_tag_0_26.dat +0 -0
  57. pymscada-0.1.11b10/tests/test_assets/hist_tag_0_50.dat +0 -0
  58. pymscada-0.1.11b10/tests/test_openweather.py +0 -47
  59. {pymscada-0.1.11b10 → pymscada-0.2.0}/LICENSE +0 -0
  60. {pymscada-0.1.11b10 → pymscada-0.2.0}/README.md +0 -0
  61. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/__init__.py +0 -0
  62. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/__main__.py +0 -0
  63. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/config.py +0 -0
  64. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/README.md +0 -0
  65. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/__init__.py +0 -0
  66. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/accuweather.yaml +0 -0
  67. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/bus.yaml +0 -0
  68. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/files.yaml +0 -0
  69. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/history.yaml +0 -0
  70. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/logixclient.yaml +0 -0
  71. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/modbus_plc.py +0 -0
  72. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/modbusclient.yaml +0 -0
  73. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/modbusserver.yaml +0 -0
  74. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/opnotes.yaml +0 -0
  75. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/ping.yaml +0 -0
  76. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-bus.service +0 -0
  77. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  78. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-files.service +0 -0
  79. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-history.service +0 -0
  80. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  81. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  82. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  83. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  84. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  85. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  86. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  87. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/snmpclient.yaml +0 -0
  88. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/demo/wwwserver.yaml +0 -0
  89. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/__init__.py +0 -0
  90. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/accuweather.py +0 -0
  91. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/logix_client.py +0 -0
  92. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/logix_map.py +0 -0
  93. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/modbus_client.py +0 -0
  94. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/modbus_map.py +0 -0
  95. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/modbus_server.py +0 -0
  96. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/ping_client.py +0 -0
  97. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/ping_map.py +0 -0
  98. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/snmp_client.py +0 -0
  99. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/iodrivers/snmp_map.py +0 -0
  100. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/misc.py +0 -0
  101. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/pdf/__init__.py +0 -0
  102. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/pdf/one.pdf +0 -0
  103. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/pdf/two.pdf +0 -0
  104. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/periodic.py +0 -0
  105. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/samplers.py +0 -0
  106. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/tools/snmp_client2.py +0 -0
  107. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/tools/walk.py +0 -0
  108. {pymscada-0.1.11b10 → pymscada-0.2.0}/src/pymscada/validate.py +0 -0
  109. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_bus_server.py +0 -0
  110. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_config.py +0 -0
  111. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_misc.py +0 -0
  112. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_samplers.py +0 -0
  113. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_tag.py +0 -0
  114. {pymscada-0.1.11b10 → pymscada-0.2.0}/tests/test_validate.py +0 -0
@@ -0,0 +1,2 @@
1
+ graft src/pymscada/demo
2
+ graft src/pymscada/pdf
@@ -1,25 +1,27 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.1.11b10
3
+ Version: 0.2.0
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.0rc4
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
24
+ Dynamic: license-file
23
25
 
24
26
  # pymscada
25
27
  #### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
@@ -1,20 +1,21 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.1.11b10"
3
+ version = "0.2.0"
4
4
  description = "Shared tag value SCADA with python backup and Angular UI"
5
5
  authors = [
6
- { name = "Jamie Walton", email = "jamie@walton.net.nz" },
6
+ {name = "Jamie Walton", email = "jamie@walton.net.nz"},
7
7
  ]
8
8
  dependencies = [
9
- "PyYAML>=6.0.1",
10
- "aiohttp>=3.8.5",
11
- "pymscada-html>=0.1.10b5",
12
- "cerberus>=1.3.5",
13
- "pycomm3>=1.2.14",
14
- "pysnmplib>=5.0.24",
9
+ "PyYAML>=6.0.1", # all
10
+ "aiohttp>=3.8.5", # www_server
11
+ "pymscada-html==0.2.0rc4", # www_server
12
+ "cerberus>=1.3.5", # validate
13
+ "pycomm3>=1.2.14", # logix_client
14
+ "pysnmplib>=5.0.24", # DON'T use pysnmp, dead
15
15
  ]
16
16
  requires-python = ">=3.9"
17
17
  readme = "README.md"
18
+ license = {text = "GPL-3.0-or-later"}
18
19
  classifiers = [
19
20
  "Programming Language :: Python :: 3",
20
21
  "Programming Language :: JavaScript",
@@ -24,21 +25,9 @@ classifiers = [
24
25
  "Development Status :: 1 - Planning",
25
26
  ]
26
27
 
27
- [project.license]
28
- text = "GPL-3.0-or-later"
29
-
30
- [project.scripts]
31
- pymscada = "pymscada.__main__:cmd_line"
32
-
33
- [project.urls]
34
- Homepage = "https://github.com/jamie0walton/pymscada"
35
- "Bug Tracker" = "https://github.com/jamie0walton/pymscada/issues"
36
-
37
28
  [build-system]
38
- requires = [
39
- "pdm-backend",
40
- ]
41
- build-backend = "pdm.backend"
29
+ requires = ["setuptools>=61.0"]
30
+ build-backend = "setuptools.build_meta"
42
31
 
43
32
  [tool.pdm.dev-dependencies]
44
33
  test = [
@@ -46,14 +35,21 @@ test = [
46
35
  "flake8>=6.1.0",
47
36
  "flake8-docstrings>=1.7.0",
48
37
  "pytest-asyncio>=0.21.1",
49
- "pytest-cov>=4.1.0",
38
+ "pytest-cov>=4.1.0"
50
39
  ]
51
40
  pdm = []
52
41
 
53
42
  [tool.coverage.run]
54
43
  omit = [
55
- "tests/*",
44
+ 'tests/*',
56
45
  ]
57
46
 
47
+ [project.scripts]
48
+ pymscada = "pymscada.__main__:cmd_line"
49
+
50
+ [project.urls]
51
+ "Homepage" = "https://github.com/jamie0walton/pymscada"
52
+ "Bug Tracker" = "https://github.com/jamie0walton/pymscada/issues"
53
+
58
54
  [tool.pytest.ini_options]
59
55
  addopts = "-v -s"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,353 @@
1
+ """Alarms handling."""
2
+ import logging
3
+ import sqlite3 # note that sqlite3 has blocking calls
4
+ import socket
5
+ import time
6
+ from pymscada.bus_client import BusClient
7
+ from pymscada.periodic import Periodic
8
+ from pymscada.tag import Tag, TYPES
9
+
10
+ ALM = 0
11
+ RTN = 1
12
+ ACT = 2
13
+ INF = 3
14
+ KIND = {
15
+ ALM: 'ALM',
16
+ RTN: 'RTN',
17
+ ACT: 'ACT',
18
+ INF: 'INF'
19
+ }
20
+ NORMAL = 0
21
+ ALARM = 1
22
+
23
+ """
24
+ Database schema:
25
+
26
+ alarms contains an event log of changes as they occur, this
27
+ includes information on actions taken by the alarm system.
28
+
29
+ CREATE TABLE IF NOT EXISTS alarms (
30
+ id INTEGER PRIMARY KEY ASC,
31
+ date_ms INTEGER,
32
+ alarm_string TEXT,
33
+ kind INTEGER, # one of ALM, RTN, ACT, INF
34
+ desc TEXT,
35
+ group TEXT
36
+ )
37
+ """
38
+
39
+
40
+ def standardise_tag_info(tagname: str, tag: dict):
41
+ """Correct tag dictionary in place to be suitable for modules."""
42
+ tag['name'] = tagname
43
+ tag['id'] = None
44
+ if 'desc' not in tag:
45
+ logging.warning(f"Tag {tagname} has no description, using name")
46
+ tag['desc'] = tag['name']
47
+ if 'group' not in tag:
48
+ tag['group'] = ''
49
+ if 'multi' in tag:
50
+ tag['type'] = int
51
+ else:
52
+ if 'type' not in tag:
53
+ tag['type'] = float
54
+ else:
55
+ if tag['type'] not in TYPES:
56
+ tag['type'] = str
57
+ else:
58
+ tag['type'] = TYPES[tag['type']]
59
+ if 'dp' not in tag:
60
+ if tag['type'] == int:
61
+ tag['dp'] = 0
62
+ else:
63
+ tag['dp'] = 2
64
+ if 'units' not in tag:
65
+ tag['units'] = ''
66
+ if 'alarm' in tag:
67
+ if isinstance(tag['alarm'], str):
68
+ tag['alarm'] = [tag['alarm']]
69
+ if not isinstance(tag['alarm'], list):
70
+ logging.warning(f"Tag {tagname} has invalid alarm {tag['alarm']}")
71
+ del tag['alarm']
72
+
73
+
74
+ def split_operator(alarm: str) -> dict:
75
+ """Split alarm string into operator and value."""
76
+ tokens = alarm.split(' ')
77
+ alm_dict = {'for': 0}
78
+ if len(tokens) not in (2, 4):
79
+ raise ValueError(f"Invalid alarm {alarm}")
80
+ if tokens[0] not in ['>', '<', '==', '>=', '<=']:
81
+ raise ValueError(f"Invalid alarm {alarm}")
82
+ alm_dict['operator'] = tokens[0]
83
+ try:
84
+ alm_dict['value'] = float(tokens[1])
85
+ except ValueError:
86
+ raise ValueError(f"Invalid alarm {alarm}")
87
+ if len(tokens) == 4:
88
+ if tokens[2] != 'for':
89
+ raise ValueError(f"Invalid alarm {alarm}")
90
+ try:
91
+ alm_dict['for'] = int(tokens[3])
92
+ except ValueError:
93
+ raise ValueError(f"Invalid alarm {alarm}")
94
+ return alm_dict
95
+
96
+
97
+ class Alarm():
98
+ """
99
+ Single alarm class.
100
+
101
+ Alarms are defined by a tag and a condition. Tags may have multiple
102
+ conditions, each combination of tag and condition is a separate Alarm.
103
+
104
+ Monitors tag value through the Tag callback. Tracks in alarm state.
105
+ Generates the ALM and RTN messages for Alarms to publish via rta_tag.
106
+ """
107
+
108
+ def __init__(self, tagname: str, tag: dict, alarm: str, group: str, rta_cb, alarms) -> None:
109
+ """Initialize alarm with tag and condition(s)."""
110
+ self.alarm_id = f'{tagname} {alarm}'
111
+ self.tag = Tag(tagname, tag['type'])
112
+ self.tag.desc = tag['desc']
113
+ self.tag.dp = tag['dp']
114
+ self.tag.units = tag['units']
115
+ self.tag.add_callback(self.callback)
116
+ self.group = group
117
+ self.rta_cb = rta_cb
118
+ self.alarms = alarms
119
+ self.alarm = split_operator(alarm)
120
+ self.in_alarm = False
121
+ self.checking = False
122
+
123
+ def callback(self, tag: Tag):
124
+ """Handle tag value changes and generate ALM/RTN messages."""
125
+ if tag.value is None:
126
+ return
127
+ value = float(tag.value)
128
+ time_us = tag.time_us
129
+ new_in_alarm = False
130
+ op = self.alarm['operator']
131
+ if op == '>':
132
+ new_in_alarm = value > self.alarm['value']
133
+ elif op == '<':
134
+ new_in_alarm = value < self.alarm['value']
135
+ elif op == '==':
136
+ new_in_alarm = value == self.alarm['value']
137
+ elif op == '>=':
138
+ new_in_alarm = value >= self.alarm['value']
139
+ elif op == '<=':
140
+ new_in_alarm = value <= self.alarm['value']
141
+ if new_in_alarm == self.in_alarm:
142
+ return
143
+ self.in_alarm = new_in_alarm
144
+ if self.in_alarm:
145
+ if self.alarm['for'] > 0:
146
+ if not self.checking:
147
+ self.checking = True
148
+ self.alarms.checking_alarms.append(self)
149
+ else:
150
+ self.generate_alarm(ALM, time_us, value)
151
+ else:
152
+ if self.checking:
153
+ self.checking = False
154
+ self.alarms.checking_alarms.remove(self)
155
+ self.generate_alarm(RTN, time_us, value)
156
+
157
+ def generate_alarm(self, kind: int, time_us: int, value: float):
158
+ """Generate alarm message."""
159
+ logging.warning(f'Alarm {self.alarm_id} {value} {KIND[kind]}')
160
+ self.rta_cb({
161
+ 'action': 'ADD',
162
+ 'date_ms': int(time_us / 1000),
163
+ 'alarm_string': self.alarm_id,
164
+ 'kind': kind,
165
+ 'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
166
+ f' {self.tag.units}',
167
+ 'group': self.group
168
+ })
169
+
170
+ def check_duration(self, current_time_us: int):
171
+ """Check if alarm condition has been met for required duration."""
172
+ if current_time_us - self.tag.time_us >= self.alarm['for'] * 1000000:
173
+ self.generate_alarm(ALM, current_time_us, self.tag.value)
174
+ self.checking = False
175
+ self.alarms.checking_alarms.remove(self)
176
+
177
+
178
+ class Alarms:
179
+ """Connect to bus_ip:bus_port, store and provide Alarms."""
180
+
181
+ def __init__(
182
+ self,
183
+ bus_ip: str | None = '127.0.0.1',
184
+ bus_port: int | None = 1324,
185
+ db: str | None = None,
186
+ table: str = 'alarms',
187
+ tag_info: dict[str, dict] = {},
188
+ rta_tag: str = '__alarms__'
189
+ ) -> None:
190
+ """
191
+ Connect to bus_ip:bus_port, serve and update alarms database.
192
+
193
+ Open an Alarms table, creating if necessary. Provide additions
194
+ and history requests via the rta_tag.
195
+
196
+ Event loop must be running.
197
+
198
+ For testing only: bus_ip can be None to skip connection.
199
+ """
200
+ if db is None:
201
+ raise SystemExit('Alarms db must be defined')
202
+ if bus_ip is None:
203
+ logging.warning('Alarms has bus_ip=None, only use for testing')
204
+ else:
205
+ try:
206
+ socket.gethostbyname(bus_ip)
207
+ except socket.gaierror as e:
208
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
209
+ if not isinstance(bus_port, int):
210
+ raise TypeError('bus_port must be an integer')
211
+ if not 1024 <= bus_port <= 65535:
212
+ raise ValueError('bus_port must be between 1024 and 65535')
213
+ if not isinstance(rta_tag, str) or not rta_tag:
214
+ raise ValueError('rta_tag must be a non-empty string')
215
+ if not isinstance(table, str) or not table:
216
+ raise ValueError('table must be a non-empty string')
217
+
218
+ logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
219
+ self.alarms: list[Alarm] = []
220
+ self.checking_alarms: list[Alarm] = []
221
+ for tagname, tag in tag_info.items():
222
+ standardise_tag_info(tagname, tag)
223
+ if 'alarm' not in tag or tag['type'] not in (int, float):
224
+ continue
225
+ group = tag['group']
226
+ for alarm in tag['alarm']:
227
+ new_alarm = Alarm(tagname, tag, alarm, group, self.rta_cb,
228
+ self)
229
+ self.alarms.append(new_alarm)
230
+ self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
231
+ self.rta = Tag(rta_tag, dict)
232
+ self.rta.value = {}
233
+ self.busclient.add_callback_rta(rta_tag, self.rta_cb)
234
+ self._init_db(db, table)
235
+ self.periodic = Periodic(self.periodic_cb, 1.0)
236
+
237
+ def _init_db(self, db, table):
238
+ """Initialize the database table schema."""
239
+ self.connection = sqlite3.connect(db)
240
+ self.table = table
241
+ self.cursor = self.connection.cursor()
242
+ query = (
243
+ 'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
244
+ '(id INTEGER PRIMARY KEY ASC, '
245
+ 'date_ms INTEGER, '
246
+ 'alarm_string TEXT, '
247
+ 'kind INTEGER, '
248
+ 'desc TEXT, '
249
+ '"group" TEXT)'
250
+ )
251
+ self.cursor.execute(query)
252
+ self.connection.commit()
253
+
254
+ startup_record = {
255
+ 'action': 'ADD',
256
+ 'date_ms': int(time.time() * 1000),
257
+ 'alarm_string': self.rta.name,
258
+ 'kind': INF,
259
+ 'desc': 'Alarm logging started',
260
+ 'group': '__system__'
261
+ }
262
+ self.rta_cb(startup_record)
263
+
264
+ async def periodic_cb(self):
265
+ """Periodic callback to check alarms."""
266
+ current_time_us = int(time.time() * 1000000)
267
+ for alarm in self.checking_alarms[:]:
268
+ alarm.check_duration(current_time_us)
269
+
270
+ def rta_cb(self, request):
271
+ """Respond to Request to Author and publish on rta_tag as needed."""
272
+ if 'action' not in request:
273
+ logging.warning(f'rta_cb malformed {request}')
274
+ elif request['action'] == 'ADD':
275
+ try:
276
+ logging.info(f'add {request}')
277
+ with self.connection:
278
+ self.cursor.execute(
279
+ f'INSERT INTO {self.table} '
280
+ '(date_ms, alarm_string, kind, desc, "group") '
281
+ 'VALUES(:date_ms, :alarm_string, :kind, :desc, :group) '
282
+ 'RETURNING *;',
283
+ request)
284
+ res = self.cursor.fetchone()
285
+ self.rta.value = {
286
+ 'id': res[0],
287
+ 'date_ms': res[1],
288
+ 'alarm_string': res[2],
289
+ 'kind': res[3],
290
+ 'desc': res[4],
291
+ 'group': res[5]
292
+ }
293
+ except sqlite3.IntegrityError as error:
294
+ logging.warning(f'Alarms rta_cb {error}')
295
+ elif request['action'] == 'UPDATE':
296
+ try:
297
+ logging.info(f'update {request}')
298
+ with self.connection:
299
+ self.cursor.execute(
300
+ f'UPDATE {self.table} SET in_alm = :in_alm '
301
+ 'WHERE id = :id RETURNING *;',
302
+ request)
303
+ res = self.cursor.fetchone()
304
+ if res:
305
+ self.rta.value = {
306
+ 'id': res[0],
307
+ 'date_ms': res[1],
308
+ 'alarm_string': res[2],
309
+ 'kind': res[3],
310
+ 'desc': res[4],
311
+ 'group': res[5]
312
+ }
313
+ except sqlite3.IntegrityError as error:
314
+ logging.warning(f'Alarms rta_cb update {error}')
315
+ elif request['action'] == 'HISTORY':
316
+ try:
317
+ logging.info(f'history {request}')
318
+ with self.connection:
319
+ self.cursor.execute(
320
+ f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
321
+ 'ORDER BY (date_ms - :date_ms);', request)
322
+ for res in self.cursor.fetchall():
323
+ self.rta.value = {
324
+ '__rta_id__': request['__rta_id__'],
325
+ 'id': res[0],
326
+ 'date_ms': res[1],
327
+ 'alarm_string': res[2],
328
+ 'kind': res[3],
329
+ 'desc': res[4],
330
+ 'group': res[5]
331
+ }
332
+ except sqlite3.IntegrityError as error:
333
+ logging.warning(f'Alarms rta_cb {error}')
334
+ elif request['action'] == 'BULK HISTORY':
335
+ try:
336
+ logging.info(f'bulk history {request}')
337
+ with self.connection:
338
+ self.cursor.execute(
339
+ f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
340
+ 'ORDER BY -date_ms;', request)
341
+ results = list(self.cursor.fetchall())
342
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
343
+ 'data': results}
344
+ except sqlite3.IntegrityError as error:
345
+ logging.warning(f'Alarms rta_cb {error}')
346
+ elif request['action'] == 'IN ALARM':
347
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
348
+ 'data': {'in_alarm': list(self.in_alarm)}}
349
+
350
+ async def start(self):
351
+ """Async startup."""
352
+ await self.busclient.start()
353
+ await self.periodic.start()
@@ -12,12 +12,13 @@ class BusClient:
12
12
  """
13
13
  Connects to a Bus Server.
14
14
 
15
- await client.connect() to make the connection. If bus server connection
16
- fails, die.
15
+ The client is created without a connection. client.start() creates the
16
+ connection and checks the tags at that time. When the connection fails
17
+ the client dies. A connection is mandatory for the client to run.
17
18
  """
18
19
 
19
- def __init__(self, ip: str = '127.0.0.1', port: int = 1324, tag_info=None,
20
- module: str = '_unset_'):
20
+ def __init__(self, ip: str | None = '127.0.0.1', port: int | None = 1324,
21
+ tag_info=None, module: str = '_unset_'):
21
22
  """Create bus server."""
22
23
  self.ip = ip
23
24
  self.port = port
@@ -125,7 +126,6 @@ class BusClient:
125
126
 
126
127
  async def read(self):
127
128
  """Read forever."""
128
- await self.open_connection()
129
129
  while True:
130
130
  try:
131
131
  head = await self.reader.readexactly(14)
@@ -223,4 +223,5 @@ class BusClient:
223
223
 
224
224
  async def start(self):
225
225
  """Start async."""
226
+ await self.open_connection()
226
227
  self.read_task = asyncio.create_task(self.read())
@@ -3,6 +3,7 @@ import asyncio
3
3
  from struct import pack, unpack
4
4
  import time
5
5
  import logging
6
+ import socket
6
7
  import pymscada.protocol_constants as pc
7
8
 
8
9
 
@@ -143,8 +144,12 @@ class BusServer:
143
144
 
144
145
  __slots__ = ('ip', 'port', 'server', 'connections', 'bus_tag')
145
146
 
146
- def __init__(self, ip: str = '127.0.0.1', port: int = 1324,
147
- bus_tag: str = '__bus__'):
147
+ def __init__(
148
+ self,
149
+ ip: str = '127.0.0.1',
150
+ port: int = 1324,
151
+ bus_tag: str = '__bus__'
152
+ ) -> None:
148
153
  """
149
154
  Serve Tags on ip:port, echoing changes to any subscribers.
150
155
 
@@ -154,6 +159,13 @@ class BusServer:
154
159
 
155
160
  Event loop must be running.
156
161
  """
162
+ try:
163
+ socket.gethostbyname(ip)
164
+ except socket.gaierror as e:
165
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
166
+ if not isinstance(bus_tag, str):
167
+ raise ValueError('bus_tag must be a string')
168
+
157
169
  self.ip = ip
158
170
  self.port = port
159
171
  self.server = None