defmodule DaProductApp.TerminalManagement.OtaService do @moduledoc """ Service module for handling OTA (Over-The-Air) configuration push to devices. Implements the TMS to Device communication protocol for configuration updates. """ import Ecto.Query require Logger alias DaProductApp.TerminalManagement alias DaProductApp.TerminalManagement.{OtaConfiguration, TmsTerminal} @doc """ Sends OTA configuration to a device via MQTT. Topic format: /ota/{product_key}/{client_id}/update """ def send_ota_configuration(%OtaConfiguration{} = config) do # Validate configuration before sending with {:ok, validated_config} <- validate_configuration(config), {:ok, topic} <- build_ota_topic(validated_config), {:ok, payload} <- build_ota_payload(validated_config) do Logger.info("Sending OTA configuration to device #{config.device_serial} on topic: #{topic}") case DaProductApp.MQTT.publish(topic, payload, qos: 1) do :ok -> now = DateTime.utc_now() |> DateTime.truncate(:second) TerminalManagement.update_ota_configuration(config, %{ status: "sent", sent_at: now }) {:ok, "Configuration sent successfully"} {:ok, _reference} -> now = DateTime.utc_now() |> DateTime.truncate(:second) TerminalManagement.update_ota_configuration(config, %{ status: "sent", sent_at: now }) {:ok, "Configuration sent successfully"} {:error, reason} -> Logger.error("Failed to send OTA configuration to #{config.device_serial}: #{inspect(reason)}") TerminalManagement.update_ota_configuration(config, %{ status: "failed", error_message: "MQTT publish failed: #{inspect(reason)}" }) {:error, "Failed to send configuration"} end else {:error, reason} -> Logger.error("OTA configuration validation failed: #{reason}") TerminalManagement.update_ota_configuration(config, %{ status: "failed", error_message: reason }) {:error, reason} end end @doc """ Creates and sends a standard merchant configuration update to a device. """ def send_merchant_config_update(device_serial, merchant_config) do attrs = %{ request_id: System.unique_integer([:positive]), device_serial: device_serial, merchant_config: true, merchant_id: merchant_config["merchant_id"], terminal_id: merchant_config["terminal_id"], mqtt_ip: merchant_config["mqtt_ip"] || "testapp.ariticapp.com", mqtt_port: merchant_config["mqtt_port"] || 1883, http_ip: merchant_config["http_ip"] || "demo.ctrmv.com", http_port: merchant_config["http_port"] || 4001, product_key: merchant_config["product_key"] || "pFppbioOCKlo5c8E", product_secret: merchant_config["product_secret"] || "sj2AJl102397fQAV", client_id: device_serial, username: merchant_config["username"] || "user001", mqtt_topic: "/ota/#{merchant_config["product_key"] || "pFppbioOCKlo5c8E"}/#{device_serial}/update", keepalive_time: merchant_config["keepalive_time"] || 300, play_language: merchant_config["play_language"] || 1, heartbeat_interval: merchant_config["heartbeat_interval"] || 300 } case TerminalManagement.create_ota_configuration(attrs) do {:ok, config} -> send_ota_configuration(config) {:error, changeset} -> {:error, "Failed to create configuration: #{inspect(changeset.errors)}"} end end defp validate_configuration(%OtaConfiguration{} = config) do cond do is_nil(config.device_serial) or config.device_serial == "" -> {:error, "Device serial is required"} is_nil(config.product_key) or config.product_key == "" -> {:error, "Product key is required"} is_nil(config.merchant_id) or config.merchant_id == "" -> {:error, "Merchant ID is required"} is_nil(config.terminal_id) or config.terminal_id == "" -> {:error, "Terminal ID is required"} true -> {:ok, config} end end defp build_ota_topic(%OtaConfiguration{} = config) do topic = "/ota/#{config.product_key}/#{config.client_id || config.device_serial}/update" {:ok, topic} end defp build_ota_payload(%OtaConfiguration{} = config) do payload = config |> OtaConfiguration.to_mqtt_payload() |> Jason.encode!() {:ok, payload} end @doc """ Handles acknowledgment from device after receiving OTA configuration. """ def handle_ota_acknowledgment(device_serial, request_id, ack_status) do case TerminalManagement.Repo.get_by(OtaConfiguration, device_serial: device_serial, request_id: request_id ) do nil -> Logger.warning("Received OTA ACK for unknown configuration: #{device_serial}, request_id: #{request_id}") :ok config -> now = DateTime.utc_now() |> DateTime.truncate(:second) status = if ack_status == "OK", do: "acknowledged", else: "failed" TerminalManagement.update_ota_configuration(config, %{ status: status, acknowledged_at: now, error_message: if(ack_status != "OK", do: "Device rejected configuration: #{ack_status}", else: nil) }) Logger.info("OTA configuration #{status} for device #{device_serial}, request_id: #{request_id}") :ok end end @doc """ Gets the latest OTA configuration for a device. """ def get_latest_ota_config(device_serial) do DaProductApp.Repo.one( from c in OtaConfiguration, where: c.device_serial == ^device_serial, order_by: [desc: c.inserted_at], limit: 1 ) end @doc """ Lists pending OTA configurations that need to be sent or retried. """ def list_pending_configurations do DaProductApp.Repo.all( from c in OtaConfiguration, where: c.status in ["pending", "failed"], order_by: [asc: c.inserted_at] ) end end