systant/server/lib/systant/mqtt_handler.ex
ryan 505aede8d3 Fix config file loading and MQTT connection error handling
Config file loading fixes:
- Fix tilde expansion in config paths (~/.config/systant/systant.toml)
- Add detailed debug logging for config file search paths
- Properly expand home directory paths with System.user_home()

MQTT connection improvements:
- Add clearer connection status logging with broker host:port
- Improve error handling in connection callback
- Better error messages when connection fails or is lost
- More detailed initial connection logging

These fixes address production deployment issues where config files
aren't found and MQTT connection errors aren't properly reported.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-09 18:54:42 -07:00

163 lines
5.1 KiB
Elixir

defmodule Systant.MqttHandler do
@moduledoc """
Custom MQTT handler for processing command messages
"""
@behaviour Tortoise.Handler
require Logger
def init(args) do
Logger.info("Initializing MQTT handler")
# Get the client_id from the passed arguments
client_id = Keyword.get(args, :client_id)
Logger.info("Handler initialized with client_id: #{client_id}")
state = %{client_id: client_id}
{:ok, state}
end
def connection(status, state) do
case status do
:up ->
Logger.info("MQTT connection established successfully")
:down ->
Logger.error("MQTT connection lost - check MQTT broker availability and configuration")
:terminating ->
Logger.info("MQTT connection terminating")
{:error, reason} ->
Logger.error("MQTT connection failed: #{inspect(reason)}")
other ->
Logger.error("MQTT connection status unknown: #{inspect(other)}")
end
{:ok, state}
end
def subscription(status, topic_filter, state) do
case status do
:up ->
Logger.info("Subscribed to #{topic_filter}")
:down ->
Logger.warning("Subscription to #{topic_filter} lost")
end
{:ok, state}
end
def handle_message(topic, payload, state) do
# Topic can come as a list or string, normalize it
topic_str = case topic do
topic when is_binary(topic) -> topic
topic when is_list(topic) -> Enum.join(topic, "/")
_ -> to_string(topic)
end
Logger.info("Received MQTT message on topic: #{topic_str}")
# Only process command topics
if String.contains?(topic_str, "/commands") do
process_command_message(topic_str, payload, state)
else
Logger.debug("Ignoring non-command message on topic: #{topic_str}")
end
{:ok, state}
end
def terminate(reason, _state) do
Logger.info("MQTT handler terminating: #{inspect(reason)}")
:ok
end
# Private functions
defp process_command_message(topic, payload, state) do
try do
# Parse the JSON command
case Jason.decode(payload) do
{:ok, command_data} ->
Logger.info("Processing command: #{inspect(command_data)}")
execute_and_respond(command_data, topic, state)
{:error, reason} ->
Logger.error("Failed to parse command JSON: #{inspect(reason)}")
send_error_response(topic, "Invalid JSON format", nil, state)
end
rescue
error ->
Logger.error("Error processing command: #{inspect(error)}")
send_error_response(topic, "Command processing failed", nil, state)
end
end
defp execute_and_respond(command_data, topic, state) do
# Load current configuration
config = Systant.Config.load_config()
# Use client_id from handler state
client_id = state.client_id
# Handle special "list" command to show available commands
if command_data["command"] == "list" do
available_commands = Systant.CommandExecutor.list_available_commands(config)
response = %{
request_id: command_data["request_id"] || generate_request_id(),
command: "list",
status: "success",
output: "Available commands: #{Enum.map(available_commands, &(&1.trigger)) |> Enum.join(", ")}",
data: available_commands,
execution_time: 0.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}
response_topic = String.replace(topic, "/commands", "/responses")
response_payload = Jason.encode!(response)
Tortoise.publish_sync(client_id, response_topic, response_payload, qos: 0)
else
case Systant.CommandExecutor.execute_command(command_data, config) do
{:ok, response} ->
# Send response to the response topic
response_topic = String.replace(topic, "/commands", "/responses")
response_payload = Jason.encode!(response)
case Tortoise.publish_sync(client_id, response_topic, response_payload, qos: 0) do
:ok ->
Logger.info("Command response sent successfully")
{:error, reason} ->
Logger.error("Failed to send command response: #{inspect(reason)}")
end
{:error, reason} ->
send_error_response(topic, reason, command_data["request_id"], state)
end
end
end
defp send_error_response(topic, error_message, request_id, state) do
client_id = state.client_id
response_topic = String.replace(topic, "/commands", "/responses")
error_response = %{
request_id: request_id || "unknown",
command: "unknown",
status: "error",
output: "",
error: error_message,
execution_time: 0.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}
response_payload = Jason.encode!(error_response)
case Tortoise.publish_sync(client_id, response_topic, response_payload, qos: 0) do
:ok ->
Logger.info("Error response sent successfully")
{:error, reason} ->
Logger.error("Failed to send error response: #{inspect(reason)}")
end
end
defp generate_request_id do
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
end
end