// Proof that the 30-second health heartbeat works end to end, over MQTT. // // Publishes a heartbeat to the live broker as a throwaway terminal, then polls // the public API until it shows online — and keeps polling past the TTL so the // automatic expiry is visible too. Nothing is written to Postgres; presence // lives in Redis under a TTL and cleans itself up. // // REQUIRES pos_terminal CREDENTIALS. The MQTT_USER in .env is pos_ingest, which // the broker ACL deliberately denies publish on the health topic — it may only // write acks and the catalogue. Running this with the ingest account connects // fine and then silently drops every publish, because Mosquitto answers an // ACL-denied QoS 1 publish with a PUBACK and discards it. That looks exactly // like a broken consumer and cost an hour of misdiagnosis; set MQTT_USER and // MQTT_PASSWORD to the terminal account before believing a negative result. // // The HTTP path needs none of this — see POST /pos/health, which is what the // fix on v1.3.96 added and how the endpoint was actually verified. // // go run ./scratch/healthproof package main import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "time" mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/joho/godotenv" ) const ( locationID = "1135" terminalID = "TPROOF" // throwaway; disappears on its own when the TTL lapses apiBase = "https://fiesta.nearle.app/live/api/v1/pos" ) func main() { _ = godotenv.Load() brokerURL := os.Getenv("MQTT_URL") if brokerURL == "" { log.Fatal("MQTT_URL not set") } opts := mqtt.NewClientOptions(). AddBroker(brokerURL). SetClientID("healthproof-" + terminalID). SetUsername(os.Getenv("MQTT_USER")). SetPassword(os.Getenv("MQTT_PASSWORD")). SetConnectTimeout(10 * time.Second) client := mqtt.NewClient(opts) if t := client.Connect(); t.Wait() && t.Error() != nil { log.Fatal("connect: ", t.Error()) } defer client.Disconnect(250) fmt.Printf("connected to %s as %s\n\n", brokerURL, terminalID) fmt.Println("BEFORE — has this terminal ever been seen?") show() // Exactly what the till sends every 30 seconds. health, _ := json.Marshal(map[string]any{ "schema": 1, "status": "online", "terminal_id": terminalID, "location_id": locationID, "store_name": "Ragul stores Selvapuram", "app_version": "1.1.0", "pending_bills": 0, "pending_registrations": 0, "today_bills": 17, "today_amount": 2510.0, "printer_reachable": true, "reported_at": time.Now().UTC().Format(time.RFC3339), }) topic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID) if t := client.Publish(topic, 1, false, health); t.Wait() && t.Error() != nil { log.Fatal("publish: ", t.Error()) } fmt.Printf("\npublished one heartbeat to %s\n", topic) time.Sleep(2 * time.Second) fmt.Println("\nAFTER one heartbeat:") show() // The TTL is 90s and a real till refreshes every 30s, so it never lapses // while the till is alive. Stopping here is what a till being switched off // looks like. fmt.Println("\nnow going quiet, as a till that was switched off would.") fmt.Println("presence TTL is 90s, so it should drop off on its own:") for _, wait := range []int{30, 30, 35} { time.Sleep(time.Duration(wait) * time.Second) fmt.Printf("\n+%ds since the last heartbeat:\n", wait) show() } } func show() { resp, err := http.Get(fmt.Sprintf("%s/health/location?location_id=%s", apiBase, locationID)) if err != nil { fmt.Println(" API unreachable:", err) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var out struct { Details struct { Online int `json:"online"` Total int `json:"total"` Terminals []struct { Terminalid string `json:"terminal_id"` Status string `json:"status"` Reason string `json:"reason"` Todaybills int `json:"today_bills"` Todayamount float64 `json:"today_amount"` Reportedat string `json:"reported_at"` } `json:"terminals"` } `json:"details"` } if err := json.Unmarshal(body, &out); err != nil { fmt.Println(" unparseable:", string(body)[:200]) return } fmt.Printf(" online %d of %d\n", out.Details.Online, out.Details.Total) for _, t := range out.Details.Terminals { mark := " " if t.Terminalid == terminalID { mark = ">" } extra := t.Reason if t.Status == "online" { extra = fmt.Sprintf("today %d bills / Rs %.0f, reported %s", t.Todaybills, t.Todayamount, t.Reportedat) } fmt.Printf(" %s %-8s %-8s %s\n", mark, t.Terminalid, t.Status, extra) } }