pymscada 0.1.11b10__tar.gz → 0.2.0rc2__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.
- pymscada-0.2.0rc2/MANIFEST.in +2 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/PKG-INFO +7 -6
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/pyproject.toml +20 -24
- pymscada-0.2.0rc2/setup.cfg +4 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/bus_client.py +4 -3
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/bus_server.py +14 -2
- pymscada-0.2.0rc2/src/pymscada/checkout.py +103 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/console.py +2 -2
- pymscada-0.2.0rc2/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/openweather.yaml +1 -1
- pymscada-0.1.11b10/src/pymscada/demo/pymscada-io-accuweather.service → pymscada-0.2.0rc2/src/pymscada/demo/pymscada-io-openweather.service +2 -2
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/files.py +3 -3
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/history.py +61 -6
- pymscada-0.2.0rc2/src/pymscada/iodrivers/openweather.py +209 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/main.py +1 -1
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/module_config.py +14 -10
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/opnotes.py +76 -16
- pymscada-0.2.0rc2/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada-0.2.0rc2/src/pymscada/protocol_constants.py +84 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/tag.py +18 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/www_server.py +43 -10
- pymscada-0.2.0rc2/src/pymscada.egg-info/PKG-INFO +62 -0
- pymscada-0.2.0rc2/src/pymscada.egg-info/SOURCES.txt +86 -0
- pymscada-0.2.0rc2/src/pymscada.egg-info/dependency_links.txt +1 -0
- pymscada-0.2.0rc2/src/pymscada.egg-info/entry_points.txt +2 -0
- pymscada-0.2.0rc2/src/pymscada.egg-info/requires.txt +6 -0
- pymscada-0.2.0rc2/src/pymscada.egg-info/top_level.txt +1 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_openweather.py +1 -2
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_opnotes.py +4 -2
- pymscada-0.1.11b10/src/pymscada/checkout.py +0 -105
- pymscada-0.1.11b10/src/pymscada/iodrivers/openweather.py +0 -128
- pymscada-0.1.11b10/src/pymscada/protocol_constants.py +0 -66
- pymscada-0.1.11b10/tests/__init__.py +0 -1
- pymscada-0.1.11b10/tests/bus_echo.py +0 -48
- pymscada-0.1.11b10/tests/iodrivers/test_logix.py +0 -132
- pymscada-0.1.11b10/tests/iodrivers/test_modbus.py +0 -170
- pymscada-0.1.11b10/tests/test_assets/busserver.yaml +0 -2
- pymscada-0.1.11b10/tests/test_assets/db.sqlite +0 -0
- pymscada-0.1.11b10/tests/test_assets/hist_tag_0_0.dat +0 -0
- pymscada-0.1.11b10/tests/test_assets/hist_tag_0_10_2.dat +0 -0
- pymscada-0.1.11b10/tests/test_assets/hist_tag_0_15.dat +0 -0
- pymscada-0.1.11b10/tests/test_assets/hist_tag_0_26.dat +0 -0
- pymscada-0.1.11b10/tests/test_assets/hist_tag_0_50.dat +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/LICENSE +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/README.md +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/__init__.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/__main__.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/config.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/misc.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/periodic.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/samplers.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/src/pymscada/validate.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_bus_server.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_config.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_history.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_misc.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_periodic.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_samplers.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_tag.py +0 -0
- {pymscada-0.1.11b10 → pymscada-0.2.0rc2}/tests/test_validate.py +0 -0
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0rc2
|
|
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.0rc2
|
|
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,20 +1,21 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymscada"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0rc2"
|
|
4
4
|
description = "Shared tag value SCADA with python backup and Angular UI"
|
|
5
5
|
authors = [
|
|
6
|
-
{
|
|
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
|
|
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.0rc2", # 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
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -12,8 +12,9 @@ class BusClient:
|
|
|
12
12
|
"""
|
|
13
13
|
Connects to a Bus Server.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
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
20
|
def __init__(self, ip: str = '127.0.0.1', port: int = 1324, tag_info=None,
|
|
@@ -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__(
|
|
147
|
-
|
|
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
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Create base config folder and check out demo files."""
|
|
2
|
+
import difflib
|
|
3
|
+
import getpass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
from pymscada.config import get_demo_files, get_pdf_files
|
|
7
|
+
|
|
8
|
+
|
|
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)
|
|
26
|
+
|
|
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()
|
|
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())
|
|
43
|
+
|
|
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)
|
|
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
|
|
73
|
+
|
|
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')
|
|
89
|
+
else:
|
|
90
|
+
print(f'\n--- MISSING FILE\n\n+++ {config_file}')
|
|
91
|
+
|
|
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()
|
|
100
|
+
else:
|
|
101
|
+
self.make_history()
|
|
102
|
+
self.make_pdf()
|
|
103
|
+
self.make_config()
|
|
@@ -3,7 +3,7 @@ import asyncio
|
|
|
3
3
|
import logging
|
|
4
4
|
import sys
|
|
5
5
|
from pymscada.bus_client import BusClient
|
|
6
|
-
from pymscada.tag import Tag, tag_for_web
|
|
6
|
+
from pymscada.tag import Tag, tag_for_web, TagInfo
|
|
7
7
|
try:
|
|
8
8
|
import termios
|
|
9
9
|
import tty
|
|
@@ -153,7 +153,7 @@ class Console:
|
|
|
153
153
|
"""Provide a text console to interact with a Bus."""
|
|
154
154
|
|
|
155
155
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
156
|
-
tag_info: dict = {}):
|
|
156
|
+
tag_info: dict[str, TagInfo] = {}) -> None:
|
|
157
157
|
"""
|
|
158
158
|
Connect to bus_ip:bus_port and provide console interaction with a Bus.
|
|
159
159
|
|
|
Binary file
|
|
@@ -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__
|
|
@@ -10,13 +10,13 @@ class Files():
|
|
|
10
10
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
11
11
|
|
|
12
12
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
13
|
-
path: str = '', files: list =
|
|
13
|
+
path: str = '', files: list[dict] = [],
|
|
14
14
|
rta_tag: str = '__files__') -> None:
|
|
15
15
|
"""
|
|
16
16
|
Connect to bus_ip:bus_port, serve and update files.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Scans paths in files at the root defined by path. These should
|
|
19
|
+
be readable by wwwserver so that download links work.
|
|
20
20
|
|
|
21
21
|
Event loop must be running.
|
|
22
22
|
"""
|
|
@@ -1,11 +1,34 @@
|
|
|
1
|
-
"""Store and provide history.
|
|
1
|
+
"""Store and provide history.
|
|
2
|
+
|
|
3
|
+
History File Structure
|
|
4
|
+
---------------------
|
|
5
|
+
History files are binary files stored as <tagname>_<time_us>.dat where time_us
|
|
6
|
+
is the microsecond timestamp of the first entry in that file.
|
|
7
|
+
|
|
8
|
+
Each file contains a series of fixed-size records (16 bytes each):
|
|
9
|
+
- For integer tags: 8 bytes timestamp (uint64) + 8 bytes value (int64)
|
|
10
|
+
- For float tags: 8 bytes timestamp (uint64) + 8 bytes value (double)
|
|
11
|
+
|
|
12
|
+
Files are organized in chunks:
|
|
13
|
+
- Each chunk is 1024 records (16KB)
|
|
14
|
+
- Each file contains up to 64 chunks (1MB)
|
|
15
|
+
- New files are created when:
|
|
16
|
+
1. Current file reaches max size (64 chunks)
|
|
17
|
+
2. Manual flush() is called
|
|
18
|
+
3. Application shutdown
|
|
19
|
+
|
|
20
|
+
Timestamps are stored as microseconds since epoch in network byte order (big-endian).
|
|
21
|
+
Values are also stored in network byte order.
|
|
22
|
+
"""
|
|
2
23
|
import atexit
|
|
3
24
|
import logging
|
|
4
25
|
from pathlib import Path
|
|
5
26
|
from struct import pack, pack_into, unpack_from, error
|
|
6
27
|
import time
|
|
28
|
+
import socket
|
|
29
|
+
from typing import TypedDict, Optional
|
|
7
30
|
from pymscada.bus_client import BusClient
|
|
8
|
-
from pymscada.tag import Tag, TYPES
|
|
31
|
+
from pymscada.tag import Tag, TagInfo, TYPES
|
|
9
32
|
|
|
10
33
|
|
|
11
34
|
ITEM_SIZE = 16 # Q + q, Q or d
|
|
@@ -14,6 +37,16 @@ CHUNK_SIZE = ITEM_COUNT * ITEM_SIZE
|
|
|
14
37
|
FILE_CHUNKS = 64
|
|
15
38
|
|
|
16
39
|
|
|
40
|
+
class Request(TypedDict, total=False):
|
|
41
|
+
"""Type definition for request dictionary."""
|
|
42
|
+
tagname: str
|
|
43
|
+
start_ms: Optional[int] # Allow web client to use native ms
|
|
44
|
+
start_us: Optional[int] # Native for pymscada server
|
|
45
|
+
end_ms: Optional[int]
|
|
46
|
+
end_us: Optional[int]
|
|
47
|
+
__rta_id__: Optional[int] # Empty for a change that must be broadcast
|
|
48
|
+
|
|
49
|
+
|
|
17
50
|
def tag_for_history(tagname: str, tag: dict):
|
|
18
51
|
"""Correct tag dictionary in place to be suitable for web client."""
|
|
19
52
|
tag['name'] = tagname
|
|
@@ -188,9 +221,14 @@ class TagHistory():
|
|
|
188
221
|
class History():
|
|
189
222
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
190
223
|
|
|
191
|
-
def __init__(
|
|
192
|
-
|
|
193
|
-
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
bus_ip: str = '127.0.0.1',
|
|
227
|
+
bus_port: int = 1324,
|
|
228
|
+
path: str = 'history',
|
|
229
|
+
tag_info: TagInfo = {},
|
|
230
|
+
rta_tag: str | None = '__history__'
|
|
231
|
+
) -> None:
|
|
194
232
|
"""
|
|
195
233
|
Connect to bus_ip:bus_port, store and provide a value history.
|
|
196
234
|
|
|
@@ -200,6 +238,23 @@ class History():
|
|
|
200
238
|
|
|
201
239
|
Event loop must be running.
|
|
202
240
|
"""
|
|
241
|
+
if not isinstance(bus_ip, str):
|
|
242
|
+
raise ValueError("bus_ip must be a string")
|
|
243
|
+
try:
|
|
244
|
+
socket.gethostbyname(bus_ip)
|
|
245
|
+
except socket.gaierror:
|
|
246
|
+
raise ValueError(f"Invalid bus_ip: {bus_ip}")
|
|
247
|
+
if not isinstance(bus_port, int):
|
|
248
|
+
raise ValueError("bus_port must be an integer")
|
|
249
|
+
if not 1024 <= bus_port <= 65535:
|
|
250
|
+
raise ValueError(f"bus_port must be between 1024 and 65535")
|
|
251
|
+
if not isinstance(path, str):
|
|
252
|
+
raise ValueError("path must be a string")
|
|
253
|
+
if not isinstance(tag_info, dict):
|
|
254
|
+
raise ValueError("tag_info must be a dictionary")
|
|
255
|
+
if not isinstance(rta_tag, (str, type(None))):
|
|
256
|
+
raise ValueError("rta_tag must be a string or None")
|
|
257
|
+
|
|
203
258
|
self.busclient = BusClient(bus_ip, bus_port, module='History')
|
|
204
259
|
self.path = path
|
|
205
260
|
self.tags: dict[str, Tag] = {}
|
|
@@ -217,7 +272,7 @@ class History():
|
|
|
217
272
|
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
218
273
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
219
274
|
|
|
220
|
-
def rta_cb(self, request):
|
|
275
|
+
def rta_cb(self, request: Request):
|
|
221
276
|
"""Respond to bus requests for data to publish on rta."""
|
|
222
277
|
if 'start_ms' in request:
|
|
223
278
|
request['start_us'] = request['start_ms'] * 1000
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Poll OpenWeather current and forecast APIs."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import aiohttp
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
from time import time
|
|
7
|
+
from pymscada.bus_client import BusClient
|
|
8
|
+
from pymscada.periodic import Periodic
|
|
9
|
+
from pymscada.tag import Tag
|
|
10
|
+
|
|
11
|
+
class OpenWeatherClient:
|
|
12
|
+
"""Get weather data from OpenWeather Current and Forecast APIs."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
bus_ip: str | None = '127.0.0.1',
|
|
17
|
+
bus_port: int = 1324,
|
|
18
|
+
proxy: str | None = None,
|
|
19
|
+
api: dict = {},
|
|
20
|
+
tags: list = []
|
|
21
|
+
) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Connect to bus on bus_ip:bus_port.
|
|
24
|
+
|
|
25
|
+
api dict should contain:
|
|
26
|
+
- api_key: OpenWeatherMap API key
|
|
27
|
+
- locations: dict of location names and coordinates
|
|
28
|
+
- times: list of hours ahead to fetch forecast data for
|
|
29
|
+
- units: optional units (standard, metric, imperial)
|
|
30
|
+
"""
|
|
31
|
+
if bus_ip is not None:
|
|
32
|
+
try:
|
|
33
|
+
socket.gethostbyname(bus_ip)
|
|
34
|
+
except socket.gaierror:
|
|
35
|
+
raise ValueError(f"Invalid bus_ip: {bus_ip}")
|
|
36
|
+
if not isinstance(proxy, str) and proxy is not None:
|
|
37
|
+
raise ValueError("proxy must be a string or None")
|
|
38
|
+
if not isinstance(api, dict):
|
|
39
|
+
raise ValueError("api must be a dictionary")
|
|
40
|
+
if not isinstance(tags, list):
|
|
41
|
+
raise ValueError("tags must be a list")
|
|
42
|
+
|
|
43
|
+
self.busclient = None
|
|
44
|
+
if bus_ip is not None:
|
|
45
|
+
self.busclient = BusClient(bus_ip, bus_port, module='OpenWeather')
|
|
46
|
+
self.proxy = proxy
|
|
47
|
+
self.map_bus = id(self)
|
|
48
|
+
self.tags = {tagname: Tag(tagname, float) for tagname in tags}
|
|
49
|
+
self.api_key = api['api_key']
|
|
50
|
+
self.units = api.get('units', 'standard')
|
|
51
|
+
self.locations = api.get('locations', {})
|
|
52
|
+
self.parameters = api.get('parameters', {})
|
|
53
|
+
self.times = api.get('times', [3, 6, 12, 24, 48])
|
|
54
|
+
self.current_url = "https://api.openweathermap.org/data/2.5/weather"
|
|
55
|
+
self.forecast_url = "https://api.openweathermap.org/data/2.5/forecast"
|
|
56
|
+
self.queue = asyncio.Queue()
|
|
57
|
+
self.session = None
|
|
58
|
+
self.handle = None
|
|
59
|
+
self.periodic = None
|
|
60
|
+
|
|
61
|
+
def update_tags(self, location, data, suffix):
|
|
62
|
+
"""Update tags for forecast weather."""
|
|
63
|
+
if 'dt' not in data:
|
|
64
|
+
logging.error(f'No timestamp in data for {location}, skipping update')
|
|
65
|
+
return
|
|
66
|
+
for parameter in self.parameters:
|
|
67
|
+
tagname = f"{location}_{parameter}{suffix}"
|
|
68
|
+
try:
|
|
69
|
+
if parameter == 'Temp':
|
|
70
|
+
main_data = data.get('main', {})
|
|
71
|
+
value = main_data.get('temp', 0)
|
|
72
|
+
elif parameter == 'WindSpeed':
|
|
73
|
+
wind_data = data.get('wind', {})
|
|
74
|
+
value = wind_data.get('speed', 0)
|
|
75
|
+
elif parameter == 'WindDir':
|
|
76
|
+
wind_data = data.get('wind', {})
|
|
77
|
+
value = wind_data.get('deg', 0)
|
|
78
|
+
elif parameter == 'Rain':
|
|
79
|
+
rain_data = data.get('rain', {})
|
|
80
|
+
value = rain_data.get('1h', 0)
|
|
81
|
+
else:
|
|
82
|
+
logging.warning(f'Unknown parameter {parameter} for {tagname}')
|
|
83
|
+
continue
|
|
84
|
+
time_us = int(data['dt'] * 1_000_000)
|
|
85
|
+
self.tags[tagname].value = value, time_us, self.map_bus
|
|
86
|
+
logging.debug(f'Updated {tagname} = {value} at timestamp {data["dt"]}')
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logging.error(
|
|
89
|
+
f'Error updating {tagname}: {type(e).__name__} - {str(e)}'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def handle_response(self):
|
|
93
|
+
"""Handle responses from the API."""
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
location, data = await self.queue.get()
|
|
97
|
+
logging.debug(f'Processing data for {location}')
|
|
98
|
+
|
|
99
|
+
if 'dt' in data: # Current weather data
|
|
100
|
+
self.update_tags(location, data, '')
|
|
101
|
+
elif 'list' in data: # Forecast data
|
|
102
|
+
now = int(time())
|
|
103
|
+
for forecast in data['list']:
|
|
104
|
+
hours_ahead = int((forecast['dt'] - now) / 3600)
|
|
105
|
+
if hours_ahead in self.times:
|
|
106
|
+
suffix = f'_{hours_ahead:02d}'
|
|
107
|
+
self.update_tags(location, forecast, suffix)
|
|
108
|
+
|
|
109
|
+
self.queue.task_done()
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logging.error(f'Error handling response: {type(e).__name__} - {str(e)}')
|
|
112
|
+
|
|
113
|
+
async def fetch_current_data(self):
|
|
114
|
+
"""Fetch current weather data for all locations."""
|
|
115
|
+
try:
|
|
116
|
+
if self.session is None:
|
|
117
|
+
self.session = aiohttp.ClientSession()
|
|
118
|
+
|
|
119
|
+
for location, coords in self.locations.items():
|
|
120
|
+
base_params = {
|
|
121
|
+
'lat': coords.get('lat'),
|
|
122
|
+
'lon': coords.get('lon'),
|
|
123
|
+
'appid': self.api_key,
|
|
124
|
+
'units': self.units
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Validate required parameters
|
|
128
|
+
if not all(base_params.values()):
|
|
129
|
+
logging.error(
|
|
130
|
+
f'Missing required parameters for {location}: '
|
|
131
|
+
f'{[k for k, v in base_params.items() if not v]}'
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
async with self.session.get(
|
|
137
|
+
self.current_url,
|
|
138
|
+
params=base_params,
|
|
139
|
+
proxy=self.proxy,
|
|
140
|
+
timeout=30 # Add timeout
|
|
141
|
+
) as resp:
|
|
142
|
+
if resp.status == 200:
|
|
143
|
+
data = await resp.json()
|
|
144
|
+
logging.debug(
|
|
145
|
+
f'Received current weather data for {location}'
|
|
146
|
+
)
|
|
147
|
+
await self.queue.put((location, data))
|
|
148
|
+
else:
|
|
149
|
+
error_text = await resp.text()
|
|
150
|
+
logging.error(
|
|
151
|
+
f'OpenWeather API error for {location}: '
|
|
152
|
+
f'Status: {resp.status}, Response: {error_text[:200]}'
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
logging.error(f'Timeout fetching data for {location}')
|
|
157
|
+
except aiohttp.ClientError as e:
|
|
158
|
+
logging.error(
|
|
159
|
+
f'Network error for {location}: {type(e).__name__} - {str(e)}'
|
|
160
|
+
)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logging.error(
|
|
163
|
+
f'Unexpected error for {location}: {type(e).__name__} - {str(e)}'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logging.error(f'Fatal error in fetch_current_data: {type(e).__name__} - {str(e)}')
|
|
168
|
+
|
|
169
|
+
async def fetch_forecast_data(self):
|
|
170
|
+
"""Fetch forecast weather data for all locations."""
|
|
171
|
+
if self.session is None:
|
|
172
|
+
self.session = aiohttp.ClientSession()
|
|
173
|
+
for location, coords in self.locations.items():
|
|
174
|
+
base_params = {
|
|
175
|
+
'lat': coords['lat'],
|
|
176
|
+
'lon': coords['lon'],
|
|
177
|
+
'appid': self.api_key,
|
|
178
|
+
'units': self.units
|
|
179
|
+
}
|
|
180
|
+
try:
|
|
181
|
+
async with self.session.get(self.forecast_url,
|
|
182
|
+
params=base_params, proxy=self.proxy) as resp:
|
|
183
|
+
if resp.status == 200:
|
|
184
|
+
data = await resp.json()
|
|
185
|
+
logging.info(f'Queue forecast {location} {data}')
|
|
186
|
+
await self.queue.put((location, data))
|
|
187
|
+
else:
|
|
188
|
+
logging.error(f'OpenWeather forecast API error for '
|
|
189
|
+
f'{location}: Status:{resp.status}, '
|
|
190
|
+
f'Response:{await resp.text()}')
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logging.error(f'OpenWeather forecast API error for {location}: '
|
|
193
|
+
f'Exception:{type(e).__name__}, Message:{str(e)}')
|
|
194
|
+
|
|
195
|
+
async def poll(self):
|
|
196
|
+
"""Poll OpenWeather APIs every 10 minutes."""
|
|
197
|
+
now = int(time())
|
|
198
|
+
if now % 600 == 0: # Every 10 minutes
|
|
199
|
+
asyncio.create_task(self.fetch_current_data())
|
|
200
|
+
if now % 3600 == 60: # Every 3 hours, offset by 1 minute
|
|
201
|
+
asyncio.create_task(self.fetch_forecast_data())
|
|
202
|
+
|
|
203
|
+
async def start(self):
|
|
204
|
+
"""Start bus connection and API polling."""
|
|
205
|
+
if self.busclient is not None:
|
|
206
|
+
await self.busclient.start()
|
|
207
|
+
self.handle = asyncio.create_task(self.handle_response())
|
|
208
|
+
self.periodic = Periodic(self.poll, 1.0)
|
|
209
|
+
await self.periodic.start()
|
|
@@ -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()
|