From 94e764e32fb25780cee60fafc955e5813c196f5d Mon Sep 17 00:00:00 2001
From: "th.l" <thl-cmk@outlook.com>
Date: Sun, 6 Aug 2023 19:12:28 +0200
Subject: [PATCH] update project

---
 README.md                                     |   2 +-
 agent_based/vzlogger.py                       | 210 ++++++++++++++++++
 agents/special/agent_vzlogger                 |  18 ++
 checks/agent_vzlogger                         |  33 +++
 gui/wato/check_parameters/agent_vzlogger.py   |  48 ++++
 gui/wato/check_parameters/vzlogger.py         | 124 +++++++++++
 .../cmk/special_agents/agent_vzlogger.py      |  73 ++++++
 packages/vzlogger                             |  17 ++
 vzlogger-0.0.1-230806.mkp                     | Bin 0 -> 4681 bytes
 9 files changed, 524 insertions(+), 1 deletion(-)
 create mode 100644 agent_based/vzlogger.py
 create mode 100755 agents/special/agent_vzlogger
 create mode 100644 checks/agent_vzlogger
 create mode 100644 gui/wato/check_parameters/agent_vzlogger.py
 create mode 100644 gui/wato/check_parameters/vzlogger.py
 create mode 100644 lib/python3/cmk/special_agents/agent_vzlogger.py
 create mode 100644 packages/vzlogger
 create mode 100644 vzlogger-0.0.1-230806.mkp

diff --git a/README.md b/README.md
index 6d6b581..5ca2818 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[PACKAGE]: ../../raw/master/packagee-0.1.2-20230706.mkp "package-0.1.2-20230706.mkp"
+[PACKAGE]: ../../raw/master/vzlogger-0.0.1-230806.mkp "vzlogger-0.0.1-230806.mkp"
 # Title
 
 A short description about the plugin
diff --git a/agent_based/vzlogger.py b/agent_based/vzlogger.py
new file mode 100644
index 0000000..5a58833
--- /dev/null
+++ b/agent_based/vzlogger.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : vzlogger.py
+#
+# vzlogger
+#
+#
+# https://wiki.volkszaehler.org/software/controller/vzlogger
+#
+#
+# 2023-08-05: initial release
+
+import json
+import time
+from dataclasses import dataclass
+from typing import Dict, Mapping, Tuple, Optional, Any
+from cmk.base.plugins.agent_based.agent_based_api.v1.type_defs import (
+    DiscoveryResult,
+    CheckResult,
+    StringTable,
+)
+from cmk.base.plugins.agent_based.agent_based_api.v1 import (
+    register,
+    Service,
+    Result,
+    State,
+    check_levels_predictive,
+    check_levels,
+)
+
+
+@dataclass
+class VzLoggerChannel:
+    uuid: str
+    interval: int
+    last: int
+    protocol: str
+    value: float
+    value_last: int
+
+
+_vzlogger_channel_types = {
+    'meter_reading': 'Meter reading',
+    'voltage': 'Voltage',
+    'electrical_power': 'Electrical Power',
+}
+
+
+def _render_voltage(value: float) -> str:
+    return f'{value:.2f} V'
+
+
+def _render_electrical_power(value: float) -> str:
+    return f'{value:.2f} W'
+
+
+def _render_meter_reading(value: float) -> str:
+    return f'{value:.0f} kWh'
+
+
+def _yield_channel_details(channel: VzLoggerChannel) -> CheckResult:
+    yield Result(state=State.OK, notice=f'UUID: {channel.uuid}')
+    yield Result(state=State.OK, notice=f'Interval: {channel.interval}')
+    yield Result(state=State.OK, notice=f'Last: {time.ctime(channel.last)}')
+    yield Result(state=State.OK, notice=f'Protocol: {channel.protocol}')
+    yield Result(state=State.OK, notice=f'Value: {channel.value}')
+    yield Result(state=State.OK, notice=f'Value last: {time.ctime(channel.value_last)}')
+
+
+def _yield_channel_value(
+        value: float,
+        label: str,
+        metric: str,
+        render_func: Any,
+        levels_upper: Optional[Tuple[int, int]] = None,
+        level_lower: Optional[Tuple[int, int]] = None,
+) -> CheckResult:
+    metric = metric.lower().replace(' ', '_').replace('-', '_')
+    yield from check_levels_predictive(
+        value=value,
+        label=label,
+        metric_name=f'{metric}',
+        render_func=render_func,
+        levels=levels_upper,
+        boundaries=(0, None),
+    ) if isinstance(levels_upper, dict) else check_levels(
+        value=value,
+        # label=label,
+        metric_name=f'{metric}',
+        render_func=render_func,
+        levels_upper=levels_upper,
+        levels_lower=level_lower,
+        # boundaries=(0, None),
+    )
+
+
+def parse_vzlogger(string_table: StringTable) -> Dict[str, VzLoggerChannel]:
+    section = {}
+    data = json.loads(string_table[0][0])
+    channels = data['data']
+    for channel in channels:
+        value_last, value = channel['tuples'][0]
+        section[channel['uuid']] = VzLoggerChannel(
+            uuid=channel['uuid'],
+            interval=channel['interval'],
+            last=channel['last'],
+            protocol=channel['protocol'],
+            value=value,
+            value_last=value_last,
+        )
+
+    return section
+
+
+def discovery_vzlogger(params: Mapping[str, any], section: Dict[str, VzLoggerChannel]) -> DiscoveryResult:
+    # add vzlogger channels with info from discovery rule vzlogger
+    for uuid, channel_type, item in params['channels']:
+        if uuid in section.keys():
+            yield Service(
+                item=f'{_vzlogger_channel_types.get(channel_type, uuid)} {item}',
+                parameters={
+                    'channel_type': channel_type,
+                    'uuid': uuid,
+                }
+            )
+            section.pop(uuid)
+
+    # add unknown vzlogger channels
+    for uuid in section.keys():
+        yield Service(
+            item=f'vzlogger {uuid}',
+            parameters={
+                'channel_type': 'unknown',
+                'uuid': uuid,
+            }
+        )
+
+
+def check_vzlogger(item: str, params: Mapping[str, any], section: Dict[str, VzLoggerChannel]) -> CheckResult:
+    try:
+        channel = section[params['uuid']]
+    except KeyError:
+        yield Result(state=State.UNKNOWN, summary=f'Data not found in section')
+        return
+
+    if params['channel_type'] == 'voltage':
+        yield from _yield_channel_value(
+            value=channel.value,
+            label=item[8:],
+            metric=item,
+            render_func=_render_voltage,
+            levels_upper=params.get('levels_upper'),
+            level_lower=params.get('levels_lower'),
+        )
+
+    elif params['channel_type'] == 'electrical_power':
+
+        yield from _yield_channel_value(
+            value=channel.value,
+            label=item[17:],
+            metric=item,
+            render_func=_render_electrical_power,
+            levels_upper=params.get('levels_upper'),
+            level_lower=params.get('levels_lower'),
+        )
+
+    elif params['channel_type'] == 'meter_reading':
+        yield from _yield_channel_value(
+            value=(channel.value/1000),
+            label=item[14:],
+            metric=item,
+            render_func=_render_meter_reading,
+            levels_upper=params.get('levels_upper'),
+            level_lower=params.get('levels_lower'),
+        )
+
+    elif params['channel_type'] not in _vzlogger_channel_types.keys():
+        yield Result(
+            state=State.UNKNOWN,
+            summary=f'Unknown channel type (see details)',
+            details=f'Please configure a channel type and item name in discovery rules -> vzlogger.'
+        )
+        yield from _yield_channel_details(channel)
+
+    else:
+        yield from _yield_channel_details(channel)
+        yield Result(state=State.UNKNOWN, summary=f'not yet implemented')
+
+
+register.agent_section(
+    name='vzlogger',
+    parse_function=parse_vzlogger,
+)
+
+register.check_plugin(
+    name='vzlogger',
+    service_name='%s',
+    discovery_function=discovery_vzlogger,
+    discovery_default_parameters={},
+    discovery_ruleset_name='discovery_vzlogger',
+    check_function=check_vzlogger,
+    check_default_parameters={},
+    check_ruleset_name='vzlogger',
+)
diff --git a/agents/special/agent_vzlogger b/agents/special/agent_vzlogger
new file mode 100755
index 0000000..58e1812
--- /dev/null
+++ b/agents/special/agent_vzlogger
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : vzlogger.py
+#
+# vzlogger
+#
+# 2023-08-05: initial relese
+#
+from cmk.special_agents.agent_vzlogger import main
+
+if __name__ == '__main__':
+    main()
diff --git a/checks/agent_vzlogger b/checks/agent_vzlogger
new file mode 100644
index 0000000..94b4a8b
--- /dev/null
+++ b/checks/agent_vzlogger
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : agent_vzlogger.py
+#
+# vzlogger
+#
+# 2023-08-05: initial release
+
+from typing import Mapping, Any
+
+
+def agent_vzlogger_arguments(params: Mapping[str, Any], hostname: str, ipaddress: str):
+    args = []
+    if params.get('port'):
+        args += ['--port', params['port']]
+    if params.get('timeout'):
+        args += ['--timeout', params['timeout']]
+    if params.get('testing'):
+        args += ['--testing']
+    if params.get('use_ip_address'):
+        args += [ipaddress]
+    else:
+        args += [hostname]
+    return args
+
+
+special_agent_info['vzlogger'] = agent_vzlogger_arguments
diff --git a/gui/wato/check_parameters/agent_vzlogger.py b/gui/wato/check_parameters/agent_vzlogger.py
new file mode 100644
index 0000000..e056f73
--- /dev/null
+++ b/gui/wato/check_parameters/agent_vzlogger.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : vzlogger.py
+#
+# https://wiki.volkszaehler.org/software/controller/vzlogger
+#
+# 2023-08-05: initial release
+
+from cmk.gui.i18n import _
+from cmk.gui.valuespec import (
+    Dictionary,
+    ValueSpec,
+    Integer,
+    FixedValue,
+)
+
+from cmk.gui.plugins.wato.utils import (
+    HostRulespec,
+    rulespec_registry,
+)
+from cmk.gui.plugins.wato.special_agents.common import RulespecGroupDatasourceProgramsApps
+
+
+def _valuespec_special_agent_vzlogger() -> ValueSpec:
+    return Dictionary(
+        title=_("vzlogger"),
+        elements=[
+            ('port', Integer(title=_('Port'), default_value=8081, minvalue=1, maxvalue=65565)),
+            ('timeout', Integer(title=_('Connection timeout'), default_value=5, minvalue=1, maxvalue=59)),
+            ('use_ip_address', FixedValue(True, title=_('Use IP-Address instead of hostname'), totext='')),
+            ('testing', FixedValue(True, title=_('Use test data only'), totext='')),
+        ],
+    )
+
+
+rulespec_registry.register(
+    HostRulespec(
+        group=RulespecGroupDatasourceProgramsApps,
+        name="special_agents:vzlogger",
+        valuespec=_valuespec_special_agent_vzlogger,
+    )
+)
diff --git a/gui/wato/check_parameters/vzlogger.py b/gui/wato/check_parameters/vzlogger.py
new file mode 100644
index 0000000..b4601d4
--- /dev/null
+++ b/gui/wato/check_parameters/vzlogger.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : vzlogger.py
+#
+# vzlogger
+#
+#
+# https://wiki.volkszaehler.org/software/controller/vzlogger
+#
+# 2023-08-05: initial release
+
+from cmk.gui.i18n import _
+from cmk.gui.valuespec import (
+    Dictionary,
+    ListOf,
+    TextUnicode,
+    Tuple,
+    DropdownChoice,
+    Integer,
+    TextAscii,
+)
+
+from cmk.gui.plugins.wato.utils import (
+    HostRulespec,
+    rulespec_registry,
+    RulespecGroupCheckParametersDiscovery,
+    CheckParameterRulespecWithItem,
+    RulespecGroupCheckParametersApplications,
+    Levels,
+)
+
+
+def _parameter_valuespec_vzlogger():
+    return Dictionary(
+        title=_('vzlogger'),
+        elements=[
+            ('levels_upper',
+             Levels(
+                 title=_('Levels (upper)'),
+                 help=_('This sets the upper limits for the vzlogger channel.'),
+                 unit=_(''),
+             )),
+            ('levels_lower',
+             Tuple(
+                 title=_('Levels lower'),
+                 help=_('This sets the lower limits for the vzlogger channel. '
+                        'This will only be used if "Levels (upper)" is not using Predictive Levels.'
+                        ),
+                 elements=[
+                     Integer(title=_('Warning blow'), minvalue=0, unit=_('')),
+                     Integer(title=_('Critical below'), minvalue=0, unit=_('')),
+                 ]))
+
+        ],
+        optional_keys=False,
+    )
+
+
+rulespec_registry.register(
+    CheckParameterRulespecWithItem(
+        check_group_name="vzlogger",
+        group=RulespecGroupCheckParametersApplications,
+        parameter_valuespec=_parameter_valuespec_vzlogger,
+        item_spec=lambda: TextAscii(title=_('vzlogger item')),
+    ))
+
+
+def _parameter_valuespec_discovery_vzlogger():
+    return Dictionary(
+        title=_('vzlogger'),
+        elements=[
+            ('channels',
+             ListOf(
+                 Tuple(
+                     orientation='horizontal',
+                     elements=[
+                         TextUnicode(
+                             title=_('UUID'),
+                             help=_(
+                                 'Value must (or better should) match the pattern '
+                                 '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$'
+                                 '.'
+                             ),
+                             allow_empty=False,
+                             size=36,
+                         ),
+                         DropdownChoice(
+                             title=_('Channel type'),
+                             help=_('Defines what type of counter the channel delivers.'),
+                             choices=[
+                                 ('meter_reading', 'Meter reading'),
+                                 ('voltage', 'Voltage (V)'),
+                                 ('electrical_power', 'Electrical Power (W)'),
+                             ],
+                             sorted=True,
+                         ),
+                         TextUnicode(
+                             title=_('Service name (item)'),
+                             help=_('Name of the service in CMK. Must be unique within the channel type.'),
+                             allow_empty=False,
+                             size=30,
+                         ),
+                     ]),
+                 add_label=_('Add channel'),
+                 movable=False,
+                 title=_('Channels'),
+             )),
+        ],
+    )
+
+
+rulespec_registry.register(
+    HostRulespec(
+        group=RulespecGroupCheckParametersDiscovery,
+        match_type='dict',
+        name='discovery_vzlogger',
+        valuespec=_parameter_valuespec_discovery_vzlogger,
+    ))
diff --git a/lib/python3/cmk/special_agents/agent_vzlogger.py b/lib/python3/cmk/special_agents/agent_vzlogger.py
new file mode 100644
index 0000000..caa9922
--- /dev/null
+++ b/lib/python3/cmk/special_agents/agent_vzlogger.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# License: GNU General Public License v2
+
+# Author: thl-cmk[at]outlook[dot]com
+# URL   : https://thl-cmk.hopto.org
+# Date  : 2023-08-05
+# File  : vzlogger.py
+#
+# vzlogger
+#
+#
+# https://wiki.volkszaehler.org/software/controller/vzlogger
+#
+# 2023-08-05: initial release
+
+# from __future__ import annotations
+
+import requests
+import json
+import argparse
+from os import environ
+# import logging
+from collections.abc import Sequence
+
+
+from cmk.special_agents.utils.agent_common import (
+    special_agent_main,
+)
+from cmk.special_agents.utils.argument_parsing import Args, create_default_argument_parser
+
+
+def parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace:
+    parser = create_default_argument_parser(description=__doc__)
+    parser.add_argument('hostname', type=str)
+    parser.add_argument('--port', type=int, default=8081)
+    parser.add_argument('--timeout', type=int, default=5)
+    parser.add_argument('--testing', const=True, required=False, action='store_const')
+    # parser.add_argument('--proxy', type=str)
+    return parser.parse_args(argv)
+
+
+def agent_vzlogger_main(args: Args) -> int:  # , retrurn=None
+    _vars = vars(args)
+    _base_dir = environ['OMD_ROOT']
+    vzlogger_file = f'{_base_dir}/vzlogger.json'
+
+    if _vars['testing']:
+        try:
+            with open(vzlogger_file, 'r') as file:
+                data = json.load(file)
+
+        except (FileNotFoundError, json.decoder.JSONDecodeError) as e:
+            print('Error retrieving data:', e)
+            exit()
+    else:
+        try:
+            response = requests.get(f'http://{_vars["hostname"]}:{_vars["port"]}/', timeout=_vars['timeout'])
+            response.raise_for_status()
+        except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e:
+            print('Error retrieving data:', e)
+            exit()
+        else:
+            data = response.json()
+
+    print('<<<vzlogger:sep(0)>>>')
+    print(json.dumps(data))
+    return 0
+
+
+def main() -> int:
+    return special_agent_main(parse_arguments, agent_vzlogger_main)
diff --git a/packages/vzlogger b/packages/vzlogger
new file mode 100644
index 0000000..968c233
--- /dev/null
+++ b/packages/vzlogger
@@ -0,0 +1,17 @@
+{'author': 'Th.L. (thl-cmk[at]outlook[dot]com)',
+ 'description': 'Special agent for vzlogger (prototype)\n'
+                '\n'
+                ' https://wiki.volkszaehler.org/software/controller/vzlogger\n',
+ 'download_url': 'https://thl-cmk.hopto.org',
+ 'files': {'agent_based': ['vzlogger.py'],
+           'agents': ['special/agent_vzlogger'],
+           'checks': ['agent_vzlogger'],
+           'gui': ['wato/check_parameters/agent_vzlogger.py',
+                   'wato/check_parameters/vzlogger.py'],
+           'lib': ['python3/cmk/special_agents/agent_vzlogger.py']},
+ 'name': 'vzlogger',
+ 'title': 'vzlogger',
+ 'version': '0.0.1-230806',
+ 'version.min_required': '2.2.0b1',
+ 'version.packaged': '2.2.0p7',
+ 'version.usable_until': None}
diff --git a/vzlogger-0.0.1-230806.mkp b/vzlogger-0.0.1-230806.mkp
new file mode 100644
index 0000000000000000000000000000000000000000..b06dfe620601b8008feb3874175f8322301b7e7b
GIT binary patch
literal 4681
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

literal 0
HcmV?d00001

-- 
GitLab