Initial commit

This commit is contained in:
gerviba committed 2026-11-01 18:33:58 +01:00
commit e1e03ba73f
38 files changed
+2968

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
container:
valid_volumes:
- /home/clustermaster/storage/cdn/serve/internal/probe
+26
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.idea/
build/
.kotlin/
.gradle/
+31
View File
@@ -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")
}
+85
View File
@@ -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/<webhook_id>.
"""
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)
+58
View File
@@ -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()
)
+30
View File
@@ -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)
+11
View File
@@ -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
}
+293
View File
@@ -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()
@@ -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."
}
}
}
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+249
View File
@@ -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" "$@"
Vendored
+92
View File
@@ -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
@@ -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<String> = 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<String> = 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<SensorRecord> = listOf(),
val devices: List<DeviceRecord> = listOf(),
val services: List<ServiceRecord> = 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<ConfigRecord>(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
)
)
)
}
}
@@ -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<String>) {
if (args.getOrNull(0)?.equals("event", ignoreCase = true) == true) {
val eventName = args.getOrNull(1) ?: run {
println("Usage: probe event <event name> [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<Sensor>()
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<Pair<Sensor, List<Measurement>>>
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<Service>(
RunCommandActionService(config)
)
webServer = ServerService(config, services).also { it.start() }
}
}
fun start() {
scheduler.schedule()
}
}
@@ -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),
))
}
@@ -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<Sensor>,
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) })
}
}
}
@@ -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<String> = listOf()
)
@@ -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<DeviceRecord>,
val entities: List<Measurement>,
)
@@ -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<Measurement>) {
updateMeasurements(config.devices, measurements)
}
fun updateMeasurements(devices: List<DeviceRecord>, measurements: List<Measurement>) {
log.info("sending measurements:")
measurements.forEach { measurement -> log.info(measurement.toString()) }
sendPostRequestToWebhook(devices, measurements)
}
fun sendPostRequestToWebhook(devices: List<DeviceRecord>, entities: List<Measurement>) = 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}")
}
}
}
@@ -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,
)
@@ -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<SchedulerFrequency, List<DeviceDiscFactory>>())
override val name: String
get() = "disc"
override fun getAvailableNames(): List<String> {
return listOf("root")
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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<Measurement> {
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<Measurement> {
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<Long, Long> {
val root = File("/")
val totalSpace = root.totalSpace
val usedSpace = totalSpace - root.freeSpace
return Pair(totalSpace, usedSpace)
}
}
@@ -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<String, ContainerData>()
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<SchedulerFrequency, List<DockerMeasurementFactory>>())
private val sensorByName = sensors.values.flatten().associateBy { it.name }
override val name: String
get() = "docker"
override fun getAvailableNames(): List<String> {
return runningContainers.values.map { it.name ?: it.id }
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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<EventsResultCallback, Event>()
abstract class StatsCallback : ResultCallbackTemplate<StatsCallback, Statistics>()
}
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<Measurement> {
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<Measurement> {
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
)
}
}
}
@@ -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<String> {
return listOf("any name you like, e.g. backup_success")
}
override fun getMeasurementSchemas(): List<Measurement> {
return sensors.map { it.getMeasurementSchema() }
}
override fun hasMeasurement(trigger: SchedulerFrequency): Boolean {
// Events are pushed on demand via `probe event <name>`, never polled on a schedule.
return false
}
override fun getMeasurements(trigger: SchedulerFrequency): List<Measurement> {
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,
)
}
@@ -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<SchedulerFrequency, List<FolderMeasurementFactory>>())
override val name: String
get() = "folder"
override fun getAvailableNames(): List<String> {
return listOf("/home/user", "/any/path/you/like")
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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
}
}
@@ -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<String> {
return listOf("last_seen")
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
if (trigger != HEARTBEAT_FREQUENCY)
return listOf()
return listOf(
Measurement(
id = calculatedId,
textValue = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
)
)
}
}
@@ -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<SchedulerFrequency, List<NvidiaMeasurementFactory>>())
override val name: String
get() = "nvidia"
override fun getAvailableNames(): List<String> {
return getNvidiaGpuInfo().map { it.name }
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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<Measurement> {
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<Measurement> {
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<NvidiaGpuInfo> {
val gpuInfoList = mutableListOf<NvidiaGpuInfo>()
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
}
}
@@ -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<SchedulerFrequency, List<PcTemperatureMeasurementFactory>>())
override val name: String
get() = "sensor"
override fun getAvailableNames(): List<String> {
return PcTemperatureUtil.readValues().map { it.sensorName }
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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<TemperatureInfo>) = 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<TemperatureInfo> {
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()
}
}
@@ -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<SchedulerFrequency, List<PcUtilizationMeasurementFactory>>())
override val name: String
get() = "utilization"
override fun getAvailableNames(): List<String> {
return listOf(NAME_CPU, NAME_RAM, NAME_SWAP, NAME_UPTIME, NAME_LOAD_1M, NAME_LOAD_5M, NAME_LOAD_15M)
}
override fun getMeasurementSchemas(): List<Measurement> {
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<Measurement> {
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<Double, Double, Double> {
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)
}
}
}
@@ -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<String>
fun hasMeasurement(trigger: SchedulerFrequency): Boolean
fun getMeasurementSchemas(): List<Measurement>
fun getMeasurements(trigger: SchedulerFrequency): List<Measurement>
}
@@ -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<String>,
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<String>) {
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()
}
}
@@ -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<Service>
) {
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<ActionRequest>("/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")
}
}
@@ -0,0 +1,8 @@
package hu.gerviba.probe.service
interface Service {
val type: String
fun runAction(name: String, args: List<String>)
}
@@ -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"
}
}
@@ -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
}
@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%d{HH:mm:ss.SSS} [%-10t{10}] %-5level %logger{20}: %msg%n
</Pattern>
</layout>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>