main.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package main
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "encoding/json"
  6. "fmt"
  7. "log"
  8. "os"
  9. "os/signal"
  10. "path/filepath"
  11. "strconv"
  12. "strings"
  13. "syscall"
  14. "time"
  15. mqtt "github.com/eclipse/paho.mqtt.golang"
  16. "github.com/spf13/cobra"
  17. )
  18. var (
  19. mqttHost string
  20. mqttPort int
  21. mqttUsername string
  22. mqttPassword string
  23. tempDir string
  24. deviceID string
  25. rootCmd = &cobra.Command{
  26. Use: "ha-gpu-ccd",
  27. Short: "Home Assistant GPU CCD Temperature Monitor",
  28. Long: "Monitor GPU temperatures from Home Assistant MQTT and write to sysfs format files for CCD integration",
  29. Run: run,
  30. }
  31. )
  32. func init() {
  33. rootCmd.PersistentFlags().StringVar(&mqttHost, "mqtt-host", "localhost", "MQTT broker host")
  34. rootCmd.PersistentFlags().IntVar(&mqttPort, "mqtt-port", 1883, "MQTT broker port")
  35. rootCmd.PersistentFlags().StringVar(&mqttUsername, "mqtt-username", "", "MQTT username")
  36. rootCmd.PersistentFlags().StringVar(&mqttPassword, "mqtt-password", "", "MQTT password")
  37. rootCmd.PersistentFlags().StringVar(&tempDir, "temp-dir", "/tmp", "Directory to write temperature files")
  38. rootCmd.PersistentFlags().StringVar(&deviceID, "device-id", "", "Specific GPU device ID to monitor (leave empty to monitor all devices)")
  39. }
  40. func main() {
  41. if err := rootCmd.Execute(); err != nil {
  42. log.Fatal(err)
  43. }
  44. }
  45. func run(cmd *cobra.Command, args []string) {
  46. log.Printf("Starting ha-gpu-ccd")
  47. log.Printf("MQTT Broker: %s:%d", mqttHost, mqttPort)
  48. log.Printf("Temperature directory: %s", tempDir)
  49. log.Printf("MQTT Username: %s", func() string {
  50. if mqttUsername != "" {
  51. return mqttUsername
  52. }
  53. return "(none)"
  54. }())
  55. // Create temp directory if it doesn't exist
  56. if err := os.MkdirAll(tempDir, 0755); err != nil {
  57. log.Fatalf("Failed to create temp directory %s: %v", tempDir, err)
  58. }
  59. // Setup MQTT client
  60. mqttClient := setupMQTTClient()
  61. defer mqttClient.Disconnect(250)
  62. // Setup graceful shutdown
  63. quit := make(chan os.Signal, 1)
  64. signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
  65. log.Println("Monitoring GPU temperatures from Home Assistant...")
  66. log.Println("Press Ctrl+C to stop")
  67. <-quit
  68. log.Println("Shutting down...")
  69. }
  70. func setupMQTTClient() mqtt.Client {
  71. opts := mqtt.NewClientOptions()
  72. opts.AddBroker(fmt.Sprintf("tcp://%s:%d", mqttHost, mqttPort))
  73. // Generate random suffix for client ID to avoid conflicts
  74. randomBytes := make([]byte, 3)
  75. if _, err := rand.Read(randomBytes); err == nil {
  76. clientID := fmt.Sprintf("ha-gpu-ccd-%s", hex.EncodeToString(randomBytes))
  77. opts.SetClientID(clientID)
  78. } else {
  79. // Fallback to timestamp if random generation fails
  80. clientID := fmt.Sprintf("ha-gpu-ccd-%d", time.Now().Unix())
  81. opts.SetClientID(clientID)
  82. }
  83. opts.SetUsername(mqttUsername)
  84. opts.SetPassword(mqttPassword)
  85. opts.SetAutoReconnect(true)
  86. opts.SetConnectRetry(true)
  87. opts.SetConnectRetryInterval(10 * time.Second)
  88. opts.SetKeepAlive(60 * time.Second)
  89. opts.SetOnConnectHandler(func(client mqtt.Client) {
  90. log.Println("Connected to MQTT broker")
  91. // Wait a moment for connection to stabilize
  92. go func() {
  93. time.Sleep(1 * time.Second)
  94. // Check if connection is still active
  95. if !client.IsConnected() {
  96. log.Println("Connection lost during stabilization")
  97. return
  98. }
  99. // Subscribe to temperature topics after successful connection
  100. if err := subscribeToTemperatureTopics(client); err != nil {
  101. log.Printf("Failed to subscribe to temperature topics: %v", err)
  102. }
  103. }()
  104. })
  105. opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
  106. log.Printf("Connection lost to MQTT broker: %v", err)
  107. })
  108. client := mqtt.NewClient(opts)
  109. if token := client.Connect(); token.Wait() && token.Error() != nil {
  110. log.Fatalf("Failed to connect to MQTT broker: %v", token.Error())
  111. }
  112. // Wait a bit to ensure connection is stable
  113. time.Sleep(2 * time.Second)
  114. return client
  115. }
  116. func subscribeToTemperatureTopics(client mqtt.Client) error {
  117. var topic string
  118. if deviceID != "" {
  119. // Subscribe to specific device temperature topic
  120. topic = fmt.Sprintf("homeassistant/sensor/nvml-gpu/%s_temperature/state", deviceID)
  121. } else {
  122. // Subscribe to all GPU temperature topics using # wildcard
  123. topic = "homeassistant/sensor/nvml-gpu/#"
  124. }
  125. // Wait for subscription with timeout
  126. token := client.Subscribe(topic, 1, onTemperatureMessage)
  127. if !token.WaitTimeout(10 * time.Second) {
  128. return fmt.Errorf("timeout waiting for subscription to %s", topic)
  129. }
  130. if token.Error() != nil {
  131. return fmt.Errorf("failed to subscribe to %s: %v", topic, token.Error())
  132. }
  133. log.Printf("Successfully subscribed to: %s", topic)
  134. return nil
  135. }
  136. func onTemperatureMessage(client mqtt.Client, msg mqtt.Message) {
  137. topic := msg.Topic()
  138. payload := string(msg.Payload())
  139. // Filter for temperature topics only
  140. // Topic format: homeassistant/sensor/nvml-gpu/{DEVICEID}_temperature/state
  141. if !strings.Contains(topic, "_temperature/state") {
  142. // Ignore non-temperature topics
  143. return
  144. }
  145. parts := strings.Split(topic, "/")
  146. if len(parts) < 4 {
  147. log.Printf("Invalid topic format: %s", topic)
  148. return
  149. }
  150. deviceSensor := parts[3] // This should be {DEVICEID}_temperature
  151. if !strings.HasSuffix(deviceSensor, "_temperature") {
  152. log.Printf("Topic does not end with _temperature: %s", topic)
  153. return
  154. }
  155. deviceID := strings.TrimSuffix(deviceSensor, "_temperature")
  156. // Parse temperature from JSON payload
  157. var temperature float64
  158. if err := json.Unmarshal([]byte(payload), &temperature); err != nil {
  159. log.Printf("Failed to parse temperature from payload '%s': %v", payload, err)
  160. return
  161. }
  162. log.Printf("Received temperature for device %s: %.1f°C", deviceID, temperature)
  163. // Convert temperature to sysfs format (millidegrees)
  164. // Example: 80.5°C -> 80500
  165. tempMillidegrees := int(temperature * 1000)
  166. // Write to temp file
  167. tempFile := filepath.Join(tempDir, fmt.Sprintf("temp_%s", deviceID))
  168. if err := writeTemperatureFile(tempFile, tempMillidegrees); err != nil {
  169. log.Printf("Failed to write temperature file %s: %v", tempFile, err)
  170. return
  171. }
  172. log.Printf("Updated %s: %d (%.1f°C)", tempFile, tempMillidegrees, temperature)
  173. }
  174. func writeTemperatureFile(filename string, tempMillidegrees int) error {
  175. content := strconv.Itoa(tempMillidegrees)
  176. // Create or overwrite the file
  177. file, err := os.Create(filename)
  178. if err != nil {
  179. return fmt.Errorf("failed to create file: %v", err)
  180. }
  181. defer file.Close()
  182. // Write the temperature value
  183. if _, err := file.WriteString(content); err != nil {
  184. return fmt.Errorf("failed to write temperature: %v", err)
  185. }
  186. return nil
  187. }