pymscada 0.1.11b9__tar.gz → 0.2.0rc1__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 (102) hide show
  1. pymscada-0.2.0rc1/MANIFEST.in +2 -0
  2. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/PKG-INFO +7 -6
  3. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/pyproject.toml +20 -24
  4. pymscada-0.2.0rc1/setup.cfg +4 -0
  5. pymscada-0.2.0rc1/src/pymscada/checkout.py +103 -0
  6. pymscada-0.2.0rc1/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  7. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/openweather.yaml +1 -1
  8. pymscada-0.1.11b9/src/pymscada/demo/pymscada-io-accuweather.service → pymscada-0.2.0rc1/src/pymscada/demo/pymscada-io-openweather.service +2 -2
  9. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/history.py +36 -4
  10. pymscada-0.2.0rc1/src/pymscada/iodrivers/openweather.py +190 -0
  11. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/main.py +1 -1
  12. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/module_config.py +14 -10
  13. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/opnotes.py +54 -13
  14. pymscada-0.2.0rc1/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  15. pymscada-0.2.0rc1/src/pymscada/protocol_constants.py +84 -0
  16. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/tag.py +18 -0
  17. pymscada-0.2.0rc1/src/pymscada.egg-info/PKG-INFO +62 -0
  18. pymscada-0.2.0rc1/src/pymscada.egg-info/SOURCES.txt +86 -0
  19. pymscada-0.2.0rc1/src/pymscada.egg-info/dependency_links.txt +1 -0
  20. pymscada-0.2.0rc1/src/pymscada.egg-info/entry_points.txt +2 -0
  21. pymscada-0.2.0rc1/src/pymscada.egg-info/requires.txt +6 -0
  22. pymscada-0.2.0rc1/src/pymscada.egg-info/top_level.txt +1 -0
  23. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_bus_server.py +14 -14
  24. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_openweather.py +2 -2
  25. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_opnotes.py +4 -2
  26. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_validate.py +2 -0
  27. pymscada-0.1.11b9/src/pymscada/checkout.py +0 -105
  28. pymscada-0.1.11b9/src/pymscada/iodrivers/openweather.py +0 -128
  29. pymscada-0.1.11b9/src/pymscada/protocol_constants.py +0 -66
  30. pymscada-0.1.11b9/tests/__init__.py +0 -1
  31. pymscada-0.1.11b9/tests/bus_echo.py +0 -48
  32. pymscada-0.1.11b9/tests/iodrivers/test_logix.py +0 -120
  33. pymscada-0.1.11b9/tests/iodrivers/test_modbus.py +0 -136
  34. pymscada-0.1.11b9/tests/test_assets/busserver.yaml +0 -2
  35. pymscada-0.1.11b9/tests/test_assets/db.sqlite +0 -0
  36. pymscada-0.1.11b9/tests/test_assets/hist_tag_0_0.dat +0 -0
  37. pymscada-0.1.11b9/tests/test_assets/hist_tag_0_10_2.dat +0 -0
  38. pymscada-0.1.11b9/tests/test_assets/hist_tag_0_15.dat +0 -0
  39. pymscada-0.1.11b9/tests/test_assets/hist_tag_0_26.dat +0 -0
  40. pymscada-0.1.11b9/tests/test_assets/hist_tag_0_50.dat +0 -0
  41. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/LICENSE +0 -0
  42. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/README.md +0 -0
  43. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/__init__.py +0 -0
  44. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/__main__.py +0 -0
  45. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/bus_client.py +0 -0
  46. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/bus_server.py +0 -0
  47. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/config.py +0 -0
  48. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/console.py +0 -0
  49. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/README.md +0 -0
  50. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/__init__.py +0 -0
  51. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/accuweather.yaml +0 -0
  52. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/bus.yaml +0 -0
  53. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/files.yaml +0 -0
  54. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/history.yaml +0 -0
  55. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/logixclient.yaml +0 -0
  56. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/modbus_plc.py +0 -0
  57. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/modbusclient.yaml +0 -0
  58. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/modbusserver.yaml +0 -0
  59. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/opnotes.yaml +0 -0
  60. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/ping.yaml +0 -0
  61. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-bus.service +0 -0
  62. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  63. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-files.service +0 -0
  64. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-history.service +0 -0
  65. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  66. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  67. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  68. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  69. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  70. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  71. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  72. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/snmpclient.yaml +0 -0
  73. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/tags.yaml +0 -0
  74. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/demo/wwwserver.yaml +0 -0
  75. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/files.py +0 -0
  76. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/__init__.py +0 -0
  77. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/accuweather.py +0 -0
  78. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/logix_client.py +0 -0
  79. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/logix_map.py +0 -0
  80. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/modbus_client.py +0 -0
  81. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/modbus_map.py +0 -0
  82. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/modbus_server.py +0 -0
  83. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/ping_client.py +0 -0
  84. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/ping_map.py +0 -0
  85. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/snmp_client.py +0 -0
  86. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/iodrivers/snmp_map.py +0 -0
  87. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/misc.py +0 -0
  88. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/pdf/__init__.py +0 -0
  89. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/pdf/one.pdf +0 -0
  90. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/pdf/two.pdf +0 -0
  91. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/periodic.py +0 -0
  92. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/samplers.py +0 -0
  93. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/tools/snmp_client2.py +0 -0
  94. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/tools/walk.py +0 -0
  95. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/validate.py +0 -0
  96. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/src/pymscada/www_server.py +0 -0
  97. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_config.py +0 -0
  98. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_history.py +0 -0
  99. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_misc.py +0 -0
  100. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_periodic.py +0 -0
  101. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_samplers.py +0 -0
  102. {pymscada-0.1.11b9 → pymscada-0.2.0rc1}/tests/test_tag.py +0 -0
@@ -0,0 +1,2 @@
1
+ graft src/pymscada/demo
2
+ graft src/pymscada/pdf
@@ -1,25 +1,26 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pymscada
3
- Version: 0.1.11b9
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,20 +1,21 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.1.11b9"
3
+ version = "0.2.0rc1"
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.0rc1", # 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,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()
@@ -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__
@@ -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
@@ -0,0 +1,190 @@
1
+ """Poll OpenWeather current and forecast APIs."""
2
+ import asyncio
3
+ import aiohttp
4
+ import logging
5
+ from time import time
6
+ from pymscada.bus_client import BusClient
7
+ from pymscada.periodic import Periodic
8
+ from pymscada.tag import Tag
9
+
10
+ class OpenWeatherClient:
11
+ """Get weather data from OpenWeather Current and Forecast APIs."""
12
+
13
+ def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
14
+ proxy: str = None, api: dict = {}, tags: dict = {}) -> None:
15
+ """
16
+ Connect to bus on bus_ip:bus_port.
17
+
18
+ api dict should contain:
19
+ - api_key: OpenWeatherMap API key
20
+ - locations: dict of location names and coordinates
21
+ - times: list of hours ahead to fetch forecast data for
22
+ - units: optional units (standard, metric, imperial)
23
+ """
24
+ self.busclient = None
25
+ if bus_ip is not None:
26
+ self.busclient = BusClient(bus_ip, bus_port, module='OpenWeather')
27
+ self.proxy = proxy
28
+ self.map_bus = id(self)
29
+ self.tags = {tagname: Tag(tagname, float) for tagname in tags}
30
+ self.api_key = api['api_key']
31
+ self.units = api.get('units', 'standard')
32
+ self.locations = api.get('locations', {})
33
+ self.parameters = api.get('parameters', {})
34
+ self.times = api.get('times', [3, 6, 12, 24, 48])
35
+ self.current_url = "https://api.openweathermap.org/data/2.5/weather"
36
+ self.forecast_url = "https://api.openweathermap.org/data/2.5/forecast"
37
+ self.queue = asyncio.Queue()
38
+ self.session = None
39
+ self.handle = None
40
+ self.periodic = None
41
+
42
+ def update_tags(self, location, data, suffix):
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
47
+ for parameter in self.parameters:
48
+ tagname = f"{location}_{parameter}{suffix}"
49
+ try:
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
+ )
72
+
73
+ async def handle_response(self):
74
+ """Handle responses from the API."""
75
+ while True:
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)}')
93
+
94
+ async def fetch_current_data(self):
95
+ """Fetch current weather data for all locations."""
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)}')
149
+
150
+ async def fetch_forecast_data(self):
151
+ """Fetch forecast weather data for all locations."""
152
+ if self.session is None:
153
+ self.session = aiohttp.ClientSession()
154
+ for location, coords in self.locations.items():
155
+ base_params = {
156
+ 'lat': coords['lat'],
157
+ 'lon': coords['lon'],
158
+ 'appid': self.api_key,
159
+ 'units': self.units
160
+ }
161
+ try:
162
+ async with self.session.get(self.forecast_url,
163
+ params=base_params, proxy=self.proxy) as resp:
164
+ if resp.status == 200:
165
+ data = await resp.json()
166
+ logging.info(f'Queue forecast {location} {data}')
167
+ await self.queue.put((location, data))
168
+ else:
169
+ logging.error(f'OpenWeather forecast API error for '
170
+ f'{location}: Status:{resp.status}, '
171
+ f'Response:{await resp.text()}')
172
+ except Exception as e:
173
+ logging.error(f'OpenWeather forecast API error for {location}: '
174
+ f'Exception:{type(e).__name__}, Message:{str(e)}')
175
+
176
+ async def poll(self):
177
+ """Poll OpenWeather APIs every 10 minutes."""
178
+ now = int(time())
179
+ if now % 600 == 0: # Every 10 minutes
180
+ asyncio.create_task(self.fetch_current_data())
181
+ if now % 3600 == 60: # Every 3 hours, offset by 1 minute
182
+ asyncio.create_task(self.fetch_forecast_data())
183
+
184
+ async def start(self):
185
+ """Start bus connection and API polling."""
186
+ if self.busclient is not None:
187
+ await self.busclient.start()
188
+ self.handle = asyncio.create_task(self.handle_response())
189
+ self.periodic = Periodic(self.poll, 1.0)
190
+ 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.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()
@@ -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)