Files
Suriya 11595ad415 Record the probes that touched live data
seedprices is the only account of what the 15 seeded retail prices were and how
to put the zeros back — products at 1135 and 1185 were all at 0, so nothing was
sellable and the terminal could not be exercised at all. It refuses to overwrite
a price a human already set, so re-running it is safe.

termfixcleanup removes the one bill posted to prove the terminalid fix against
production. Named by its own terminalorderid rather than by date or by "the
newest row": pos_orders holds real takings and is not a table to run an
unbounded DELETE against.

healthproof carries a warning it did not have when it was written. MQTT_USER in
.env is pos_ingest, which the ACL denies publish on the health topic, and
Mosquitto answers an ACL-denied QoS 1 publish with a PUBACK before discarding
it. So the tool connects, reports success, and nothing arrives — indistinguishable
from a dead consumer, which is how it read for an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30

142 lines
4.4 KiB
Go

// 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)
}
}