systant/server/lib/systant/mqtt_client.ex
ryan fbd0c6dcf6 Complete secure MQTT command system with clean JSON responses
- Remove null error field from successful command responses for cleaner JSON
- Fix client_id consistency between MqttClient and MqttHandler for reliable publishing
- Add comprehensive command system documentation to CLAUDE.md:
  * User-configurable commands via systant.toml
  * Enterprise security features (whitelist-only, parameter validation, timeouts)
  * Simple command interface: {"command":"trigger","params":[...]}
  * Built-in commands and response format examples
  * Complete MQTT topic structure documentation

Command system now production-ready with:
 Secure execution (no arbitrary shell commands)
 Clean JSON responses (no null fields)
 Comprehensive logging and audit trail
 User-customizable command definitions
 Request/response correlation with auto-generated IDs

Ready for Home Assistant integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-05 22:40:26 -07:00

90 lines
2.8 KiB
Elixir

defmodule Systant.MqttClient do
use GenServer
require Logger
@moduledoc """
MQTT client for publishing system stats and handling commands
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(_opts) do
# Load the TOML-based configuration
app_config = Systant.Config.load_config()
mqtt_config = Systant.Config.mqtt_config(app_config)
Logger.info("Starting MQTT client with config: #{inspect(mqtt_config)}")
# Store both configs for later use
state_config = %{
app_config: app_config,
mqtt_config: mqtt_config
}
connection_opts = [
client_id: mqtt_config.client_id,
server: {Tortoise.Transport.Tcp, host: to_charlist(mqtt_config.host), port: mqtt_config.port},
handler: {Systant.MqttHandler, [client_id: mqtt_config.client_id]},
user_name: mqtt_config.username,
password: mqtt_config.password,
subscriptions: [{mqtt_config.command_topic, mqtt_config.qos}]
]
case Tortoise.Connection.start_link(connection_opts) do
{:ok, _pid} ->
Logger.info("MQTT client connected successfully")
# Send system metrics after a short delay to ensure dashboard is ready
startup_delay = Systant.Config.get(app_config, ["general", "startup_delay"]) || 5000
Process.send_after(self(), :publish_startup_stats, startup_delay)
Logger.info("Will publish initial stats in #{startup_delay}ms")
schedule_stats_publish(mqtt_config.publish_interval)
{:ok, state_config}
{:error, reason} ->
Logger.error("Failed to connect to MQTT broker: #{inspect(reason)}")
{:stop, reason}
end
end
def handle_info(:publish_startup_stats, state) do
Logger.info("Publishing initial system metrics")
publish_stats(state.app_config, state.mqtt_config)
{:noreply, state}
end
def handle_info(:publish_stats, state) do
publish_stats(state.app_config, state.mqtt_config)
schedule_stats_publish(state.mqtt_config.publish_interval)
{:noreply, state}
end
def handle_info(_msg, state) do
{:noreply, state}
end
def terminate(reason, _state) do
Logger.info("MQTT client terminating: #{inspect(reason)}")
:ok
end
defp publish_stats(app_config, mqtt_config) do
stats = Systant.SystemMetrics.collect_metrics(app_config)
payload = Jason.encode!(stats)
case Tortoise.publish(mqtt_config.client_id, mqtt_config.stats_topic, payload, qos: mqtt_config.qos) do
:ok ->
Logger.info("Published system metrics for #{stats.hostname}")
{:error, reason} ->
Logger.error("Failed to publish stats: #{inspect(reason)}")
end
end
defp schedule_stats_publish(interval) do
Process.send_after(self(), :publish_stats, interval)
end
end