commit e1e03ba73f6effe065247644a62020c34dac10e5 Author: gerviba Date: Sun Nov 1 18:33:58 2026 +0100 Initial commit diff --git a/.gitea/runner-config.yaml b/.gitea/runner-config.yaml new file mode 100644 index 0000000..dffdbf6 --- /dev/null +++ b/.gitea/runner-config.yaml @@ -0,0 +1,3 @@ +container: + valid_volumes: + - /home/clustermaster/storage/cdn/serve/internal/probe diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e6911f4 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build probe jar + +on: + push: + branches: + - main + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + container: + image: gradle:8.8.0-jdk21 + volumes: + - /home/clustermaster/storage/cdn/serve/internal/probe:/output + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build shadow jar + run: | + chmod +x ./gradlew + ./gradlew build --no-daemon + + - name: Publish jar + run: cp build/libs/probe-all.jar /output/probe-all.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6888157 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +build/ +.kotlin/ +.gradle/ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5fc55c6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + kotlin("jvm") version "2.0.20" + kotlin("plugin.serialization") version "2.0.20" + id("com.github.johnrengelman.shadow") version "7.0.0" + id("io.ktor.plugin") version "3.0.0" + application +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.fasterxml.jackson.core:jackson-databind:2.22.4") + implementation("com.fasterxml.jackson.core:jackson-core:2.22.3") + implementation("com.github.docker-java:docker-java:3.4.0") + implementation("com.github.docker-java:docker-java-transport-httpclient5:3.4.0") + implementation("io.ktor:ktor-server-core") + implementation("io.ktor:ktor-server-netty") + implementation("io.ktor:ktor-server-content-negotiation") + implementation("io.ktor:ktor-client-core") + implementation("io.ktor:ktor-client-cio") + implementation("io.ktor:ktor-client-content-negotiation") + implementation("io.ktor:ktor-serialization-kotlinx-json") + implementation("org.slf4j:slf4j-api:2.0.16") + implementation("ch.qos.logback:logback-classic:1.6.3") +} + +application { + mainClass.set("hu.gerviba.probe.ProbeApplicationKt") +} \ No newline at end of file diff --git a/custom_components/ha_probe/__init__.py b/custom_components/ha_probe/__init__.py new file mode 100644 index 0000000..1fbdcc2 --- /dev/null +++ b/custom_components/ha_probe/__init__.py @@ -0,0 +1,85 @@ +"""The HA Probe integration. + +Receives pushes from a gerviba/systemmonitor probe instance over a webhook and +turns them into sensors. See hu.gerviba.probe.api.ApiService on the probe side +for the wire format: a POST of {"devices": [...], "entities": [...]} to +/api/webhook/. +""" + +from __future__ import annotations + +import logging + +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_WEBHOOK_ID, DOMAIN, PLATFORMS, HaProbeRuntimeData, signal_entities + +_LOGGER = logging.getLogger(__name__) + +type HaProbeConfigEntry = ConfigEntry[HaProbeRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> bool: + """Set up a HA Probe config entry.""" + entry.runtime_data = HaProbeRuntimeData() + webhook_id = entry.data[CONF_WEBHOOK_ID] + + async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response: + """Handle an incoming push from the probe.""" + try: + payload = await request.json() + except ValueError: + _LOGGER.warning("Received invalid JSON on the HA Probe webhook") + return Response(status=400) + + if not isinstance(payload, dict): + _LOGGER.warning("Received an unexpected HA Probe webhook payload") + return Response(status=400) + + for device in payload.get("devices") or (): + ha_id = device.get("haId") + display_name = device.get("haDisplayName") + if ha_id and display_name: + entry.runtime_data.devices[ha_id] = display_name + + entities = payload.get("entities") or () + if entities: + async_dispatcher_send(hass, signal_entities(entry.entry_id), entities) + + return Response(status=200) + + webhook.async_register( + hass, + DOMAIN, + entry.title, + webhook_id, + handle_webhook, + local_only=True, + allowed_methods=[METH_POST], + ) + + @callback + def _unregister_webhook() -> None: + webhook.async_unregister(hass, webhook_id) + + entry.async_on_unload(_unregister_webhook) + + entry.runtime_data.webhook_url = webhook.async_generate_url(hass, webhook_id) + _LOGGER.info("HA Probe webhook URL for %s: %s", entry.title, entry.runtime_data.webhook_url) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> bool: + """Unload a HA Probe config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/ha_probe/config_flow.py b/custom_components/ha_probe/config_flow.py new file mode 100644 index 0000000..7384480 --- /dev/null +++ b/custom_components/ha_probe/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for the HA Probe integration.""" + +from __future__ import annotations + +import re +from typing import Any + +import probatio + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME + +from .const import CONF_WEBHOOK_ID, DOMAIN + +WEBHOOK_ID_PATTERN = re.compile(r"^[a-z0-9_-]+$") + + +class HaProbeConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for HA Probe.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step.""" + errors: dict[str, str] = {} + + if user_input is not None: + webhook_id = user_input[CONF_WEBHOOK_ID].strip() + if not WEBHOOK_ID_PATTERN.match(webhook_id): + errors[CONF_WEBHOOK_ID] = "invalid_webhook_id" + elif self._webhook_id_in_use(webhook_id): + errors[CONF_WEBHOOK_ID] = "webhook_id_in_use" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + data_schema = probatio.Schema( + { + probatio.Required(CONF_NAME, default="HA Probe"): str, + probatio.Required( + CONF_WEBHOOK_ID, default=webhook.async_generate_id() + ): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + def _webhook_id_in_use(self, webhook_id: str) -> bool: + return any( + entry.data.get(CONF_WEBHOOK_ID) == webhook_id + for entry in self._async_current_entries() + ) diff --git a/custom_components/ha_probe/const.py b/custom_components/ha_probe/const.py new file mode 100644 index 0000000..f3009e0 --- /dev/null +++ b/custom_components/ha_probe/const.py @@ -0,0 +1,30 @@ +"""Constants for the HA Probe integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.const import Platform + +if TYPE_CHECKING: + from .sensor import HaProbeSensor + +DOMAIN = "ha_probe" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_WEBHOOK_ID = "webhook_id" + + +def signal_entities(entry_id: str) -> str: + """Return the dispatcher signal used to deliver a webhook payload's entities.""" + return f"{DOMAIN}_{entry_id}_entities" + + +@dataclass +class HaProbeRuntimeData: + """Runtime state for a configured probe webhook, kept on ConfigEntry.runtime_data.""" + + webhook_url: str = "" + devices: dict[str, str] = field(default_factory=dict) + entities: dict[str, "HaProbeSensor"] = field(default_factory=dict) diff --git a/custom_components/ha_probe/manifest.json b/custom_components/ha_probe/manifest.json new file mode 100644 index 0000000..cd4bfdb --- /dev/null +++ b/custom_components/ha_probe/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ha_probe", + "name": "HA Probe", + "version": "2.0.0", + "integration_type": "device", + "iot_class": "local_push", + "dependencies": ["webhook"], + "codeowners": [], + "requirements": [], + "config_flow": true +} diff --git a/custom_components/ha_probe/sensor.py b/custom_components/ha_probe/sensor.py new file mode 100644 index 0000000..f756b97 --- /dev/null +++ b/custom_components/ha_probe/sensor.py @@ -0,0 +1,293 @@ +"""Sensor platform for the HA Probe integration. + +Entities are created and updated dynamically from whatever the probe sends - +there is no static sensor list here. The probe only sends full metadata +(name/deviceClass/unit/device) once per entity, at startup; every later push +is just {"id": ..., "value"/"textValue": ...}, so an already-known entity is +updated in place and never re-created. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HaProbeConfigEntry +from .const import DOMAIN, signal_entities + +_LOGGER = logging.getLogger(__name__) + +# hu.gerviba.probe.util.UnitOfMeasurement, by Kotlin constant name. kotlinx.serialization +# encodes enums by their declared constant name (e.g. "MEGABYTES"), not the overridden +# toString() display symbol (e.g. "MB") - verified against the probe's actual webhook +# payload, so this is a name -> unit-symbol table, not the symbols themselves. +UNIT_MAP: dict[str, str] = { + "VOLT_AMPERE": "VA", + "WATT": "W", + "KILO_WATT": "kW", + "BTU_PER_HOUR": "BTU/h", + "VOLT_AMPERE_REACTIVE": "var", + "GIGA_JOULE": "GJ", + "KILO_WATT_HOUR": "kWh", + "MEGA_JOULE": "MJ", + "MEGA_WATT_HOUR": "MWh", + "WATT_HOUR": "Wh", + "MILLIAMPERE": "mA", + "AMPERE": "A", + "MILLIVOLT": "mV", + "VOLT": "V", + "CELSIUS": "°C", + "FAHRENHEIT": "°F", + "KELVIN": "K", + "MICROSECONDS": "μs", + "MILLISECONDS": "ms", + "SECONDS": "s", + "MINUTES": "min", + "HOURS": "h", + "DAYS": "d", + "WEEKS": "w", + "MONTHS": "m", + "YEARS": "y", + "MILLIMETERS": "mm", + "CENTIMETERS": "cm", + "METERS": "m", + "KILOMETERS": "km", + "INCHES": "in", + "FEET": "ft", + "YARDS": "yd", + "MILES": "mi", + "HERTZ": "Hz", + "KILOHERTZ": "kHz", + "MEGAHERTZ": "MHz", + "GIGAHERTZ": "GHz", + "PA": "Pa", + "HPA": "hPa", + "KPA": "kPa", + "BAR": "bar", + "CBAR": "cbar", + "MBAR": "mbar", + "MMHG": "mmHg", + "INHG": "inHg", + "PSI": "psi", + "DECIBEL": "dB", + "WEIGHTED_DECIBEL_A": "dBA", + "CUBIC_FEET": "ft³", + "CENTUM_CUBIC_FEET": "CCF", + "CUBIC_METERS": "m³", + "LITERS": "L", + "MILLILITERS": "mL", + "GALLONS": "gal", + "FLUID_OUNCES": "fl. oz.", + "CUBIC_METERS_PER_HOUR": "m³/h", + "CUBIC_FEET_PER_MINUTE": "ft³/m", + "GRAMS": "g", + "KILOGRAMS": "kg", + "MILLIGRAMS": "mg", + "MICROGRAMS": "µg", + "OUNCES": "oz", + "POUNDS": "lb", + "STONES": "st", + "WATTS_PER_SQUARE_METER": "W/m²", + "BTUS_PER_HOUR_SQUARE_FOOT": "BTU/(h⋅ft²)", + "INCHES_PER_DAY": "in/d", + "INCHES_PER_HOUR": "in/h", + "MILLIMETERS_PER_DAY": "mm/d", + "MILLIMETERS_PER_HOUR": "mm/h", + "PRECIPITATION_INCHES": "in", + "PRECIPITATION_MILLIMETERS": "mm", + "PRECIPITATION_CENTIMETERS": "cm", + "FEET_PER_SECOND": "ft/s", + "METERS_PER_SECOND": "m/s", + "KILOMETERS_PER_HOUR": "km/h", + "KNOTS": "kn", + "MILES_PER_HOUR": "mph", + "BITS": "bit", + "KILOBITS": "kbit", + "MEGABITS": "Mbit", + "GIGABITS": "Gbit", + "BYTES": "B", + "KILOBYTES": "kB", + "MEGABYTES": "MB", + "GIGABYTES": "GB", + "TERABYTES": "TB", + "PETABYTES": "PB", + "EXABYTES": "EB", + "ZETTABYTES": "ZB", + "YOTTABYTES": "YB", + "KIBIBYTES": "KiB", + "MEBIBYTES": "MiB", + "GIBIBYTES": "GiB", + "TEBIBYTES": "TiB", + "PEBIBYTES": "PiB", + "EXBIBYTES": "EiB", + "ZEBIBYTES": "ZiB", + "YOBIBYTES": "YiB", + "BITS_PER_SECOND": "bit/s", + "KILOBITS_PER_SECOND": "kbit/s", + "MEGABITS_PER_SECOND": "Mbit/s", + "GIGABITS_PER_SECOND": "Gbit/s", + "BYTES_PER_SECOND": "B/s", + "KILOBYTES_PER_SECOND": "kB/s", + "MEGABYTES_PER_SECOND": "MB/s", + "GIGABYTES_PER_SECOND": "GB/s", + "KIBIBYTES_PER_SECOND": "KiB/s", + "MEBIBYTES_PER_SECOND": "MiB/s", + "GIBIBYTES_PER_SECOND": "GiB/s", + "DEGREE": "°", + "CURRENCY_EURO": "€", + "CURRENCY_DOLLAR": "$", + "CURRENCY_CENT": "¢", + "PERCENTAGE": PERCENTAGE, + "AREA_SQUARE_METERS": "m²", + "CONDUCTIVITY": "µS/cm", + "LIGHT_LUX": "lx", + "UV_INDEX": "UV index", + "REVOLUTIONS_PER_MINUTE": "rpm", + "SIGNAL_STRENGTH_DECIBELS": "dB", + "SIGNAL_STRENGTH_DECIBELS_MILLIWATT": "dBm", + "CONCENTRATION_MICROGRAMS_PER_CUBIC_METER": "µg/m³", + "CONCENTRATION_PARTS_PER_MILLION": "ppm", + "CONCENTRATION_PARTS_PER_BILLION": "ppb", +} + + +def _resolve_unit(unit_name: str | None) -> str | None: + """Map a probe UnitOfMeasurement constant name to its HA unit symbol.""" + if unit_name is None: + return None + unit = UNIT_MAP.get(unit_name) + if unit is None: + _LOGGER.warning("Unknown HA Probe unit '%s'", unit_name) + return unit + + +def _resolve_device_class(device_class_name: str | None) -> SensorDeviceClass | None: + """Map a probe SensorDeviceClass constant name to HA's enum member.""" + if device_class_name is None: + return None + device_class = SensorDeviceClass.__members__.get(device_class_name) + if device_class is None: + _LOGGER.warning("Unknown HA Probe device class '%s'", device_class_name) + return device_class + + +def _fallback_name(entity_id: str) -> str: + """Make a readable name out of an id when no schema metadata is known yet.""" + return entity_id.rsplit("__", 1)[-1].replace("_", " ").title() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HaProbeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up HA Probe sensors for a config entry.""" + runtime_data = entry.runtime_data + + @callback + def handle_entities(entities: list[dict[str, Any]]) -> None: + new_sensors: list[HaProbeSensor] = [] + + for entity in entities: + entity_id = entity.get("id") + if not entity_id: + continue + + text_value = entity.get("textValue") + state = text_value if text_value is not None else entity.get("value") + + existing = runtime_data.entities.get(entity_id) + if existing is not None: + existing.handle_update(entity, state) + continue + + device_ha_id = entity.get("device") + device_name = ( + runtime_data.devices.get(device_ha_id, device_ha_id) + if device_ha_id + else entry.title + ) + + sensor = HaProbeSensor( + entry_id=entry.entry_id, + entity_key=entity_id, + device_ha_id=device_ha_id or "probe", + device_name=device_name, + initial_state=state, + name=entity.get("name") or _fallback_name(entity_id), + device_class=_resolve_device_class(entity.get("deviceClass")), + unit=_resolve_unit(entity.get("unit")), + ) + runtime_data.entities[entity_id] = sensor + new_sensors.append(sensor) + + if new_sensors: + async_add_entities(new_sensors) + + entry.async_on_unload( + async_dispatcher_connect(hass, signal_entities(entry.entry_id), handle_entities) + ) + + +class HaProbeSensor(SensorEntity): + """A sensor created dynamically from a probe webhook push.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + *, + entry_id: str, + entity_key: str, + device_ha_id: str, + device_name: str, + initial_state: Any, + name: str, + device_class: SensorDeviceClass | None, + unit: str | None, + ) -> None: + """Initialize the sensor from its first-seen webhook entity.""" + self._attr_unique_id = f"{entry_id}_{entity_key}" + self._attr_name = name + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit + self._attr_native_value = initial_state + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_{device_ha_id}")}, + name=device_name, + manufacturer="gerviba/systemmonitor", + model="Probe", + ) + + @callback + def handle_update(self, entity: dict[str, Any], state: Any) -> None: + """Apply a later webhook push for this already-known entity. + + Usually this is just a bare value/textValue push, but if the probe + restarted it will resend full metadata too - pick that back up + instead of only ever trusting the first value we saw. + """ + self._attr_native_value = state + + name = entity.get("name") + if name: + self._attr_name = name + + device_class_name = entity.get("deviceClass") + if device_class_name: + self._attr_device_class = _resolve_device_class(device_class_name) + + unit_name = entity.get("unit") + if unit_name: + self._attr_native_unit_of_measurement = _resolve_unit(unit_name) + + if self.hass is not None: + self.async_write_ha_state() diff --git a/custom_components/ha_probe/translations/en.json b/custom_components/ha_probe/translations/en.json new file mode 100644 index 0000000..3601983 --- /dev/null +++ b/custom_components/ha_probe/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "title": "HA Probe", + "description": "Set up a webhook to receive pushes from a systemmonitor probe. Put this webhook ID into the probe's config.json as \"webhookToken\".", + "data": { + "name": "Integration Name", + "webhook_id": "Webhook ID" + } + } + }, + "errors": { + "invalid_webhook_id": "Invalid webhook ID. Use lowercase letters, numbers, underscores, or hyphens.", + "webhook_id_in_use": "Webhook ID is already in use." + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b740cf1 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7101f8e --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt b/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt new file mode 100644 index 0000000..53b84e5 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/ConfigLoader.kt @@ -0,0 +1,238 @@ +package hu.gerviba.probe + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.TimeUnit + +enum class SchedulerFrequency( + val period: Long, + val timeUnit: TimeUnit +) { + EVERY_30SEC(30, TimeUnit.SECONDS), + EVERY_MINUTE(1, TimeUnit.MINUTES), + EVERY_10MINUTES(10, TimeUnit.MINUTES), + EVERY_30MINUTES(10, TimeUnit.MINUTES), + EVERY_HOUR(1, TimeUnit.HOURS), + EVERY_6HOURS(6, TimeUnit.HOURS), + EVERY_12HOURS(12, TimeUnit.HOURS), + EVERY_DAY(1, TimeUnit.DAYS), +} + +@Serializable +data class SensorRecord( + val type: String, + val name: String, + val args: List = listOf(), + val haId: String, + val haDisplayName: String, + val frequency: SchedulerFrequency, + val device: String = "default" +) + +@Serializable +data class DeviceRecord( + val haId: String, + val haDisplayName: String, +) + +@Serializable +data class ServiceRecord( + val type: String, + val name: String, + val command: List = listOf(), + val waitFor: Boolean = false, +) + +@Serializable +data class ConfigRecord( + val haId: String, + val haDisplayName: String, + val webServerEnabled: Boolean = false, + val webServerToken: String? = null, + val webServerPort: Int? = null, + val haUrl: String = "http://127.0.0.1:5123", + val webhookToken: String? = null, + val measurements: List = listOf(), + val devices: List = listOf(), + val services: List = listOf(), +) + +class ConfigLoader { + + private val log = LoggerFactory.getLogger(ConfigLoader::class.java) + + fun loadConfig(): ConfigRecord { + val fileName = "config.json" + val file = File(System.getProperty("config.path", "."), fileName) + + if (file.exists()) { + val jsonContent = file.readText() + try { + val config = Json.decodeFromString(jsonContent) + log.info("Data loaded successfully") + return config + + } catch (e: SerializationException) { + log.error("Error deserializing JSON: ${e.message}") + return defaultConfig() + } + } else { + log.error("File '$fileName' does not exist in the working directory. Using default config.") + return defaultConfig() + } + } + + private fun defaultConfig(): ConfigRecord { + return ConfigRecord( + haId = "hacluster", + haDisplayName = "HaCluster", + webServerEnabled = true, + measurements = listOf( + SensorRecord( + type = "nvidia", + name = "NVIDIA GeForce RTX 4090", + args = listOf(), + haId = "nvidia_4090", + haDisplayName = "Nvidia RTX 4090 OC", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "folder", + name = "/home/gerviba/workspaces/systemmonitor", + args = listOf(), + haId = "systemonitor", + haDisplayName = "SystemMonitor folder size", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "folder", + name = "/home/gerviba/workspaces", + args = listOf(), + haId = "workspaces", + haDisplayName = "Workspaces folder size", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "disc", + name = "", + args = listOf(), + haId = "disc", + haDisplayName = "HaCluster disc", + frequency = SchedulerFrequency.EVERY_HOUR, + ), + SensorRecord( + type = "sensor", + name = "coretemp-isa-0000.Package id 0", + args = listOf(), + haId = "cpu-temperature", + haDisplayName = "Cpu temperature", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "cpu", + args = listOf(), + haId = "cpu", + haDisplayName = "Cpu utilization", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "ram", + args = listOf(), + haId = "memory", + haDisplayName = "Memory utilization", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "swap", + args = listOf(), + haId = "swap", + haDisplayName = "Swap utilization", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "uptime", + args = listOf(), + haId = "uptime", + haDisplayName = "Uptime", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "load1", + args = listOf(), + haId = "load1", + haDisplayName = "Load average 1m", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "load5", + args = listOf(), + haId = "load5", + haDisplayName = "Load average 5m", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "utilization", + name = "load15", + args = listOf(), + haId = "load15", + haDisplayName = "Load average 15m", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "docker", + name = "naughty_rosalind", + args = listOf(), + haId = "nginx-test", + haDisplayName = "Nginx Test", + frequency = SchedulerFrequency.EVERY_MINUTE, + ), + SensorRecord( + type = "event", + name = "backup_success", + args = listOf(), + haId = "backup_success", + haDisplayName = "Last successful backup", + frequency = SchedulerFrequency.EVERY_DAY, + ), + SensorRecord( + type = "event", + name = "backup_error", + args = listOf(), + haId = "backup_error", + haDisplayName = "Last failed backup", + frequency = SchedulerFrequency.EVERY_DAY, + ), + ), + devices = listOf( + DeviceRecord( + haId = "default", + haDisplayName = "HaCluster" + ) + ), + services = listOf( + ServiceRecord( + type = "cmd", + name = "notify1", + command = listOf("notify-send", "'Hello world!'", "'This is an example notification.'", "--icon=dialog-information"), + waitFor = true + ), + ServiceRecord( + type = "cmd", + name = "notify2", + command = listOf("notify-send", "'Hello world!'", "'This is an example notification.'", "--icon=dialog-warning"), + waitFor = true + ) + ) + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt b/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt new file mode 100644 index 0000000..31e3572 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/ProbeApplication.kt @@ -0,0 +1,99 @@ +package hu.gerviba.probe + +import hu.gerviba.probe.api.ApiService +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.sensor.* +import hu.gerviba.probe.service.RunCommandActionService +import hu.gerviba.probe.service.ServerService +import hu.gerviba.probe.service.Service +import org.slf4j.LoggerFactory +import kotlin.system.exitProcess + +fun main(args: Array) { + if (args.getOrNull(0)?.equals("event", ignoreCase = true) == true) { + val eventName = args.getOrNull(1) ?: run { + println("Usage: probe event [state]") + exitProcess(1) + } + sendEvent(eventName, args.getOrNull(2)) + return + } + + val probeApplication = ProbeApplication() + probeApplication.configure() + probeApplication.printAvailable() + probeApplication.setupSensors() + probeApplication.setupScheduler() + probeApplication.setupServices() + probeApplication.start() +} + +class ProbeApplication { + + private val log = LoggerFactory.getLogger(ProbeApplication::class.java) + + private val sensors = mutableListOf() + private var webServer: ServerService? = null + + private lateinit var config: ConfigRecord + private lateinit var apiService: ApiService + private lateinit var scheduler: SchedulerService + private lateinit var basicSchema: List>> + + fun configure() { + val configLoader = ConfigLoader() + config = configLoader.loadConfig() + apiService = ApiService(config) + + sensors.addAll(listOf( + FolderSizeSensor(config), + DeviceDiscSensor(config), + NvidiaGpuSensor(config), + PcTemperatureSensor(config), + PcUtilizationSensor(config), + DockerSensor(config, apiService), + EventSensor(config), + HeartbeatSensor(config), + )) + } + + fun printAvailable() { + log.info("Available measurements:") + sensors.forEach { sensor -> + log.info(" # type:${sensor.name}") + sensor.getAvailableNames().forEach { name -> log.info(" - $name") } + } + log.info("") + } + + fun setupSensors() { + sensors.forEach { it.onConfig() } + basicSchema = sensors.map { it to it.getMeasurementSchemas() } + log.info("Sending schemas") + apiService.updateMeasurements(basicSchema.flatMap { it.second }) + log.info("") + } + + fun setupScheduler() { + log.info("Initializing scheduler") + scheduler = SchedulerService( + sensors = basicSchema.filter { it.second.isNotEmpty() }.map { it.first }, + apiService = apiService + ) + scheduler.justPrint() + } + + fun setupServices() { + if (config.webServerEnabled) { + val services = listOf( + RunCommandActionService(config) + ) + webServer = ServerService(config, services).also { it.start() } + } + } + + fun start() { + scheduler.schedule() + } + +} diff --git a/src/main/kotlin/hu/gerviba/probe/ProbeEventApplication.kt b/src/main/kotlin/hu/gerviba/probe/ProbeEventApplication.kt new file mode 100644 index 0000000..ba9f80c --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/ProbeEventApplication.kt @@ -0,0 +1,23 @@ +package hu.gerviba.probe + +import hu.gerviba.probe.api.ApiService +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.system.exitProcess + +fun sendEvent(eventName: String, state: String?) { + val config = ConfigLoader().loadConfig() + val apiService = ApiService(config) + val measurementConfig = config.measurements.firstOrNull { it.type == "event" && it.name == eventName } + ?: run { + println("No configured event found with name $eventName") + exitProcess(1) + } + + apiService.updateMeasurement(Measurement( + id = ProbeUtil.formatId(config.haId, "event", measurementConfig.haId), + textValue = state ?: LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME), + )) +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/SchedulerService.kt b/src/main/kotlin/hu/gerviba/probe/SchedulerService.kt new file mode 100644 index 0000000..bcaada3 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/SchedulerService.kt @@ -0,0 +1,41 @@ +package hu.gerviba.probe + +import hu.gerviba.probe.api.ApiService +import hu.gerviba.probe.sensor.DockerSensor +import hu.gerviba.probe.sensor.Sensor +import org.slf4j.LoggerFactory +import java.util.concurrent.Executors + +class SchedulerService( + private val sensors: List, + private val apiService: ApiService +) { + + private val log = LoggerFactory.getLogger(SchedulerService::class.java) + private val scheduler = Executors.newSingleThreadScheduledExecutor() + + fun schedule() { + val initialDelay = 0L + + SchedulerFrequency.entries + .filter { frequency -> sensors.any { it.hasMeasurement(frequency) } } + .forEach { frequency -> + log.info("Scheduling for update frequency: {}", frequency) + scheduler.scheduleAtFixedRate({ + log.info("Executing measurement refreshes task: {}", frequency) + apiService.updateMeasurements(sensors.flatMap { it.getMeasurements(frequency) }) + }, initialDelay, frequency.period, frequency.timeUnit) + } + } + + fun shutdown() { + scheduler.shutdown() + } + + fun justPrint() { + SchedulerFrequency.entries.forEach { frequency -> + apiService.updateMeasurements(sensors.flatMap { it.getMeasurements(frequency) }) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/api/ActionRequest.kt b/src/main/kotlin/hu/gerviba/probe/api/ActionRequest.kt new file mode 100644 index 0000000..8168a18 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/api/ActionRequest.kt @@ -0,0 +1,10 @@ +package hu.gerviba.probe.api + +import kotlinx.serialization.Serializable + +@Serializable +data class ActionRequest( + val type: String, + val action: String, + val args: List = listOf() +) \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/api/ApiRequest.kt b/src/main/kotlin/hu/gerviba/probe/api/ApiRequest.kt new file mode 100644 index 0000000..6a79c23 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/api/ApiRequest.kt @@ -0,0 +1,10 @@ +package hu.gerviba.probe.api + +import hu.gerviba.probe.DeviceRecord +import kotlinx.serialization.Serializable + +@Serializable +data class ApiRequest( + val devices: List, + val entities: List, +) \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt b/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt new file mode 100644 index 0000000..5a79f08 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/api/ApiService.kt @@ -0,0 +1,61 @@ +package hu.gerviba.probe.api + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.DeviceRecord +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +class ApiService( + private val config: ConfigRecord +) { + + private val log = LoggerFactory.getLogger(ApiService::class.java) + + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + prettyPrint = false + isLenient = true + ignoreUnknownKeys = true + }) + } + } + + fun updateMeasurement(measurement: Measurement) { + updateMeasurements(listOf(measurement)) + } + + fun updateMeasurements(measurements: List) { + updateMeasurements(config.devices, measurements) + } + + fun updateMeasurements(devices: List, measurements: List) { + log.info("sending measurements:") + measurements.forEach { measurement -> log.info(measurement.toString()) } + sendPostRequestToWebhook(devices, measurements) + } + + fun sendPostRequestToWebhook(devices: List, entities: List) = runBlocking { + try { + val response: HttpResponse = client.post("${config.haUrl}/api/webhook/${config.webhookToken}") { + contentType(ContentType.Application.Json) + setBody(ApiRequest(devices, entities)) + } + + println("Response status: ${response.status}") + println("Response body: ${response.bodyAsText()}") + } catch (e: Exception) { + println("Error occurred: ${e.message}") + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/api/Measurement.kt b/src/main/kotlin/hu/gerviba/probe/api/Measurement.kt new file mode 100644 index 0000000..249df86 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/api/Measurement.kt @@ -0,0 +1,16 @@ +package hu.gerviba.probe.api + +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import kotlinx.serialization.Serializable + +@Serializable +data class Measurement( + val id: String, + val name: String? = null, + val deviceClass: SensorDeviceClass? = null, + val unit: UnitOfMeasurement? = null, + val value: Double? = null, + val textValue: String? = null, + val device: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/DeviceDiscSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/DeviceDiscSensor.kt new file mode 100644 index 0000000..04c6ea0 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/DeviceDiscSensor.kt @@ -0,0 +1,108 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.sensor.DeviceDiscUtil.getTotalAndUsed +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +private const val GIGABYTES = 1024 * 1024 * 1024 + +class DeviceDiscSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + DeviceDiscFactory( + idPrefix = ProbeUtil.formatId(config.haId, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + override val name: String + get() = "disc" + + override fun getAvailableNames(): List { + return listOf("root") + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .flatMap { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + return sensors[trigger]?.flatMap { it.measure() } ?: listOf() + } + +} + +private class DeviceDiscFactory( + idPrefix: String, + private val haDisplayName: String, + val device: String, + val frequency: SchedulerFrequency +) { + + private val totalId = ProbeUtil.formatId(idPrefix, "total") + private val usedId = ProbeUtil.formatId(idPrefix, "used") + + fun getMeasurementSchema(): List { + return listOf( + Measurement( + id = totalId, + name = "$haDisplayName total", + deviceClass = SensorDeviceClass.VOLUME_STORAGE, + unit = UnitOfMeasurement.GIGABYTES, + device = device, + ), + Measurement( + id = usedId, + name = "$haDisplayName used", + deviceClass = SensorDeviceClass.VOLUME_STORAGE, + unit = UnitOfMeasurement.GIGABYTES, + device = device, + ) + ) + } + + fun measure(): Sequence { + val (total, used) = getTotalAndUsed() + return sequenceOf( + Measurement( + id = totalId, + value = total.toDouble() / GIGABYTES + ), + Measurement( + id = usedId, + value = used.toDouble() / GIGABYTES + ), + ) + } + +} + +private object DeviceDiscUtil { + + fun getTotalAndUsed(): Pair { + val root = File("/") + val totalSpace = root.totalSpace + val usedSpace = totalSpace - root.freeSpace + return Pair(totalSpace, usedSpace) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt new file mode 100644 index 0000000..13706e5 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/DockerSensor.kt @@ -0,0 +1,351 @@ +package hu.gerviba.probe.sensor + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.api.async.ResultCallbackTemplate +import com.github.dockerjava.api.model.Event +import com.github.dockerjava.api.model.EventType +import com.github.dockerjava.api.model.Statistics +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientImpl +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient +import com.github.dockerjava.transport.DockerHttpClient +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.ApiService +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +private const val START = "start" + +class DockerSensor( + config: ConfigRecord, + private val apiService: ApiService, +) : Sensor { + + private val log = LoggerFactory.getLogger(DockerSensor::class.java) + private val rawJsonMapper = ObjectMapper() + + private lateinit var dockerClient: DockerClient + private lateinit var httpClient: DockerHttpClient + private val runningContainers = ConcurrentHashMap() + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + DockerMeasurementFactory( + idPrefix = ProbeUtil.formatId(config.haId, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + name = measurementRecord.name, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + private val sensorByName = sensors.values.flatten().associateBy { it.name } + + override val name: String + get() = "docker" + + override fun getAvailableNames(): List { + return runningContainers.values.map { it.name ?: it.id } + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .flatMap { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + return runningContainers.values.flatMap { containerData -> + val sensor = sensorByName[containerData.name] + ?: return@flatMap emptySequence() + if (sensor.frequency != trigger) + return@flatMap emptySequence() + + val latch = CountDownLatch(1) + val statsCmd = dockerClient.statsCmd(containerData.id) + val success = AtomicBoolean(false) + + statsCmd.exec(object : StatsCallback() { + override fun onNext(stats: Statistics) { + val cpuUsage = calculateCpuUsage(stats) + val memoryUsage = stats.memoryStats?.usage ?: 0L + containerData.cpuUsage = cpuUsage + containerData.memoryUsage = memoryUsage + log.info("Container ${containerData.name}: CPU $cpuUsage%, Memory $memoryUsage bytes") + + close() + latch.countDown() + success.set(true) + } + + override fun onError(throwable: Throwable) { + super.onError(throwable) + // ignore errors + latch.countDown() + } + }) + latch.await(5, TimeUnit.SECONDS) + + return@flatMap if (success.get()) { + sensor.measureForPolledDetails(containerData) + } else { + sequenceOf() + } + } + } + + override fun onConfig() { + if (sensors.isEmpty()) + return + + val config = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + + httpClient = ApacheDockerHttpClient.Builder() + .dockerHost(config.dockerHost) + .sslConfig(config.sslConfig) + .build() + + dockerClient = DockerClientImpl.getInstance(config, httpClient) + + val containers = dockerClient.listContainersCmd().withStatusFilter(setOf("running")).exec() + for (container in containers) { + val containerId = container.id + val inspection = inspectContainer(containerId) + val data = ContainerData( + id = containerId, + name = container.names?.firstOrNull()?.removePrefix("/") ?: inspection?.name, + status = inspection?.status, + startedAt = inspection?.startedAt, + health = inspection?.health, + ) + runningContainers[containerId] = data + } + + startEventListening() + } + + /** + * Inspects a container using a raw HTTP call instead of [DockerClient.inspectContainerCmd]. + * Some Docker daemon versions report `HostConfig.CapAdd`/`CapDrop` with a `CAP_` prefix + * (e.g. "CAP_NET_ADMIN"), which docker-java's [com.github.dockerjava.api.model.Capability] + * enum does not accept, causing the typed inspect call to throw. We only need a handful of + * fields here, so we parse the response as plain JSON and sidestep that enum entirely. + */ + private fun inspectContainer(containerId: String): ContainerInspection? { + return try { + val request = DockerHttpClient.Request.builder() + .method("GET") + .path("/containers/$containerId/json") + .build() + httpClient.execute(request).use { response -> + val node = rawJsonMapper.readTree(response.body) + val state = node.get("State") + ContainerInspection( + name = node.get("Name")?.asText()?.removePrefix("/"), + status = state?.get("Status")?.asText(), + startedAt = state?.get("StartedAt")?.asText(), + health = state?.get("Health")?.get("Status")?.asText(), + ) + } + } catch (e: Exception) { + log.warn("Failed to inspect container {}: {}", containerId, e.message) + null + } + } + + private fun startEventListening() { + dockerClient.eventsCmd().exec(object : EventsResultCallback() { + override fun onNext(event: Event) { + handleEvent(event) + } + }) + } + + private fun handleEvent(event: Event) { + if (event.type == EventType.CONTAINER) { + val containerId = event.id ?: return + val action = event.action + when { + action == START -> { + val inspection = inspectContainer(containerId) + val data = ContainerData( + id = containerId, + name = inspection?.name, + status = inspection?.status, + startedAt = inspection?.startedAt, + health = inspection?.health, + ) + runningContainers[containerId] = data + log.info("Container started: ${data.name}") + sensorByName[data.name]?.measurementForState(data)?.let { + apiService.updateMeasurement(it) + } + sensorByName[data.name]?.measurementForHealth(data)?.let { + apiService.updateMeasurement(it) + } + } + action?.startsWith("health_status:") == true -> { + val data = runningContainers[containerId] + if (data != null) { + data.health = action.substringAfter(":").trim() + log.info("Container health event for ${data.name}: ${data.health}") + sensorByName[data.name]?.measurementForHealth(data)?.let { + apiService.updateMeasurement(it) + } + } + } + else -> { + val data = runningContainers[containerId] + if (data != null) { + log.info("Container event $action: ${data.name}") + data.status = action + sensorByName[data.name]?.measurementForState(data)?.let { + apiService.updateMeasurement(it) + } + } + } + } + } + } + + private fun calculateCpuUsage(stats: Statistics): Double { + val cpuDelta = (stats.cpuStats?.cpuUsage?.totalUsage ?: 0L) - (stats.preCpuStats?.cpuUsage?.totalUsage ?: 0L) + val systemCpuDelta = (stats.cpuStats?.systemCpuUsage ?: 0L) - (stats.preCpuStats?.systemCpuUsage ?: 0L) + val numberOfCpus = stats.cpuStats?.onlineCpus ?: 1L + + return if (systemCpuDelta > 0L && cpuDelta > 0L) { + (cpuDelta.toDouble() / systemCpuDelta.toDouble()) * numberOfCpus * 100.0 + } else { + 0.0 + } + } + + data class ContainerData( + val id: String, + val name: String?, + var status: String?, + val startedAt: String?, + var health: String? = null, + var cpuUsage: Double = 0.0, + var memoryUsage: Long = 0L + ) + + private data class ContainerInspection( + val name: String?, + val status: String?, + val startedAt: String?, + val health: String?, + ) + + abstract class EventsResultCallback : ResultCallbackTemplate() + abstract class StatsCallback : ResultCallbackTemplate() + +} + +private class DockerMeasurementFactory( + idPrefix: String, + val name: String, + private val haDisplayName: String, + val device: String, + val frequency: SchedulerFrequency +) { + + private val startedAt = ProbeUtil.formatId(idPrefix, "startedAt") + private val cpuUsage = ProbeUtil.formatId(idPrefix, "cpuUsage") + private val memoryUsage = ProbeUtil.formatId(idPrefix, "memoryUsage") + private val state = ProbeUtil.formatId(idPrefix, "state") + private val health = ProbeUtil.formatId(idPrefix, "health") + + fun getMeasurementSchema(): List { + return listOf( + Measurement( + id = startedAt, + name = "$haDisplayName started at", + deviceClass = SensorDeviceClass.TIMESTAMP, + device = device, + ), + Measurement( + id = cpuUsage, + name = "$haDisplayName cpu usage", + deviceClass = SensorDeviceClass.DATA_SIZE, + unit = UnitOfMeasurement.PERCENTAGE, + device = device, + ), + Measurement( + id = memoryUsage, + name = "$haDisplayName memory usage", + deviceClass = SensorDeviceClass.DATA_SIZE, + unit = UnitOfMeasurement.MEGABYTES, + device = device, + ), + Measurement( + id = state, + name = "$haDisplayName state", + deviceClass = SensorDeviceClass.ENUM, + unit = null, + device = device, + ), + Measurement( + id = health, + name = "$haDisplayName health", + deviceClass = SensorDeviceClass.ENUM, + unit = null, + device = device, + ) + ) + } + + fun measureForPolledDetails(container: DockerSensor.ContainerData): Sequence { + return sequenceOf( + Measurement( + id = startedAt, + textValue = container.startedAt + ), + Measurement( + id = cpuUsage, + value = container.cpuUsage + ), + Measurement( + id = memoryUsage, + value = container.memoryUsage.toDouble() + ), + Measurement( + id = health, + textValue = container.health + ), + ) + } + + fun measurementForState(container: DockerSensor.ContainerData): Measurement { + return Measurement( + id = state, + textValue = container.status + ) + } + + // Only populated for containers that define a Docker HEALTHCHECK; null otherwise. + fun measurementForHealth(container: DockerSensor.ContainerData): Measurement? { + return container.health?.let { + Measurement( + id = health, + textValue = it + ) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/EventSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/EventSensor.kt new file mode 100644 index 0000000..11a2dd2 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/EventSensor.kt @@ -0,0 +1,58 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass + +class EventSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + EventMeasurementFactory( + calculatedId = ProbeUtil.formatId(config.haId, name, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + device = measurementRecord.device, + ) + } + + override val name: String + get() = "event" + + override fun getAvailableNames(): List { + return listOf("any name you like, e.g. backup_success") + } + + override fun getMeasurementSchemas(): List { + return sensors.map { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + // Events are pushed on demand via `probe event `, never polled on a schedule. + return false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + return listOf() + } + +} + +private class EventMeasurementFactory( + private val calculatedId: String, + private val haDisplayName: String, + val device: String, +) { + + fun getMeasurementSchema() = Measurement( + id = calculatedId, + name = haDisplayName, + deviceClass = SensorDeviceClass.TIMESTAMP, + device = device, + ) + +} diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/FolderSizeSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/FolderSizeSensor.kt new file mode 100644 index 0000000..721db35 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/FolderSizeSensor.kt @@ -0,0 +1,99 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.sensor.FolderUtils.getFolderSize +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +private const val MEGABYTES = 1024 * 1024 + +class FolderSizeSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + FolderMeasurementFactory( + calculatedId = ProbeUtil.formatId(config.haId, name, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + path = measurementRecord.name, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + override val name: String + get() = "folder" + + override fun getAvailableNames(): List { + return listOf("/home/user", "/any/path/you/like") + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .map { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + return sensors[trigger]?.map { it.measure() } ?: listOf() + } + +} + +private class FolderMeasurementFactory( + private val calculatedId: String, + private val haDisplayName: String, + val device: String, + path: String, + val frequency: SchedulerFrequency +) { + + private val path = File(path) + + fun getMeasurementSchema() = Measurement( + id = calculatedId, + name = haDisplayName, + deviceClass = SensorDeviceClass.DATA_SIZE, + unit = UnitOfMeasurement.MEGABYTES, + device = device, + ) + + fun measure() = Measurement( + id = calculatedId, + value = getFolderSize(path).toDouble() / MEGABYTES + ) + +} + +object FolderUtils { + + fun getFolderSize(folder: File): Long { + if (!folder.exists() || !folder.isDirectory) { + return -1 + } + + var length = 0L + val files = folder.listFiles() ?: return 0 + for (file in files) { + length += if (file.isFile) { + file.length() + } else { + getFolderSize(file) + } + } + return length + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/HeartbeatSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/HeartbeatSensor.kt new file mode 100644 index 0000000..05ced4d --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/HeartbeatSensor.kt @@ -0,0 +1,53 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +private val HEARTBEAT_FREQUENCY = SchedulerFrequency.EVERY_MINUTE + +class HeartbeatSensor( + config: ConfigRecord +) : Sensor { + + private val calculatedId = ProbeUtil.formatId(config.haId, name) + private val haDisplayName = "${config.haDisplayName} last seen" + + override val name: String + get() = "heartbeat" + + override fun getAvailableNames(): List { + return listOf("last_seen") + } + + override fun getMeasurementSchemas(): List { + return listOf( + Measurement( + id = calculatedId, + name = haDisplayName, + deviceClass = SensorDeviceClass.TIMESTAMP, + ) + ) + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return trigger == HEARTBEAT_FREQUENCY + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + if (trigger != HEARTBEAT_FREQUENCY) + return listOf() + + return listOf( + Measurement( + id = calculatedId, + textValue = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + ) + ) + } + +} diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/NvidiaGpuSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/NvidiaGpuSensor.kt new file mode 100644 index 0000000..00c820e --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/NvidiaGpuSensor.kt @@ -0,0 +1,210 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.sensor.NvidiaUtil.getNvidiaGpuInfo +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.ConcurrentHashMap + + +class NvidiaGpuSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + NvidiaMeasurementFactory( + idPrefix = ProbeUtil.formatId(config.haId, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + name = measurementRecord.name, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + override val name: String + get() = "nvidia" + + override fun getAvailableNames(): List { + return getNvidiaGpuInfo().map { it.name } + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .flatMap { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + return sensors[trigger]?.flatMap { it.measure() } ?: listOf() + } + +} + +private class NvidiaMeasurementFactory( + idPrefix: String, + private val name: String, + private val haDisplayName: String, + val device: String, + val frequency: SchedulerFrequency +) { + + private val temperatureId = ProbeUtil.formatId(idPrefix, "temperature") + private val utilizationId = ProbeUtil.formatId(idPrefix, "utilization") + private val memoryTotalId = ProbeUtil.formatId(idPrefix, "memory_total") + private val memoryUsedId = ProbeUtil.formatId(idPrefix, "memory_used") + private val fanSpeedId = ProbeUtil.formatId(idPrefix, "fan_speed") + private val powerDrawId = ProbeUtil.formatId(idPrefix, "power_draw") + + fun getMeasurementSchema(): List { + return listOf( + Measurement( + id = temperatureId, + name = "$haDisplayName temperature", + deviceClass = SensorDeviceClass.TEMPERATURE, + unit = UnitOfMeasurement.CELSIUS, + device = device, + ), + Measurement( + id = utilizationId, + name = "$haDisplayName utilization", + deviceClass = null, + unit = UnitOfMeasurement.PERCENTAGE, + device = device, + ), + Measurement( + id = memoryTotalId, + name = "$haDisplayName memory total", + deviceClass = SensorDeviceClass.DATA_SIZE, + unit = UnitOfMeasurement.MEGABYTES, + device = device, + ), + Measurement( + id = memoryUsedId, + name = "$haDisplayName memory used", + deviceClass = SensorDeviceClass.DATA_SIZE, + unit = UnitOfMeasurement.MEGABYTES, + device = device, + ), + Measurement( + id = fanSpeedId, + name = "$haDisplayName fan speed", + deviceClass = null, + unit = UnitOfMeasurement.PERCENTAGE, + device = device, + ), + Measurement( + id = powerDrawId, + name = "$haDisplayName power draw", + deviceClass = SensorDeviceClass.POWER, + unit = UnitOfMeasurement.WATT, + device = device, + ) + ) + } + + fun measure(): Sequence { + val gpuInfo = getNvidiaGpuInfo() + .firstOrNull { it.name == name } + ?: return sequenceOf() + + return sequenceOf( + Measurement( + id = temperatureId, + value = gpuInfo.temperature.toDouble() + ), + Measurement( + id = utilizationId, + value = gpuInfo.utilization.toDouble() + ), + Measurement( + id = memoryTotalId, + value = gpuInfo.memoryTotal.toDouble() + ), + Measurement( + id = memoryUsedId, + value = gpuInfo.memoryUsed.toDouble() + ), + Measurement( + id = fanSpeedId, + value = gpuInfo.fanSpeed.toDouble() + ), + Measurement( + id = powerDrawId, + value = gpuInfo.powerDraw + ), + ) + } + +} + +private data class NvidiaGpuInfo( + val name: String, + val temperature: Int, + val utilization: Int, + val memoryTotal: Int, + val memoryUsed: Int, + val fanSpeed: Int, + val powerDraw: Double, +) + +private object NvidiaUtil { + + private val log = LoggerFactory.getLogger(NvidiaUtil::class.java) + + fun getNvidiaGpuInfo(): List { + val gpuInfoList = mutableListOf() + try { + val process = ProcessBuilder( + "nvidia-smi", + "--query-gpu=name,temperature.gpu,utilization.gpu,memory.total,memory.used,fan.speed,power.draw", + "--format=csv,noheader,nounits" + ).start() + + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var line: String? + + while (reader.readLine().also { line = it } != null) { + val tokens = line!!.split(",") + if (tokens.size == 7) { + val name = tokens[0].trim() + val temperature = tokens[1].trim().toIntOrNull() ?: 0 + val utilization = tokens[2].trim().toIntOrNull() ?: 0 + val memoryTotal = tokens[3].trim().toIntOrNull() ?: 0 + val memoryUsed = tokens[4].trim().toIntOrNull() ?: 0 + val fanSpeed = tokens[5].trim().toIntOrNull() ?: 0 + val powerDraw = tokens[6].trim().toDoubleOrNull() ?: 0.0 + + val gpuInfo = NvidiaGpuInfo( + name = name, + temperature = temperature, + utilization = utilization, + memoryTotal = memoryTotal, + memoryUsed = memoryUsed, + fanSpeed = fanSpeed, + powerDraw = powerDraw, + ) + gpuInfoList.add(gpuInfo) + } + } + + process.waitFor() + } catch (e: Exception) { + log.error("Error retrieving GPU information: ${e.message}") + } + return gpuInfoList + } + +} diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/PcTemperatureSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/PcTemperatureSensor.kt new file mode 100644 index 0000000..bc69870 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/PcTemperatureSensor.kt @@ -0,0 +1,128 @@ +package hu.gerviba.probe.sensor + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.ConcurrentHashMap + +class PcTemperatureSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + PcTemperatureMeasurementFactory( + calculatedId = ProbeUtil.formatId(config.haId, name, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + sensorName = measurementRecord.name, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + override val name: String + get() = "sensor" + + override fun getAvailableNames(): List { + return PcTemperatureUtil.readValues().map { it.sensorName } + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .map { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + val measurements = PcTemperatureUtil.readValues() + return sensors[trigger]?.map { it.measure(measurements) } ?: listOf() + } + +} + +private class PcTemperatureMeasurementFactory( + private val calculatedId: String, + private val haDisplayName: String, + private val sensorName: String, + val device: String, + val frequency: SchedulerFrequency +) { + + fun getMeasurementSchema() = Measurement( + id = calculatedId, + name = haDisplayName, + deviceClass = SensorDeviceClass.TEMPERATURE, + unit = UnitOfMeasurement.CELSIUS, + device = device, + ) + + fun measure(measurements: List) = Measurement( + id = calculatedId, + value = measurements.firstOrNull { it.sensorName == sensorName }?.tempValue ?: 0.0 + ) + +} + +private data class TemperatureInfo( + val sensorName: String, + val tempValue: Double, +) + +private object PcTemperatureUtil { + + fun getSensorsJson(): String { + val process = ProcessBuilder("sensors", "-j").start() + val output = StringBuilder() + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line) + } + process.waitFor() + return output.toString() + } + + fun readValues(): List { + val sensorsJson = getSensorsJson() + val objectMapper = ObjectMapper() + val rootNode = objectMapper.readTree(sensorsJson) + + return rootNode.fields().asSequence().flatMap { sensorEntry -> + val sensorName = sensorEntry.key + + sensorEntry.value.fields().asSequence().flatMap { featureEntry -> + val featureName = featureEntry.key + + if (featureEntry.value.isObject) { + featureEntry.value.fields().asSequence().mapNotNull { dataEntry -> + val dataKey = dataEntry.key + val dataValue = dataEntry.value + + if (dataKey.contains("temp") && dataKey.endsWith("_input")) { + val tempValue = dataValue.asDouble() + TemperatureInfo("${sensorName}.${featureName}", tempValue) + } else { + null + } + } + } else { + sequenceOf() + } + } + } + .toList() + } + +} diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/PcUtilizationSensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/PcUtilizationSensor.kt new file mode 100644 index 0000000..9b4b5b3 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/PcUtilizationSensor.kt @@ -0,0 +1,175 @@ +package hu.gerviba.probe.sensor + +import com.sun.management.OperatingSystemMXBean +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement +import hu.gerviba.probe.util.ProbeUtil +import hu.gerviba.probe.util.SensorDeviceClass +import hu.gerviba.probe.util.UnitOfMeasurement +import org.slf4j.LoggerFactory +import java.io.File +import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap + +private const val MEGABYTE = 1024 * 1024 + +class PcUtilizationSensor( + config: ConfigRecord +) : Sensor { + + private val sensors = config.measurements + .filter { it.type == name } + .map { measurementRecord -> + PcUtilizationMeasurementFactory( + calculatedId = ProbeUtil.formatId(config.haId, name, measurementRecord.haId), + haDisplayName = measurementRecord.haDisplayName, + name = measurementRecord.name, + device = measurementRecord.device, + frequency = measurementRecord.frequency + ) + } + .groupBy { it.frequency } + .toMap(ConcurrentHashMap>()) + + override val name: String + get() = "utilization" + + override fun getAvailableNames(): List { + return listOf(NAME_CPU, NAME_RAM, NAME_SWAP, NAME_UPTIME, NAME_LOAD_1M, NAME_LOAD_5M, NAME_LOAD_15M) + } + + override fun getMeasurementSchemas(): List { + return sensors.flatMap { it.value } + .map { it.getMeasurementSchema() } + } + + override fun hasMeasurement(trigger: SchedulerFrequency): Boolean { + return sensors[trigger]?.isNotEmpty() ?: false + } + + override fun getMeasurements(trigger: SchedulerFrequency): List { + val measurement = PcUtilizationUtil.measure() + return sensors[trigger]?.map { it.measure(measurement) } ?: listOf() + } + +} + +private const val NAME_CPU = "cpu" +private const val NAME_RAM = "ram" +private const val NAME_SWAP = "swap" +private const val NAME_UPTIME = "uptime" +private const val NAME_LOAD_1M = "load1" +private const val NAME_LOAD_5M = "load5" +private const val NAME_LOAD_15M = "load15" + +private class PcUtilizationMeasurementFactory( + private val calculatedId: String, + private val haDisplayName: String, + private val name: String, + val device: String, + val frequency: SchedulerFrequency, +) { + + val deviceClass = when (name) { + NAME_CPU -> null + NAME_RAM -> SensorDeviceClass.DATA_SIZE + NAME_SWAP -> SensorDeviceClass.DATA_SIZE + NAME_UPTIME -> SensorDeviceClass.DURATION + else -> null + } + + val unit = when (name) { + NAME_CPU -> UnitOfMeasurement.PERCENTAGE + NAME_RAM -> UnitOfMeasurement.MEGABYTES + NAME_SWAP -> UnitOfMeasurement.MEGABYTES + NAME_UPTIME -> UnitOfMeasurement.SECONDS + else -> null + } + + fun getMeasurementSchema() = Measurement( + id = calculatedId, + name = haDisplayName, + deviceClass = deviceClass, + unit = unit, + device = device, + ) + + fun measure(measurement: PcUtilization) = Measurement( + id = calculatedId, + value = when (name) { + NAME_CPU -> measurement.cpuLoad + NAME_RAM -> measurement.usedMemoryMb + NAME_SWAP -> measurement.usedSwapMb + NAME_UPTIME -> measurement.uptimeSeconds + NAME_LOAD_1M -> measurement.loadAverage1m + NAME_LOAD_5M -> measurement.loadAverage5m + NAME_LOAD_15M -> measurement.loadAverage15m + else -> 0L + }.toDouble() + ) + +} + +private data class PcUtilization( + val cpuLoad: Double, + val totalMemoryMb: Long, + val usedMemoryMb: Long, + val totalSwapMb: Long, + val usedSwapMb: Long, + val uptimeSeconds: Double, + val loadAverage1m: Double, + val loadAverage5m: Double, + val loadAverage15m: Double, +) + +private object PcUtilizationUtil { + + private val log = LoggerFactory.getLogger(PcUtilizationUtil::class.java) + + fun measure(): PcUtilization { + val osBean = ManagementFactory.getOperatingSystemMXBean() as OperatingSystemMXBean + + val totalPhysicalMemorySize = osBean.totalMemorySize / MEGABYTE + val freePhysicalMemorySize = osBean.freeMemorySize / MEGABYTE + val usedPhysicalMemorySize = totalPhysicalMemorySize - freePhysicalMemorySize + + val totalSwapSpaceSize = osBean.totalSwapSpaceSize / MEGABYTE + val freeSwapSpaceSize = osBean.freeSwapSpaceSize / MEGABYTE + val usedSwapSpaceSize = totalSwapSpaceSize - freeSwapSpaceSize + + val (load1m, load5m, load15m) = readLoadAverage() + + return PcUtilization( + cpuLoad = osBean.cpuLoad * 100, + totalMemoryMb = totalPhysicalMemorySize, + usedMemoryMb = usedPhysicalMemorySize, + totalSwapMb = totalSwapSpaceSize, + usedSwapMb = usedSwapSpaceSize, + uptimeSeconds = readUptimeSeconds(), + loadAverage1m = load1m, + loadAverage5m = load5m, + loadAverage15m = load15m, + ) + } + + private fun readUptimeSeconds(): Double { + return try { + File("/proc/uptime").readText().trim().split(" ").first().toDouble() + } catch (e: Exception) { + log.warn("Failed to read /proc/uptime: {}", e.message) + 0.0 + } + } + + private fun readLoadAverage(): Triple { + return try { + val tokens = File("/proc/loadavg").readText().trim().split(" ") + Triple(tokens[0].toDouble(), tokens[1].toDouble(), tokens[2].toDouble()) + } catch (e: Exception) { + log.warn("Failed to read /proc/loadavg: {}", e.message) + Triple(0.0, 0.0, 0.0) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/sensor/Sensor.kt b/src/main/kotlin/hu/gerviba/probe/sensor/Sensor.kt new file mode 100644 index 0000000..84cfcad --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/sensor/Sensor.kt @@ -0,0 +1,15 @@ +package hu.gerviba.probe.sensor + +import hu.gerviba.probe.SchedulerFrequency +import hu.gerviba.probe.api.Measurement + +interface Sensor { + + val name: String + fun onConfig() { } + fun getAvailableNames(): List + fun hasMeasurement(trigger: SchedulerFrequency): Boolean + fun getMeasurementSchemas(): List + fun getMeasurements(trigger: SchedulerFrequency): List + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/service/RunCommandActionService.kt b/src/main/kotlin/hu/gerviba/probe/service/RunCommandActionService.kt new file mode 100644 index 0000000..d0183fa --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/service/RunCommandActionService.kt @@ -0,0 +1,41 @@ +package hu.gerviba.probe.service + +import hu.gerviba.probe.ConfigRecord +import org.slf4j.LoggerFactory + +class RunCommandActionService( + config: ConfigRecord +): Service { + + private val log = LoggerFactory.getLogger(RunCommandActionService::class.java) + + override val type: String + get() = "cmd" + + data class CommandAction( + val name: String, + val command: List, + val waitFor: Boolean, + ) + + private val actionsByName = config.services + .filter { it.type == type } + .map { CommandAction( + name = it.name, + command = it.command, + waitFor = it.waitFor, + ) } + .associateBy { it.name } + + override fun runAction(name: String, args: List) { + val command = actionsByName[name] ?: run { + log.info("No action found with name: $name") + return + } + + val process = ProcessBuilder(command.command).start() + if (command.waitFor || args.contains("waitFor")) + process.waitFor() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/service/ServerService.kt b/src/main/kotlin/hu/gerviba/probe/service/ServerService.kt new file mode 100644 index 0000000..3e84f5b --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/service/ServerService.kt @@ -0,0 +1,52 @@ +package hu.gerviba.probe.service + +import hu.gerviba.probe.ConfigRecord +import hu.gerviba.probe.api.ActionRequest +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +class ServerService( + private val config: ConfigRecord, + availableServices: List +) { + + private val log = LoggerFactory.getLogger(ServerService::class.java) + private val services = availableServices.associateBy { it.type } + + fun start() { + val port = config.webServerPort ?: 8000 + embeddedServer(Netty, port = port) { + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + }) + } + routing { + get ("/health") { + call.respondText("ok") + } + post("/api/webhook/action") { request -> + val token = call.request.headers[HttpHeaders.Authorization] + if (token != config.webServerToken) { + call.respond(HttpStatusCode.Unauthorized) + log.warn("Invalid token") + return@post + } + services[request.type]?.runAction(request.action, request.args) + call.respond(HttpStatusCode.OK) + } + } + }.start(wait = false) + log.info("Starting webserver on port $port") + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/service/Service.kt b/src/main/kotlin/hu/gerviba/probe/service/Service.kt new file mode 100644 index 0000000..696ebb0 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/service/Service.kt @@ -0,0 +1,8 @@ +package hu.gerviba.probe.service + +interface Service { + + val type: String + fun runAction(name: String, args: List) + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/util/ProbeUtil.kt b/src/main/kotlin/hu/gerviba/probe/util/ProbeUtil.kt new file mode 100644 index 0000000..2a6bf3a --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/util/ProbeUtil.kt @@ -0,0 +1,13 @@ +package hu.gerviba.probe.util + +object ProbeUtil { + + fun formatId(probeName: String, sensorName: String): String { + return "${probeName}__$sensorName" + } + + fun formatId(probeName: String, sensorName: String, measurementName: String): String { + return "${probeName}__${sensorName}__$measurementName" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/util/SensorDeviceClass.kt b/src/main/kotlin/hu/gerviba/probe/util/SensorDeviceClass.kt new file mode 100644 index 0000000..7746e78 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/util/SensorDeviceClass.kt @@ -0,0 +1,59 @@ +package hu.gerviba.probe.util + +enum class SensorDeviceClass(val literal: String, val description: String) { + // Non-numerical device classes + DATE("date", "Date. Unit of measurement: None. ISO8601 format."), + DURATION("duration", "Fixed duration. Unit of measurement: d, h, min, s, ms."), + ENUM("enum", "Enumeration. Provides a fixed list of options the state of the sensor can be in. Unit of measurement: None."), + TIMESTAMP("timestamp", "Timestamp. Unit of measurement: None. ISO8601 format."), + + // Numerical device classes + APPARENT_POWER("apparent_power", "Apparent power. Unit of measurement: VA."), + AQI("aqi", "Air Quality Index. Unit of measurement: None."), + ATMOSPHERIC_PRESSURE("atmospheric_pressure", "Atmospheric pressure. Unit of measurement: UnitOfPressure units."), + BATTERY("battery", "Percentage of battery that is left. Unit of measurement: %."), + CO("carbon_monoxide", "Carbon Monoxide gas concentration. Unit of measurement: ppm."), + CO2("carbon_dioxide", "Carbon Dioxide gas concentration. Unit of measurement: ppm."), + CURRENT("current", "Current. Unit of measurement: A, mA."), + DATA_RATE("data_rate", "Data rate. Unit of measurement: UnitOfDataRate."), + DATA_SIZE("data_size", "Data size. Unit of measurement: UnitOfInformation."), + DISTANCE("distance", "Generic distance. Unit of measurement: LENGTH_* units - SI /metric: mm, cm, m, km; USCS / imperial: in, ft, yd, mi."), + ENERGY("energy", "Energy. Unit of measurement: Wh, kWh, MWh, MJ, GJ."), + ENERGY_STORAGE("energy_storage", "Stored energy. Unit of measurement: Wh, kWh, MWh, MJ, GJ."), + FREQUENCY("frequency", "Frequency. Unit of measurement: Hz, kHz, MHz, GHz."), + GAS("gas", "Gas. Unit of measurement: SI / metric: m³; USCS / imperial: ft³, CCF."), + HUMIDITY("humidity", "Relative humidity. Unit of measurement: %."), + ILLUMINANCE("illuminance", "Illuminance. Unit of measurement: lx."), + IRRADIANCE("irradiance", "Irradiance. Unit of measurement: SI / metric: W/m²; USCS / imperial: BTU/(h⋅ft²)."), + MOISTURE("moisture", "Moisture. Unit of measurement: %."), + MONETARY("monetary", "Amount of money. Unit of measurement: ISO4217 currency code."), + NITROGEN_DIOXIDE("nitrogen_dioxide", "Amount of NO2. Unit of measurement: µg/m³."), + NITROGEN_MONOXIDE("nitrogen_monoxide", "Amount of NO. Unit of measurement: µg/m³."), + NITROUS_OXIDE("nitrous_oxide", "Amount of N2O. Unit of measurement: µg/m³."), + OZONE("ozone", "Amount of O3. Unit of measurement: µg/m³."), + PM1("pm1", "Particulate matter <= 0.1 μm. Unit of measurement: µg/m³."), + PM10("pm10", "Particulate matter <= 10 μm. Unit of measurement: µg/m³."), + PM25("pm25", "Particulate matter <= 2.5 μm. Unit of measurement: µg/m³."), + POWER_FACTOR("power_factor", "Power factor. Unit of measurement: %, None."), + POWER("power", "Power. Unit of measurement: W, kW."), + PRECIPITATION("precipitation", "Accumulated precipitation. Unit of measurement: UnitOfPrecipitationDepth."), + PRECIPITATION_INTENSITY("precipitation_intensity", "Precipitation intensity. Unit of measurement: UnitOfVolumetricFlux."), + PRESSURE("pressure", "Pressure. Unit of measurement: mbar, cbar, bar, Pa, hPa, kPa, inHg, psi."), + REACTIVE_POWER("reactive_power", "Reactive power. Unit of measurement: var."), + SIGNAL_STRENGTH("signal_strength", "Signal strength. Unit of measurement: dB, dBm."), + SOUND_PRESSURE("sound_pressure", "Sound pressure. Unit of measurement: dB, dBA."), + SPEED("speed", "Generic speed. Unit of measurement: SPEED_* units or UnitOfVolumetricFlux."), + SULPHUR_DIOXIDE("sulphur_dioxide", "Amount of SO2. Unit of measurement: µg/m³."), + TEMPERATURE("temperature", "Temperature. Unit of measurement: °C, °F, K."), + VOLATILE_ORGANIC_COMPOUNDS("volatile_organic_compounds", "Amount of VOC. Unit of measurement: µg/m³."), + VOLATILE_ORGANIC_COMPOUNDS_PARTS("volatile_organic_compounds_parts", "Ratio of VOC. Unit of measurement: ppm, ppb."), + VOLTAGE("voltage", "Voltage. Unit of measurement: V, mV."), + VOLUME("volume", "Generic volume. Unit of measurement: VOLUME_* units."), + VOLUME_STORAGE("volume_storage", "Generic stored volume. Unit of measurement: VOLUME_* units."), + WATER("water", "Water. Unit of measurement: SI / metric: m³, L; USCS / imperial: ft³, CCF, gal."), + WEIGHT("weight", "Generic weight, represents a measurement of an object's mass. Unit of measurement: MASS_* units."), + WIND_SPEED("wind_speed", "Wind speed. Unit of measurement: SPEED_* units."), + ; + + override fun toString(): String = literal +} \ No newline at end of file diff --git a/src/main/kotlin/hu/gerviba/probe/util/UnitOfMeasurement.kt b/src/main/kotlin/hu/gerviba/probe/util/UnitOfMeasurement.kt new file mode 100644 index 0000000..753cfc6 --- /dev/null +++ b/src/main/kotlin/hu/gerviba/probe/util/UnitOfMeasurement.kt @@ -0,0 +1,178 @@ +package hu.gerviba.probe.util + +enum class UnitOfMeasurement(val unit: String) { + + // Apparent Power Units + VOLT_AMPERE("VA"), + + // Power Units + WATT("W"), + KILO_WATT("kW"), + BTU_PER_HOUR("BTU/h"), + + // Reactive Power Unit + VOLT_AMPERE_REACTIVE("var"), + + // Energy Units + GIGA_JOULE("GJ"), + KILO_WATT_HOUR("kWh"), + MEGA_JOULE("MJ"), + MEGA_WATT_HOUR("MWh"), + WATT_HOUR("Wh"), + + // Electric Current Units + MILLIAMPERE("mA"), + AMPERE("A"), + + // Electric Potential Units + MILLIVOLT("mV"), + VOLT("V"), + + // Temperature Units + CELSIUS("°C"), + FAHRENHEIT("°F"), + KELVIN("K"), + + // Time Units + MICROSECONDS("μs"), + MILLISECONDS("ms"), + SECONDS("s"), + MINUTES("min"), + HOURS("h"), + DAYS("d"), + WEEKS("w"), + MONTHS("m"), + YEARS("y"), + + // Length Units + MILLIMETERS("mm"), + CENTIMETERS("cm"), + METERS("m"), + KILOMETERS("km"), + INCHES("in"), + FEET("ft"), + YARDS("yd"), + MILES("mi"), + + // Frequency Units + HERTZ("Hz"), + KILOHERTZ("kHz"), + MEGAHERTZ("MHz"), + GIGAHERTZ("GHz"), + + // Pressure Units + PA("Pa"), + HPA("hPa"), + KPA("kPa"), + BAR("bar"), + CBAR("cbar"), + MBAR("mbar"), + MMHG("mmHg"), + INHG("inHg"), + PSI("psi"), + + // Sound Pressure Units + DECIBEL("dB"), + WEIGHTED_DECIBEL_A("dBA"), + + // Volume Units + CUBIC_FEET("ft³"), + CENTUM_CUBIC_FEET("CCF"), + CUBIC_METERS("m³"), + LITERS("L"), + MILLILITERS("mL"), + GALLONS("gal"), + FLUID_OUNCES("fl. oz."), + + // Volume Flow Rate Units + CUBIC_METERS_PER_HOUR("m³/h"), + CUBIC_FEET_PER_MINUTE("ft³/m"), + + // Mass Units + GRAMS("g"), + KILOGRAMS("kg"), + MILLIGRAMS("mg"), + MICROGRAMS("µg"), + OUNCES("oz"), + POUNDS("lb"), + STONES("st"), + + // Irradiance Units + WATTS_PER_SQUARE_METER("W/m²"), + BTUS_PER_HOUR_SQUARE_FOOT("BTU/(h⋅ft²)"), + + // Volumetric Flux Units + INCHES_PER_DAY("in/d"), + INCHES_PER_HOUR("in/h"), + MILLIMETERS_PER_DAY("mm/d"), + MILLIMETERS_PER_HOUR("mm/h"), + + // Precipitation Depth Units + PRECIPITATION_INCHES("in"), + PRECIPITATION_MILLIMETERS("mm"), + PRECIPITATION_CENTIMETERS("cm"), + + // Speed Units + FEET_PER_SECOND("ft/s"), + METERS_PER_SECOND("m/s"), + KILOMETERS_PER_HOUR("km/h"), + KNOTS("kn"), + MILES_PER_HOUR("mph"), + + // Information Units + BITS("bit"), + KILOBITS("kbit"), + MEGABITS("Mbit"), + GIGABITS("Gbit"), + BYTES("B"), + KILOBYTES("kB"), + MEGABYTES("MB"), + GIGABYTES("GB"), + TERABYTES("TB"), + PETABYTES("PB"), + EXABYTES("EB"), + ZETTABYTES("ZB"), + YOTTABYTES("YB"), + KIBIBYTES("KiB"), + MEBIBYTES("MiB"), + GIBIBYTES("GiB"), + TEBIBYTES("TiB"), + PEBIBYTES("PiB"), + EXBIBYTES("EiB"), + ZEBIBYTES("ZiB"), + YOBIBYTES("YiB"), + + // Data Rate Units + BITS_PER_SECOND("bit/s"), + KILOBITS_PER_SECOND("kbit/s"), + MEGABITS_PER_SECOND("Mbit/s"), + GIGABITS_PER_SECOND("Gbit/s"), + BYTES_PER_SECOND("B/s"), + KILOBYTES_PER_SECOND("kB/s"), + MEGABYTES_PER_SECOND("MB/s"), + GIGABYTES_PER_SECOND("GB/s"), + KIBIBYTES_PER_SECOND("KiB/s"), + MEBIBYTES_PER_SECOND("MiB/s"), + GIBIBYTES_PER_SECOND("GiB/s"), + + // Additional Units + DEGREE("°"), + CURRENCY_EURO("€"), + CURRENCY_DOLLAR("$"), + CURRENCY_CENT("¢"), + PERCENTAGE("%"), + AREA_SQUARE_METERS("m²"), + CONDUCTIVITY("µS/cm"), + LIGHT_LUX("lx"), + UV_INDEX("UV index"), + REVOLUTIONS_PER_MINUTE("rpm"), + SIGNAL_STRENGTH_DECIBELS("dB"), + SIGNAL_STRENGTH_DECIBELS_MILLIWATT("dBm"), + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER("µg/m³"), + CONCENTRATION_PARTS_PER_MILLION("ppm"), + CONCENTRATION_PARTS_PER_BILLION("ppb"), + ; + + override fun toString(): String = unit + +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..5902d1d --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + + %d{HH:mm:ss.SSS} [%-10t{10}] %-5level %logger{20}: %msg%n + + + + + + + + + \ No newline at end of file