Implement network throughput monitoring instead of cumulative bytes

- 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>
This commit is contained in:
ryan 2025-08-08 21:02:29 -07:00
parent f8a8421c2f
commit b8e7c48ecf
5 changed files with 97 additions and 15 deletions

View File

@ -43,6 +43,9 @@
# Mosquito for MQTT support # Mosquito for MQTT support
mosquitto mosquitto
# Network monitoring
iftop
]; ];
shellHook = '' shellHook = ''

View File

@ -2,6 +2,7 @@
lib, lib,
beamPackages, beamPackages,
src, src,
iftop,
}: }:
beamPackages.mixRelease rec { beamPackages.mixRelease rec {
@ -10,6 +11,9 @@ beamPackages.mixRelease rec {
inherit src; inherit src;
# Runtime dependencies
buildInputs = [ iftop ];
# Disable distributed Erlang to avoid COOKIE requirement # Disable distributed Erlang to avoid COOKIE requirement
postInstall = '' postInstall = ''
# Create wrapper script that sets proper environment including COOKIE # Create wrapper script that sets proper environment including COOKIE

View File

@ -134,9 +134,9 @@ defmodule Systant.HaDiscovery do
{"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_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 }}")}, {"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 # Network Sensors - Primary interface throughput
{"sensor", "network_rx_bytes", build_sensor_config("Network RX Bytes", "#{base_topic}", "network", "bytes", "mdi:download-network", "{{ value_json.network[0].rx_bytes if value_json.network and value_json.network|length > 0 else 0 }}")}, {"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_bytes", build_sensor_config("Network TX Bytes", "#{base_topic}", "network", "bytes", "mdi:upload-network", "{{ value_json.network[0].tx_bytes 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 Sensors for status
{"binary_sensor", "system_online", build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")} {"binary_sensor", "system_online", build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")}

View File

@ -20,7 +20,8 @@ defmodule Systant.MqttClient do
# Store both configs for later use # Store both configs for later use
state_config = %{ state_config = %{
app_config: app_config, app_config: app_config,
mqtt_config: mqtt_config mqtt_config: mqtt_config,
previous_network_stats: nil
} }
connection_opts = [ connection_opts = [
@ -64,14 +65,14 @@ defmodule Systant.MqttClient do
def handle_info(:publish_startup_stats, state) do def handle_info(:publish_startup_stats, state) do
Logger.info("Publishing initial system metrics") Logger.info("Publishing initial system metrics")
publish_stats(state.app_config, state.mqtt_config) {_stats, updated_state} = collect_and_publish_stats(state)
{:noreply, state} {:noreply, updated_state}
end end
def handle_info(:publish_stats, state) do def handle_info(:publish_stats, state) do
publish_stats(state.app_config, state.mqtt_config) {_stats, updated_state} = collect_and_publish_stats(state)
schedule_stats_publish(state.mqtt_config.publish_interval) schedule_stats_publish(state.mqtt_config.publish_interval)
{:noreply, state} {:noreply, updated_state}
end end
def handle_info(_msg, state) do def handle_info(_msg, state) do
@ -83,6 +84,36 @@ defmodule Systant.MqttClient do
:ok :ok
end end
defp collect_and_publish_stats(state) do
# Collect metrics with previous network stats for throughput calculation
stats = Systant.SystemMetrics.collect_metrics(state.app_config, state.previous_network_stats)
# Store current network stats for next iteration
current_network_stats = case Map.get(stats, :network) do
network_data when is_list(network_data) ->
%{
interfaces: network_data,
timestamp: System.monotonic_time(:second)
}
_ -> nil
end
updated_state = Map.put(state, :previous_network_stats, current_network_stats)
# Publish the stats
payload = Jason.encode!(stats)
case Tortoise.publish(state.mqtt_config.client_id, state.mqtt_config.stats_topic, payload, qos: state.mqtt_config.qos) do
:ok ->
Logger.info("Published system metrics for #{stats.hostname}")
{:error, reason} ->
Logger.error("Failed to publish stats: #{inspect(reason)}")
end
{stats, updated_state}
end
# Legacy function for compatibility if needed
defp publish_stats(app_config, mqtt_config) do defp publish_stats(app_config, mqtt_config) do
stats = Systant.SystemMetrics.collect_metrics(app_config) stats = Systant.SystemMetrics.collect_metrics(app_config)

View File

@ -9,7 +9,7 @@ defmodule Systant.SystemMetrics do
@doc """ @doc """
Collect system metrics based on configuration Collect system metrics based on configuration
""" """
def collect_metrics(config \\ nil) do def collect_metrics(config \\ nil, previous_network_stats \\ nil) do
config = config || Systant.Config.load_config() config = config || Systant.Config.load_config()
base_metrics = %{ base_metrics = %{
@ -27,7 +27,7 @@ defmodule Systant.SystemMetrics do
"memory" -> Map.put(acc, :memory, collect_memory_metrics(config)) "memory" -> Map.put(acc, :memory, collect_memory_metrics(config))
"disk" -> Map.put(acc, :disk, collect_disk_metrics(config)) "disk" -> Map.put(acc, :disk, collect_disk_metrics(config))
"gpu" -> Map.put(acc, :gpu, collect_gpu_metrics(config)) "gpu" -> Map.put(acc, :gpu, collect_gpu_metrics(config))
"network" -> Map.put(acc, :network, collect_network_metrics(config)) "network" -> Map.put(acc, :network, collect_network_metrics(config, previous_network_stats))
"temperature" -> Map.put(acc, :temperature, collect_temperature_metrics(config)) "temperature" -> Map.put(acc, :temperature, collect_temperature_metrics(config))
"processes" -> Map.put(acc, :processes, collect_process_metrics(config)) "processes" -> Map.put(acc, :processes, collect_process_metrics(config))
"system" -> Map.put(acc, :system, collect_system_info(config)) "system" -> Map.put(acc, :system, collect_system_info(config))
@ -73,10 +73,10 @@ defmodule Systant.SystemMetrics do
end end
@doc """ @doc """
Collect network interface statistics Collect network interface statistics with throughput calculation
""" """
def collect_network_metrics(config) do def collect_network_metrics(config, previous_stats \\ nil) do
get_network_stats(config) get_network_stats(config, previous_stats)
end end
@doc """ @doc """
@ -681,19 +681,23 @@ defmodule Systant.SystemMetrics do
end end
end end
defp get_network_stats(config) do defp get_network_stats(config, previous_stats \\ nil) do
network_config = Systant.Config.get(config, ["network"]) || %{} network_config = Systant.Config.get(config, ["network"]) || %{}
current_time = System.monotonic_time(:second)
try do try do
case File.read("/proc/net/dev") do case File.read("/proc/net/dev") do
{:ok, content} -> {:ok, content} ->
content current_interfaces = content
|> String.split("\n") |> String.split("\n")
|> Enum.drop(2) |> Enum.drop(2)
|> Enum.filter(&(String.trim(&1) != "")) |> Enum.filter(&(String.trim(&1) != ""))
|> Enum.map(&parse_network_interface/1) |> Enum.map(&parse_network_interface/1)
|> Enum.filter(&(&1 != nil)) |> Enum.filter(&(&1 != nil))
|> filter_network_interfaces(network_config) |> filter_network_interfaces(network_config)
|> Enum.map(&calculate_throughput(&1, previous_stats, current_time))
current_interfaces
_ -> [] _ -> []
end end
rescue rescue
@ -794,4 +798,44 @@ defmodule Systant.SystemMetrics do
end end
end end
defp calculate_throughput(current_interface, previous_stats, current_time) do
case previous_stats do
%{interfaces: prev_interfaces, timestamp: prev_time} ->
# Find matching interface in previous data
prev_interface = Enum.find(prev_interfaces, &(&1.interface == current_interface.interface))
if prev_interface && prev_time do
time_diff = current_time - prev_time
if time_diff > 0 do
rx_bytes_diff = current_interface.rx_bytes - prev_interface.rx_bytes
tx_bytes_diff = current_interface.tx_bytes - prev_interface.tx_bytes
# Calculate bytes per second
rx_throughput = max(0, rx_bytes_diff / time_diff)
tx_throughput = max(0, tx_bytes_diff / time_diff)
current_interface
|> Map.put(:rx_throughput_bps, Float.round(rx_throughput, 2))
|> Map.put(:tx_throughput_bps, Float.round(tx_throughput, 2))
else
# First measurement or time error
current_interface
|> Map.put(:rx_throughput_bps, 0.0)
|> Map.put(:tx_throughput_bps, 0.0)
end
else
# No previous data for this interface
current_interface
|> Map.put(:rx_throughput_bps, 0.0)
|> Map.put(:tx_throughput_bps, 0.0)
end
_ ->
# No previous data at all
current_interface
|> Map.put(:rx_throughput_bps, 0.0)
|> Map.put(:tx_throughput_bps, 0.0)
end
end
end end