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

@@ -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>