zabbixvms 0.1.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.
Files changed (37) hide show
  1. zabbixvms-0.1.0/LICENSE +21 -0
  2. zabbixvms-0.1.0/PKG-INFO +77 -0
  3. zabbixvms-0.1.0/README.md +55 -0
  4. zabbixvms-0.1.0/pyproject.toml +60 -0
  5. zabbixvms-0.1.0/setup.cfg +4 -0
  6. zabbixvms-0.1.0/src/zabbixvms/__init__.py +7 -0
  7. zabbixvms-0.1.0/src/zabbixvms/agent.py +117 -0
  8. zabbixvms-0.1.0/src/zabbixvms/collector.py +190 -0
  9. zabbixvms-0.1.0/src/zabbixvms/config.py +166 -0
  10. zabbixvms-0.1.0/src/zabbixvms/data/config_default.json +28 -0
  11. zabbixvms-0.1.0/src/zabbixvms/data/zabbix_template.yaml +128 -0
  12. zabbixvms-0.1.0/src/zabbixvms/log.py +133 -0
  13. zabbixvms-0.1.0/src/zabbixvms/metrics.py +184 -0
  14. zabbixvms-0.1.0/src/zabbixvms/sender.py +45 -0
  15. zabbixvms-0.1.0/src/zabbixvms/service.py +264 -0
  16. zabbixvms-0.1.0/src/zabbixvms/servicecontrol.py +93 -0
  17. zabbixvms-0.1.0/src/zabbixvms/template.py +170 -0
  18. zabbixvms-0.1.0/src/zabbixvms/tray.py +294 -0
  19. zabbixvms-0.1.0/src/zabbixvms.egg-info/PKG-INFO +77 -0
  20. zabbixvms-0.1.0/src/zabbixvms.egg-info/SOURCES.txt +35 -0
  21. zabbixvms-0.1.0/src/zabbixvms.egg-info/dependency_links.txt +1 -0
  22. zabbixvms-0.1.0/src/zabbixvms.egg-info/entry_points.txt +5 -0
  23. zabbixvms-0.1.0/src/zabbixvms.egg-info/requires.txt +8 -0
  24. zabbixvms-0.1.0/src/zabbixvms.egg-info/top_level.txt +1 -0
  25. zabbixvms-0.1.0/tests/test_agent.py +387 -0
  26. zabbixvms-0.1.0/tests/test_collector.py +387 -0
  27. zabbixvms-0.1.0/tests/test_collector_db.py +128 -0
  28. zabbixvms-0.1.0/tests/test_config.py +243 -0
  29. zabbixvms-0.1.0/tests/test_entrypoints.py +44 -0
  30. zabbixvms-0.1.0/tests/test_log.py +176 -0
  31. zabbixvms-0.1.0/tests/test_metrics.py +85 -0
  32. zabbixvms-0.1.0/tests/test_sender.py +107 -0
  33. zabbixvms-0.1.0/tests/test_service.py +346 -0
  34. zabbixvms-0.1.0/tests/test_servicecontrol.py +161 -0
  35. zabbixvms-0.1.0/tests/test_template.py +206 -0
  36. zabbixvms-0.1.0/tests/test_tray.py +337 -0
  37. zabbixvms-0.1.0/tests/test_triggers.py +99 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Logic Elements s.r.o
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: zabbixvms
3
+ Version: 0.1.0
4
+ Summary: Zabbix monitoring agent for Logic Elements VMS deployments
5
+ Author-email: Jan Bartovský <jan.bartovsky@logicelements.cz>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/LogicElements/py-vms-zabbix
8
+ Keywords: logicelements,vibration,vms,zabbix
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: mysql-connector-python
15
+ Requires-Dist: jsonpickle
16
+ Requires-Dist: zabbix_utils
17
+ Requires-Dist: pywin32
18
+ Requires-Dist: PyYAML
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # zabbixvms
24
+
25
+ Zabbix agent pro monitorování instalací VMS od Logic Elements.
26
+
27
+ Sleduje MySQL databázi (`BVMS`), do které zapisuje serverový software VMS, a odvozené metriky o stavu systému (měřené otáčky, stáří bufferů, konfigurace a časových značek, počty řádků v bufferech) odesílá na Zabbix server jako trapper položky (`vms.speed`, `vms.buf_rows_1`, ...).
28
+
29
+ Balíček vznikl vyčleněním z projektu [`pyvms`](https://github.com/LogicElements/py-vms); na něm už nezávisí a s databází `BVMS` pracuje sám.
30
+
31
+ ## Dokumentace
32
+
33
+ - [PRS-zabbixvms.md](doc/PRS-zabbixvms.md) – Product Requirement Specification: účel projektu, use cases a requirementy.
34
+ - [PLAN-zabbixvms.md](doc/PLAN-zabbixvms.md) – plán: rozpad requirementů do etap.
35
+ - [NAVRH-zabbixvms.md](doc/NAVRH-zabbixvms.md) – návrh struktury balíčku: jména, členění modulů a odpovědnosti.
36
+ - [NAVOD-zabbix.md](doc/NAVOD-zabbix.md) – návod k nastavení Zabbixu: založení hostů, import šablony a její přiřazení.
37
+ - [CHYBY-agenta.md](doc/CHYBY-agenta.md) – co agent hlásí, kde chyby vznikají a kde k nim hledat podrobnosti.
38
+ - [BUILD-balicku.md](doc/BUILD-balicku.md) – sestavení balíčku: virtuální prostředí, testy, `build.bat` a publikování.
39
+
40
+ ## Instalace
41
+
42
+ ```bash
43
+ pip install -e .
44
+ ```
45
+
46
+ Běží pouze na Windows (pro integraci s Windows Service se používá `pywin32`).
47
+
48
+ Python musí být na serveru nainstalovaný **pro celý stroj** (volba „for all users"), ne jen
49
+ pro přihlášeného uživatele. Služba běží pod účtem LocalSystem a hostitelský proces
50
+ `pythonservice.exe` potřebuje najít `python3XX.dll`. U instalace jen pro uživatele leží tahle
51
+ knihovna v `%LOCALAPPDATA%`, LocalSystem ji na své cestě nemá a služba pak nenastartuje –
52
+ ohlásí se jen chyba 1053, protože proces skončí dřív, než stihne odpovědět správci služeb.
53
+
54
+ ## Použití
55
+
56
+ Instalace vytvoří dva vstupní body:
57
+
58
+ - `zabbixvms-service` – služba Windows `ZabbixVms`,
59
+ - `zabbixvms-tray` – ikona v systray, spouští se bez konzolového okna.
60
+
61
+ Konfigurace (přístupové údaje k MySQL a Zabbixu, seznam monitorovaných turbín) je JSON
62
+ v souboru `C:\ProgramData\LogicElements\ZabbixVms\config.json`. Pokud soubor neexistuje,
63
+ vytvoří se při prvním spuštění z výchozí šablony dodané v balíčku; existující soubor
64
+ zůstává beze změny i při aktualizaci balíčku.
65
+
66
+ ## Vývoj
67
+
68
+ Testy se spouští z kořene repozitáře:
69
+
70
+ ```bash
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
74
+
75
+ ## Licence
76
+
77
+ MIT © Logic Elements s.r.o. — viz [LICENSE](LICENSE).
@@ -0,0 +1,55 @@
1
+ # zabbixvms
2
+
3
+ Zabbix agent pro monitorování instalací VMS od Logic Elements.
4
+
5
+ Sleduje MySQL databázi (`BVMS`), do které zapisuje serverový software VMS, a odvozené metriky o stavu systému (měřené otáčky, stáří bufferů, konfigurace a časových značek, počty řádků v bufferech) odesílá na Zabbix server jako trapper položky (`vms.speed`, `vms.buf_rows_1`, ...).
6
+
7
+ Balíček vznikl vyčleněním z projektu [`pyvms`](https://github.com/LogicElements/py-vms); na něm už nezávisí a s databází `BVMS` pracuje sám.
8
+
9
+ ## Dokumentace
10
+
11
+ - [PRS-zabbixvms.md](doc/PRS-zabbixvms.md) – Product Requirement Specification: účel projektu, use cases a requirementy.
12
+ - [PLAN-zabbixvms.md](doc/PLAN-zabbixvms.md) – plán: rozpad requirementů do etap.
13
+ - [NAVRH-zabbixvms.md](doc/NAVRH-zabbixvms.md) – návrh struktury balíčku: jména, členění modulů a odpovědnosti.
14
+ - [NAVOD-zabbix.md](doc/NAVOD-zabbix.md) – návod k nastavení Zabbixu: založení hostů, import šablony a její přiřazení.
15
+ - [CHYBY-agenta.md](doc/CHYBY-agenta.md) – co agent hlásí, kde chyby vznikají a kde k nim hledat podrobnosti.
16
+ - [BUILD-balicku.md](doc/BUILD-balicku.md) – sestavení balíčku: virtuální prostředí, testy, `build.bat` a publikování.
17
+
18
+ ## Instalace
19
+
20
+ ```bash
21
+ pip install -e .
22
+ ```
23
+
24
+ Běží pouze na Windows (pro integraci s Windows Service se používá `pywin32`).
25
+
26
+ Python musí být na serveru nainstalovaný **pro celý stroj** (volba „for all users"), ne jen
27
+ pro přihlášeného uživatele. Služba běží pod účtem LocalSystem a hostitelský proces
28
+ `pythonservice.exe` potřebuje najít `python3XX.dll`. U instalace jen pro uživatele leží tahle
29
+ knihovna v `%LOCALAPPDATA%`, LocalSystem ji na své cestě nemá a služba pak nenastartuje –
30
+ ohlásí se jen chyba 1053, protože proces skončí dřív, než stihne odpovědět správci služeb.
31
+
32
+ ## Použití
33
+
34
+ Instalace vytvoří dva vstupní body:
35
+
36
+ - `zabbixvms-service` – služba Windows `ZabbixVms`,
37
+ - `zabbixvms-tray` – ikona v systray, spouští se bez konzolového okna.
38
+
39
+ Konfigurace (přístupové údaje k MySQL a Zabbixu, seznam monitorovaných turbín) je JSON
40
+ v souboru `C:\ProgramData\LogicElements\ZabbixVms\config.json`. Pokud soubor neexistuje,
41
+ vytvoří se při prvním spuštění z výchozí šablony dodané v balíčku; existující soubor
42
+ zůstává beze změny i při aktualizaci balíčku.
43
+
44
+ ## Vývoj
45
+
46
+ Testy se spouští z kořene repozitáře:
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ pytest
51
+ ```
52
+
53
+ ## Licence
54
+
55
+ MIT © Logic Elements s.r.o. — viz [LICENSE](LICENSE).
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zabbixvms"
7
+ description = "Zabbix monitoring agent for Logic Elements VMS deployments"
8
+ version = "0.1.0"
9
+ authors = [
10
+ { name="Jan Bartovský", email="jan.bartovsky@logicelements.cz" },
11
+ ]
12
+ readme = "README.md"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: Microsoft :: Windows",
19
+ ]
20
+ keywords = ["logicelements", "vibration", "vms", "zabbix"]
21
+
22
+ requires-python = ">=3.12"
23
+ dependencies = [
24
+ "mysql-connector-python",
25
+ "jsonpickle",
26
+ "zabbix_utils",
27
+ "pywin32",
28
+ "PyYAML",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest",
34
+ ]
35
+
36
+ [project.scripts]
37
+ zabbixvms-service = "zabbixvms.service:main"
38
+
39
+ [project.gui-scripts]
40
+ zabbixvms-tray = "zabbixvms.tray:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools]
46
+ include-package-data = true
47
+
48
+ [tool.setuptools.package-data]
49
+ zabbixvms = ["data/*.json", "data/*.yaml"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ pythonpath = ["src"]
54
+ markers = [
55
+ "db: needs the BVMS test database, skipped when it is not reachable",
56
+ "gui: builds a real window and systray icon, needs a desktop session",
57
+ ]
58
+
59
+ [project.urls]
60
+ "Homepage" = "https://github.com/LogicElements/py-vms-zabbix"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Zabbix monitoring agent for Logic Elements VMS deployments.
2
+
3
+ Nothing is re-exported here: import from the modules directly, for example
4
+ from zabbixvms.config import Config.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,117 @@
1
+ """The measurement loop: collect the values of every turbine, send them to Zabbix,
2
+ report how the cycle went, wait, repeat.
3
+
4
+ An error in one cycle must not end the loop. The database is unreachable, Zabbix is
5
+ down, a turbine has no row - the cycle is given up, the state of the agent says so,
6
+ and the next cycle starts five seconds later and picks up again once the cause is
7
+ gone.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ from zabbixvms.collector import Collector
15
+ from zabbixvms.config import DEFAULT_PERIOD, Config
16
+ from zabbixvms.log import log
17
+ from zabbixvms.sender import TrapperSender
18
+
19
+ # Delay between the end of one measurement cycle and the start of the next; it comes
20
+ # from the configuration, this is what an agent without one would wait.
21
+ CYCLE_DELAY = DEFAULT_PERIOD
22
+
23
+ # Values of vms.agent_status.
24
+ OK = 0
25
+ WARNING = 1
26
+ ERROR = 2
27
+
28
+ # vms.agent_error is a Character item, which holds 255 characters.
29
+ MAX_ERROR_LENGTH = 255
30
+
31
+ STATUS_KEY = "vms.agent_status"
32
+ ERROR_KEY = "vms.agent_error"
33
+
34
+
35
+ class Agent:
36
+ """Runs the measurement cycle over all turbines of the configuration."""
37
+
38
+ def __init__(self, config: Config, collector: Collector | None = None,
39
+ sender: TrapperSender | None = None, sleep=time.sleep) -> None:
40
+ self._config = config
41
+ self._collector = collector if collector is not None else Collector(config.database)
42
+ self._sender = sender if sender is not None else TrapperSender(config.zabbix)
43
+ self._sleep = sleep
44
+ self._running = False
45
+ self._warnings: list[str] = []
46
+ # Error of the last cycle, or None when the last cycle went through.
47
+ self.last_error: Exception | None = None
48
+ # What the agent said about itself in the last cycle.
49
+ self.status = OK
50
+ self.error_text = ""
51
+
52
+ @property
53
+ def running(self) -> bool:
54
+ return self._running
55
+
56
+ @property
57
+ def period(self) -> int:
58
+ """Seconds waited between cycles, as the configuration sets them."""
59
+ return self._config.zabbix.period
60
+
61
+ def cycle(self) -> None:
62
+ """One measurement cycle: every turbine collected and sent."""
63
+ self._warnings = []
64
+ if not self._collector.is_connected:
65
+ self._collector.connect()
66
+ for turbine in self._config.turbines:
67
+ values = self._collector.collect(turbine)
68
+ self._warnings.extend(self._collector.warnings)
69
+ self._sender.send(turbine, values)
70
+
71
+ def run(self) -> None:
72
+ """Repeat the cycle until stop() is called."""
73
+ self._running = True
74
+ while self._running:
75
+ try:
76
+ self.cycle()
77
+ self.last_error = None
78
+ status, message = self._state_of_the_cycle()
79
+ except Exception as err:
80
+ # The loop outlives the cycle; the database or Zabbix may come back.
81
+ self.last_error = err
82
+ status, message = ERROR, str(err)
83
+ log.error("measurement cycle failed: %s", err)
84
+ self._collector.close()
85
+
86
+ self.status = status
87
+ self.error_text = message[:MAX_ERROR_LENGTH]
88
+ self.report_state()
89
+ self._sleep(self.period)
90
+
91
+ def stop(self) -> None:
92
+ """Ask the loop to finish and release the database connection."""
93
+ self._running = False
94
+ self._collector.close()
95
+
96
+ def report_state(self) -> None:
97
+ """Send how the agent is doing to the host of every turbine.
98
+
99
+ The state goes out even when the cycle failed, so the operator sees why no
100
+ values arrived. When Zabbix is the thing that is down, this cannot get
101
+ through either and is only written to the log.
102
+ """
103
+ values = {STATUS_KEY: self.status, ERROR_KEY: self.error_text}
104
+ for turbine in self._config.turbines:
105
+ try:
106
+ self._sender.send(turbine, values)
107
+ except Exception as err:
108
+ log.error("state of the agent could not be sent for turbine %s: %s",
109
+ turbine.name, err)
110
+
111
+ def _state_of_the_cycle(self) -> tuple[int, str]:
112
+ """Status and text after a cycle that got through without an exception."""
113
+ if not self._warnings:
114
+ return OK, ""
115
+ message = "; ".join(self._warnings)
116
+ log.warning("measurement cycle finished with a warning: %s", message)
117
+ return WARNING, message
@@ -0,0 +1,190 @@
1
+ """Reads the BVMS database and computes the metric values from it.
2
+
3
+ What each metric is computed from is the source table of UC4 in the PRS. Values of
4
+ the info table are read by column name, never by position, so adding a column or
5
+ reordering the table does not change what is sent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta
11
+
12
+ import mysql.connector
13
+
14
+ from zabbixvms.config import DatabaseConfig, Turbine
15
+
16
+ # Columns of the info table the metrics are computed from. They are enumerated in the
17
+ # query instead of SELECT *, so every value is picked by name.
18
+ INFO_COLUMNS = (
19
+ "Date",
20
+ "Phase_Marker",
21
+ "Date_Timestamp",
22
+ "Date_Config",
23
+ "Date_Buffer_1",
24
+ "Date_Buffer_2",
25
+ "Time_bulk_1",
26
+ "Time_bulk_2",
27
+ )
28
+
29
+ # Ages are reported up to one month; anything older says the same thing, that the data
30
+ # stopped coming long ago. The database keeps 0001-01-01 for "never", which saturates
31
+ # here like any other very old date.
32
+ MAX_AGE = timedelta(days=30)
33
+ MAX_AGE_SECONDS = int(MAX_AGE.total_seconds())
34
+
35
+ # Metrics of a buffer the turbine does not have configured.
36
+ NO_BUFFER = 0
37
+
38
+
39
+ class CollectorError(Exception):
40
+ """The database does not hold what the agent needs for a turbine."""
41
+
42
+
43
+ def speed(phase_marker: int | None) -> float:
44
+ """Turbine speed in rpm from the phase marker period.
45
+
46
+ A standing turbine has no phase marker period, the speed is 0 rpm then.
47
+ """
48
+ if not phase_marker:
49
+ return 0.0
50
+ return round(1e8 / phase_marker * 60, 4)
51
+
52
+
53
+ def age(moment: datetime | None, now: datetime) -> int:
54
+ """Whole seconds between moment and the measurement time, at most one month.
55
+
56
+ A missing value means the data never came, which is as old as it gets and
57
+ saturates too. A date in the future would be a negative age, which an unsigned
58
+ item cannot hold, so it reads as zero.
59
+ """
60
+ if moment is None:
61
+ return MAX_AGE_SECONDS
62
+ elapsed = int((now - moment).total_seconds())
63
+ return max(0, min(elapsed, MAX_AGE_SECONDS))
64
+
65
+
66
+ class Collector:
67
+ """Reads one turbine's values out of the BVMS database."""
68
+
69
+ def __init__(self, database: DatabaseConfig, connect=mysql.connector.connect) -> None:
70
+ self._database = database
71
+ self._connect = connect
72
+ self._connection = None
73
+ # Things worth telling about that do not stop the collection; filled by
74
+ # collect() for the turbine it was called with.
75
+ self.warnings: list[str] = []
76
+
77
+ def connect(self) -> None:
78
+ """Open the connection to MySQL using the values from the configuration."""
79
+ self._connection = self._connect(
80
+ host=self._database.host,
81
+ database=self._database.database,
82
+ user=self._database.user,
83
+ password=self._database.password,
84
+ )
85
+
86
+ @property
87
+ def is_connected(self) -> bool:
88
+ """Whether the collector currently holds a connection to MySQL."""
89
+ return self._connection is not None
90
+
91
+ def close(self) -> None:
92
+ """Close the connection; closing a collector that is not connected is fine."""
93
+ if self._connection is not None:
94
+ self._connection.close()
95
+ self._connection = None
96
+
97
+ def read_info(self, turbine: Turbine) -> dict:
98
+ """Row of the info table belonging to the turbine, keyed by column name."""
99
+ columns = ", ".join(f"`{column}`" for column in INFO_COLUMNS)
100
+ query = (f"SELECT {columns} FROM `{self._database.info_table}` "
101
+ f"WHERE `SystemId` = %s")
102
+
103
+ cursor = self._cursor()
104
+ try:
105
+ cursor.execute(query, (turbine.system_id,))
106
+ rows = cursor.fetchall()
107
+ finally:
108
+ cursor.close()
109
+
110
+ if not rows:
111
+ raise CollectorError(
112
+ f"table {self._database.info_table} has no row with SystemId "
113
+ f"{turbine.system_id} of turbine {turbine.name!r}"
114
+ )
115
+ return rows[0]
116
+
117
+ def read_buffer_rows(self, turbine: Turbine) -> dict[str, int]:
118
+ """Row counts of the turbine's buffer tables, keyed by table name."""
119
+ if not turbine.buffers:
120
+ return {}
121
+
122
+ placeholders = ", ".join(["%s"] * len(turbine.buffers))
123
+ query = ("SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES "
124
+ f"WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders})")
125
+
126
+ cursor = self._cursor()
127
+ try:
128
+ cursor.execute(query, (self._database.database, *turbine.buffers))
129
+ rows = cursor.fetchall()
130
+ finally:
131
+ cursor.close()
132
+
133
+ return {row["TABLE_NAME"]: row["TABLE_ROWS"] or 0 for row in rows}
134
+
135
+ def collect(self, turbine: Turbine, now: datetime | None = None) -> dict[str, float]:
136
+ """Values of all collected metrics of one turbine, keyed by metric key."""
137
+ if now is None:
138
+ now = datetime.now()
139
+ self.warnings = []
140
+ info = self.read_info(turbine)
141
+ buffer_rows = self.read_buffer_rows(turbine)
142
+ for table in turbine.buffers:
143
+ if table not in buffer_rows:
144
+ # Counting zero rows here would look like an empty buffer, which is
145
+ # something else entirely than a table that is not there.
146
+ self.warnings.append(
147
+ f"buffer table {table!r} of turbine {turbine.name!r} is not in "
148
+ f"database {self._database.database}")
149
+ return self.values(turbine, info, buffer_rows, now)
150
+
151
+ @staticmethod
152
+ def values(turbine: Turbine, info: dict, buffer_rows: dict[str, int],
153
+ now: datetime) -> dict[str, float]:
154
+ """Metric values computed from one info row and the buffer row counts.
155
+
156
+ Every value of one cycle is computed against the same measurement time.
157
+ """
158
+ values = {
159
+ "vms.speed": speed(info["Phase_Marker"]),
160
+ "vms.info_age": age(info["Date"], now),
161
+ "vms.timestamp_age": age(info["Date_Timestamp"], now),
162
+ "vms.config_age": age(info["Date_Config"], now),
163
+ }
164
+
165
+ # The buffers are summed up into one metric each. Rows and bulk time add up;
166
+ # the age is the worst of them, because summing two ages gives a number that
167
+ # is the age of nothing. A buffer the turbine does not have contributes
168
+ # nothing, so a turbine with one buffer reports exactly that one. Which info
169
+ # columns a buffer uses follows its position, not the table name in the
170
+ # configuration.
171
+ rows = bulk = oldest = NO_BUFFER
172
+ for index in (1, 2):
173
+ if len(turbine.buffers) < index:
174
+ continue
175
+ table = turbine.buffers[index - 1]
176
+ oldest = max(oldest, age(info[f"Date_Buffer_{index}"], now))
177
+ bulk += info[f"Time_bulk_{index}"] or 0
178
+ rows += buffer_rows.get(table, 0)
179
+
180
+ values["vms.buf_rows"] = rows
181
+ values["vms.buf_age"] = oldest
182
+ values["vms.buf_bulk"] = bulk
183
+
184
+ return values
185
+
186
+ def _cursor(self):
187
+ """Cursor returning rows as dicts, so values are picked by column name."""
188
+ if self._connection is None:
189
+ raise CollectorError("collector is not connected to the database")
190
+ return self._connection.cursor(dictionary=True)