- Add iftop as runtime dependency in package.nix and flake.nix - Modify SystemMetrics to calculate network throughput (bytes/second) - Track previous network stats in MQTT client state for throughput calculation - Update Home Assistant discovery to show RX/TX throughput sensors - Replace cumulative byte counters with real-time throughput metrics - Add proper throughput calculation with time-based differentials This provides much more useful real-time network monitoring compared to ever-increasing cumulative byte counts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
182 lines
9.0 KiB
Elixir
182 lines
9.0 KiB
Elixir
defmodule Systant.HaDiscovery do
|
|
@moduledoc """
|
|
Home Assistant MQTT Discovery integration for Systant.
|
|
|
|
Publishes device and entity discovery configurations to Home Assistant
|
|
via MQTT following the HA discovery protocol.
|
|
|
|
Discovery topic format: homeassistant/<component>/<node_id>/<object_id>/config
|
|
"""
|
|
|
|
require Logger
|
|
|
|
@manufacturer "Systant"
|
|
@model "System Monitor"
|
|
|
|
@doc """
|
|
Publish all discovery configurations for a host
|
|
"""
|
|
def publish_discovery(client_pid, hostname, config \\ nil) do
|
|
app_config = config || Systant.Config.load_config()
|
|
ha_config = Systant.Config.get(app_config, ["homeassistant"]) || %{}
|
|
|
|
if ha_config["discovery_enabled"] != false do
|
|
discovery_prefix = ha_config["discovery_prefix"] || "homeassistant"
|
|
device_config = build_device_config(hostname)
|
|
|
|
# Publish device discovery first
|
|
publish_device_discovery(client_pid, hostname, device_config, discovery_prefix)
|
|
|
|
# Publish sensor discoveries
|
|
publish_sensor_discoveries(client_pid, hostname, device_config, discovery_prefix)
|
|
|
|
Logger.info("Published Home Assistant discovery for #{hostname}")
|
|
else
|
|
Logger.info("Home Assistant discovery disabled in configuration")
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Remove all discovery configurations for a host
|
|
"""
|
|
def remove_discovery(client_pid, hostname, config \\ nil) do
|
|
app_config = config || Systant.Config.load_config()
|
|
ha_config = Systant.Config.get(app_config, ["homeassistant"]) || %{}
|
|
discovery_prefix = ha_config["discovery_prefix"] || "homeassistant"
|
|
|
|
# Remove by publishing empty payloads to discovery topics
|
|
sensors = get_sensor_definitions(hostname)
|
|
|
|
Enum.each(sensors, fn {component, object_id, _config} ->
|
|
topic = "#{discovery_prefix}/#{component}/#{hostname}/#{object_id}/config"
|
|
Tortoise.publish(client_pid, topic, "", retain: true)
|
|
end)
|
|
|
|
Logger.info("Removed Home Assistant discovery for #{hostname}")
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp publish_device_discovery(client_pid, hostname, device_config, discovery_prefix) do
|
|
# Use device-based discovery for multiple components
|
|
components_config = %{
|
|
device: device_config,
|
|
components: build_all_components(hostname, device_config)
|
|
}
|
|
|
|
topic = "#{discovery_prefix}/device/#{hostname}/config"
|
|
payload = Jason.encode!(components_config)
|
|
|
|
Tortoise.publish(client_pid, topic, payload, retain: true)
|
|
end
|
|
|
|
defp publish_sensor_discoveries(client_pid, hostname, device_config, discovery_prefix) do
|
|
sensors = get_sensor_definitions(hostname)
|
|
|
|
Enum.each(sensors, fn {component, object_id, config} ->
|
|
full_config = Map.merge(config, %{device: device_config})
|
|
topic = "#{discovery_prefix}/#{component}/#{hostname}/#{object_id}/config"
|
|
payload = Jason.encode!(full_config)
|
|
|
|
Tortoise.publish(client_pid, topic, payload, retain: true)
|
|
end)
|
|
end
|
|
|
|
defp build_device_config(hostname) do
|
|
%{
|
|
identifiers: ["systant_#{hostname}"],
|
|
name: "Systant #{hostname}",
|
|
manufacturer: @manufacturer,
|
|
model: @model,
|
|
sw_version: Application.spec(:systant, :vsn) |> to_string()
|
|
}
|
|
end
|
|
|
|
defp build_all_components(hostname, device_config) do
|
|
get_sensor_definitions(hostname)
|
|
|> Enum.map(fn {_component, object_id, config} ->
|
|
Map.merge(config, %{device: device_config})
|
|
|> Map.put(:object_id, object_id)
|
|
end)
|
|
end
|
|
|
|
defp get_sensor_definitions(hostname) do
|
|
base_topic = "systant/#{hostname}/stats"
|
|
|
|
[
|
|
# CPU Sensors
|
|
{"sensor", "cpu_load_1m", build_sensor_config("CPU Load 1m", "#{base_topic}", "cpu.avg1", "load", "mdi:speedometer")},
|
|
{"sensor", "cpu_load_5m", build_sensor_config("CPU Load 5m", "#{base_topic}", "cpu.avg5", "load", "mdi:speedometer")},
|
|
{"sensor", "cpu_load_15m", build_sensor_config("CPU Load 15m", "#{base_topic}", "cpu.avg15", "load", "mdi:speedometer")},
|
|
|
|
# Memory Sensors
|
|
{"sensor", "memory_used_percent", build_sensor_config("Memory Used", "#{base_topic}", "memory.used_percent", "%", "mdi:memory")},
|
|
{"sensor", "memory_used_gb", build_sensor_config("Memory Used GB", "#{base_topic}", "memory.used_kb", "GB", "mdi:memory", "{{ (value_json.memory.used_kb | float / 1024 / 1024) | round(2) }}")},
|
|
{"sensor", "memory_total_gb", build_sensor_config("Memory Total GB", "#{base_topic}", "memory.total_kb", "GB", "mdi:memory", "{{ (value_json.memory.total_kb | float / 1024 / 1024) | round(2) }}")},
|
|
|
|
# System Sensors
|
|
{"sensor", "uptime_hours", build_sensor_config("Uptime", "#{base_topic}", "system.uptime_seconds", "h", "mdi:clock-outline", "{{ (value_json.system.uptime_seconds | float / 3600) | round(1) }}")},
|
|
{"sensor", "kernel_version", build_sensor_config("Kernel Version", "#{base_topic}", "system.kernel_version", nil, "mdi:linux")},
|
|
|
|
# Temperature Sensors
|
|
{"sensor", "cpu_temperature", build_sensor_config("CPU Temperature", "#{base_topic}", "temperature.cpu", "°C", "mdi:thermometer")},
|
|
|
|
# GPU Sensors - NVIDIA
|
|
{"sensor", "gpu_nvidia_utilization", build_sensor_config("NVIDIA GPU Utilization", "#{base_topic}", "gpu.nvidia[0].utilization_percent", "%", "mdi:expansion-card", "{{ value_json.gpu.nvidia[0].utilization_percent if value_json.gpu.nvidia and value_json.gpu.nvidia|length > 0 else 0 }}")},
|
|
{"sensor", "gpu_nvidia_temperature", build_sensor_config("NVIDIA GPU Temperature", "#{base_topic}", "gpu.nvidia[0].temperature_c", "°C", "mdi:thermometer", "{{ value_json.gpu.nvidia[0].temperature_c if value_json.gpu.nvidia and value_json.gpu.nvidia|length > 0 else none }}")},
|
|
{"sensor", "gpu_nvidia_memory", build_sensor_config("NVIDIA GPU Memory", "#{base_topic}", "gpu.nvidia[0].memory_used_mb", "MB", "mdi:memory", "{{ value_json.gpu.nvidia[0].memory_used_mb if value_json.gpu.nvidia and value_json.gpu.nvidia|length > 0 else none }}")},
|
|
|
|
# GPU Sensors - AMD
|
|
{"sensor", "gpu_amd_utilization", build_sensor_config("AMD GPU Utilization", "#{base_topic}", "gpu.amd[0].utilization_percent", "%", "mdi:expansion-card", "{{ value_json.gpu.amd[0].utilization_percent if value_json.gpu.amd and value_json.gpu.amd|length > 0 else 0 }}")},
|
|
{"sensor", "gpu_amd_temperature", build_sensor_config("AMD GPU Temperature", "#{base_topic}", "gpu.amd[0].temperature_c", "°C", "mdi:thermometer", "{{ value_json.gpu.amd[0].temperature_c if value_json.gpu.amd and value_json.gpu.amd|length > 0 else none }}")},
|
|
|
|
# Disk Sensors - Main filesystem usage
|
|
{"sensor", "disk_root_usage", build_sensor_config("Root Disk Usage", "#{base_topic}", "disk.disks", "%", "mdi:harddisk", "{{ (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/') | list | first).use_percent if value_json.disk.disks else 0 }}")},
|
|
{"sensor", "disk_home_usage", build_sensor_config("Home Disk Usage", "#{base_topic}", "disk.disks", "%", "mdi:harddisk", "{{ (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/home') | list | first).use_percent if (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/home') | list) else 0 }}")},
|
|
|
|
# Network Sensors - Primary interface throughput
|
|
{"sensor", "network_rx_throughput", build_sensor_config("Network RX Throughput", "#{base_topic}", "network", "B/s", "mdi:download-network", "{{ value_json.network[0].rx_throughput_bps if value_json.network and value_json.network|length > 0 else 0 }}")},
|
|
{"sensor", "network_tx_throughput", build_sensor_config("Network TX Throughput", "#{base_topic}", "network", "B/s", "mdi:upload-network", "{{ value_json.network[0].tx_throughput_bps if value_json.network and value_json.network|length > 0 else 0 }}")},
|
|
|
|
# Binary Sensors for status
|
|
{"binary_sensor", "system_online", build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")}
|
|
]
|
|
end
|
|
|
|
defp build_sensor_config(name, state_topic, value_template_path, unit, icon, custom_template \\ nil) do
|
|
base_config = %{
|
|
name: name,
|
|
state_topic: state_topic,
|
|
value_template: custom_template || "{{ value_json.#{value_template_path} }}",
|
|
icon: icon,
|
|
unique_id: "systant_#{String.replace(state_topic, "/", "_")}_#{String.replace(value_template_path, ".", "_")}",
|
|
origin: %{
|
|
name: "Systant",
|
|
sw_version: Application.spec(:systant, :vsn) |> to_string(),
|
|
support_url: "https://github.com/user/systant"
|
|
}
|
|
}
|
|
|
|
if unit do
|
|
Map.put(base_config, :unit_of_measurement, unit)
|
|
else
|
|
base_config
|
|
end
|
|
end
|
|
|
|
defp build_binary_sensor_config(name, state_topic, icon, device_class) do
|
|
%{
|
|
name: name,
|
|
state_topic: state_topic,
|
|
value_template: "{{ 'ON' if value_json.timestamp else 'OFF' }}",
|
|
device_class: device_class,
|
|
icon: icon,
|
|
unique_id: "systant_#{String.replace(state_topic, "/", "_")}_online",
|
|
origin: %{
|
|
name: "Systant",
|
|
sw_version: Application.spec(:systant, :vsn) |> to_string(),
|
|
support_url: "https://github.com/user/systant"
|
|
}
|
|
}
|
|
end
|
|
end |