diff --git a/CLAUDE.md b/CLAUDE.md index 65c3f92..7a65cf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,11 +187,35 @@ mosquitto_pub -t "systant/hostname/commands" -m '{"command":"restart","params":[ mosquitto_sub -t "systant/+/responses" ``` -### Phase 3: Home Assistant Integration -- Custom MQTT integration following Home Assistant patterns -- Auto-discovery of systant hosts via MQTT discovery protocol -- Create entities for metrics (sensors) and commands (buttons/services) -- Dashboard cards and automation support +### Phase 3: Home Assistant Integration (Completed) +- ✅ **MQTT Auto-Discovery**: `server/lib/systant/ha_discovery.ex` - Publishes HA discovery configurations for automatic device registration +- ✅ **Device Registration**: Creates unified "Systant {hostname}" device in Home Assistant with comprehensive sensor suite +- ✅ **Sensor Auto-Discovery**: CPU load averages, memory usage, system uptime, temperatures, GPU metrics, disk usage, network stats +- ✅ **Configuration Integration**: TOML-based enable/disable with `homeassistant.discovery_enabled` setting +- ✅ **Value Templates**: Proper JSON path extraction for nested metrics data with error handling +- ✅ **Real-time Updates**: Seamless integration with existing MQTT stats publishing - no additional topics needed + +#### Home Assistant Integration Features +- **Automatic Discovery**: No custom integration required - uses standard MQTT discovery protocol +- **Device Grouping**: All sensors grouped under single "Systant {hostname}" device for clean organization +- **Comprehensive Metrics**: CPU, memory, disk, GPU (NVIDIA/AMD), network, temperature, and system sensors +- **Configuration Control**: Enable/disable discovery via `systant.toml` configuration +- **Template Flexibility**: Advanced Jinja2 templates handle optional/missing data gracefully +- **Topic Structure**: Discovery on `homeassistant/#`, stats remain on `systant/{hostname}/stats` + +#### Setup Instructions +1. **Configure MQTT Discovery**: Set `homeassistant.discovery_enabled = true` in `systant.toml` +2. **Start Systant**: Discovery messages published automatically on startup (1s after MQTT connection) +3. **Check Home Assistant**: Device and sensors appear automatically in MQTT integration +4. **Verify Metrics**: All sensors should show current values within 30 seconds + +#### Available Sensors +- **CPU**: Load averages (1m, 5m, 15m), temperature +- **Memory**: Usage percentage, used/total in GB +- **Disk**: Root and home filesystem usage percentages +- **GPU**: NVIDIA/AMD utilization, temperature, memory usage +- **Network**: RX/TX bytes for primary interface +- **System**: Uptime in hours, kernel version, online status ### Future Plans - Multi-host deployment for comprehensive system monitoring diff --git a/flake.nix b/flake.nix index 2f83987..0ac0bc1 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,9 @@ # Database for development postgresql + + # Mosquito for MQTT support + mosquitto ]; shellHook = '' diff --git a/server/lib/systant/config.ex b/server/lib/systant/config.ex index dd05322..eee5afd 100644 --- a/server/lib/systant/config.ex +++ b/server/lib/systant/config.ex @@ -70,6 +70,10 @@ defmodule Systant.Config do "password" => "", "qos" => 0 }, + "homeassistant" => %{ + "discovery_enabled" => true, + "discovery_prefix" => "homeassistant" + }, "logging" => %{ "level" => "info", "log_config_changes" => true, diff --git a/server/lib/systant/ha_discovery.ex b/server/lib/systant/ha_discovery.ex new file mode 100644 index 0000000..32c6733 --- /dev/null +++ b/server/lib/systant/ha_discovery.ex @@ -0,0 +1,182 @@ +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////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 + {"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_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 }}")}, + + # 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 \ No newline at end of file diff --git a/server/lib/systant/mqtt_client.ex b/server/lib/systant/mqtt_client.ex index e55dcaa..ad35963 100644 --- a/server/lib/systant/mqtt_client.ex +++ b/server/lib/systant/mqtt_client.ex @@ -41,6 +41,10 @@ defmodule Systant.MqttClient do Process.send_after(self(), :publish_startup_stats, startup_delay) Logger.info("Will publish initial stats in #{startup_delay}ms") + # Publish Home Assistant discovery after MQTT connection + Process.send_after(self(), :publish_ha_discovery, 1000) + Logger.info("Will publish HA discovery in 1000ms") + schedule_stats_publish(mqtt_config.publish_interval) {:ok, state_config} @@ -50,6 +54,14 @@ defmodule Systant.MqttClient do end end + def handle_info(:publish_ha_discovery, state) do + Logger.info("Publishing Home Assistant discovery configuration") + # Get hostname from system metrics (reuse existing logic) + stats = Systant.SystemMetrics.collect_metrics(state.app_config) + Systant.HaDiscovery.publish_discovery(state.mqtt_config.client_id, stats.hostname, state.app_config) + {:noreply, state} + end + def handle_info(:publish_startup_stats, state) do Logger.info("Publishing initial system metrics") publish_stats(state.app_config, state.mqtt_config) diff --git a/server/systant.toml b/server/systant.toml index 67400b9..23f6ff6 100644 --- a/server/systant.toml +++ b/server/systant.toml @@ -88,6 +88,11 @@ password = "" # QoS level (0, 1, or 2) qos = 0 +# Home Assistant MQTT Discovery Configuration +[homeassistant] +discovery_enabled = true # Enable/disable HA auto-discovery +discovery_prefix = "homeassistant" # HA discovery topic prefix + [logging] # Log level: "debug", "info", "warning", "error" level = "info" diff --git a/server/systant.toml.example b/server/systant.toml.example new file mode 100644 index 0000000..5e59634 --- /dev/null +++ b/server/systant.toml.example @@ -0,0 +1,108 @@ +# Systant Configuration Example +# Copy to systant.toml and customize for your environment + +[general] +enabled_modules = ["cpu", "memory", "disk", "gpu", "network", "temperature", "processes", "system"] +collection_interval = 30000 # milliseconds +startup_delay = 5000 # milliseconds + +[mqtt] +host = "localhost" # MQTT broker hostname/IP +port = 1883 # MQTT broker port +client_id_prefix = "systant" # Prefix for MQTT client ID +username = "" # MQTT username (optional) +password = "" # MQTT password (optional) +qos = 0 # MQTT QoS level + +# Home Assistant MQTT Discovery Configuration +[homeassistant] +discovery_enabled = true # Enable/disable HA auto-discovery +discovery_prefix = "homeassistant" # HA discovery topic prefix + +[cpu] +enabled = true + +[memory] +enabled = true +show_detailed = true + +[disk] +enabled = true +include_mounts = [] # Only include these mounts (empty = all) +exclude_mounts = ["/snap", "/boot", "/dev", "/sys", "/proc", "/run", "/tmp"] +exclude_types = ["tmpfs", "devtmpfs", "squashfs", "overlay"] +min_usage_percent = 1 # Minimum usage to report + +[gpu] +enabled = true +nvidia_enabled = true +amd_enabled = true +max_gpus = 8 + +[network] +enabled = true +include_interfaces = [] # Only include these interfaces (empty = all) +exclude_interfaces = ["lo", "docker0", "br-", "veth", "virbr"] +min_bytes_threshold = 1024 # Minimum traffic to report + +[temperature] +enabled = true +cpu_temp_enabled = true +sensors_enabled = true +temp_unit = "celsius" + +[processes] +enabled = true +max_processes = 10 +sort_by = "cpu" # "cpu" or "memory" +min_cpu_percent = 0.0 +min_memory_percent = 0.0 +max_command_length = 50 + +[system] +enabled = true +include_uptime = true +include_load_average = true +include_kernel_version = true +include_os_info = true + +[commands] +enabled = true +timeout_seconds = 30 +log_executions = true + +# Example commands - customize for your needs +[[commands.available]] +trigger = "restart" +command = "systemctl" +allowed_params = ["nginx", "apache2", "docker", "ssh"] +description = "Restart system services" + +[[commands.available]] +trigger = "info" +command = "uname" +allowed_params = ["-a"] +description = "Show system information" + +[[commands.available]] +trigger = "df" +command = "df" +allowed_params = ["-h", "/", "/home", "/var", "/tmp"] +description = "Show disk usage" + +[[commands.available]] +trigger = "ps" +command = "ps" +allowed_params = ["aux", "--sort=-pcpu", "--sort=-pmem"] +description = "Show running processes" + +[[commands.available]] +trigger = "ping" +command = "ping" +allowed_params = ["-c", "4", "8.8.8.8", "google.com", "1.1.1.1"] +description = "Network connectivity test" + +[logging] +level = "info" # debug, info, warn, error +log_config_changes = true +log_metric_collection = false \ No newline at end of file