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