371 lines
12 KiB
Elixir
371 lines
12 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 "Systant"
|
|
|
|
@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)
|
|
|
|
# Publish command buttons if commands are enabled
|
|
commands_config = Systant.Config.get(app_config, ["commands"]) || %{}
|
|
if commands_config["enabled"] do
|
|
publish_command_discoveries(client_pid, hostname, device_config, discovery_prefix, app_config)
|
|
end
|
|
|
|
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 publish_command_discoveries(client_pid, hostname, device_config, discovery_prefix, app_config) do
|
|
commands_config = Systant.Config.get(app_config, ["commands"]) || %{}
|
|
available_commands = commands_config["available"] || []
|
|
|
|
# Clear stale command slots (up to 2x current command count, minimum 10)
|
|
max_slots = max(length(available_commands) * 2, 10)
|
|
for i <- 0..(max_slots - 1) do
|
|
topic = "#{discovery_prefix}/button/#{hostname}/command_#{i}/config"
|
|
Tortoise.publish(client_pid, topic, "", retain: true)
|
|
end
|
|
|
|
# Publish actual command buttons
|
|
available_commands
|
|
|> Enum.with_index()
|
|
|> Enum.each(fn {cmd, index} ->
|
|
button_config = build_command_button_config(cmd, hostname, device_config)
|
|
topic = "#{discovery_prefix}/button/#{hostname}/command_#{index}/config"
|
|
payload = Jason.encode!(button_config)
|
|
|
|
Tortoise.publish(client_pid, topic, payload, retain: true)
|
|
end)
|
|
|
|
Logger.info("Published #{length(available_commands)} command buttons for #{hostname}")
|
|
end
|
|
|
|
defp build_device_config(hostname) do
|
|
%{
|
|
identifiers: ["systant_#{hostname}"],
|
|
name: hostname |> String.capitalize(),
|
|
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.rx_throughput",
|
|
"MB/s",
|
|
"mdi:download-network",
|
|
"{{ (value_json.network[0].rx_throughput_bps | float / 1024 / 1024) | round(2) 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.tx_throughput",
|
|
"MB/s",
|
|
"mdi:upload-network",
|
|
"{{ (value_json.network[0].tx_throughput_bps | float / 1024 / 1024) | round(2) if value_json.network and value_json.network|length > 0 else 0 }}"
|
|
)},
|
|
|
|
# Status sensors
|
|
{"sensor", "last_seen",
|
|
build_sensor_config("Last Seen", "#{base_topic}", "timestamp", nil, "mdi:clock-outline", "{{ value_json.timestamp }}")}
|
|
]
|
|
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, ".", "_")}",
|
|
availability: %{
|
|
topic: state_topic,
|
|
value_template: """
|
|
{% set last_seen = as_timestamp(value_json.timestamp) %}
|
|
{% set now = as_timestamp(now()) %}
|
|
{{ 'online' if (now - last_seen) < 180 else 'offline' }}
|
|
"""
|
|
},
|
|
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_command_button_config(cmd, hostname, device_config) do
|
|
trigger = cmd["trigger"]
|
|
name = cmd["description"] || "#{String.capitalize(trigger)} Command"
|
|
icon = cmd["icon"] || "mdi:console-line"
|
|
|
|
%{
|
|
name: name,
|
|
command_topic: "systant/#{hostname}/commands",
|
|
payload_press: Jason.encode!(%{command: trigger}),
|
|
availability: %{
|
|
topic: "systant/#{hostname}/stats",
|
|
value_template: """
|
|
{% set last_seen = as_timestamp(value_json.timestamp) %}
|
|
{% set now = as_timestamp(now()) %}
|
|
{{ 'online' if (now - last_seen) < 180 else 'offline' }}
|
|
"""
|
|
},
|
|
device: device_config,
|
|
icon: icon,
|
|
unique_id: "systant_#{hostname}_command_#{trigger}",
|
|
origin: %{
|
|
name: "Systant",
|
|
sw_version: Application.spec(:systant, :vsn) |> to_string(),
|
|
support_url: "https://github.com/user/systant"
|
|
}
|
|
}
|
|
end
|
|
end
|