Files

304 lines
9.4 KiB
Go
Raw Permalink Normal View History

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"log/slog"
"math"
"os"
"strings"
"sync"
"time"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
"spoolweight/claude"
"spoolweight/spooldb"
)
// weightBreakdown is the spool/filament/total split, in grams.
type weightBreakdown struct {
Spool *float64 `json:"spool,omitempty"`
Filament *float64 `json:"filament,omitempty"`
Total *float64 `json:"total,omitempty"`
}
type result struct {
Image string `json:"image"`
SpoolID string `json:"spool_id,omitempty"`
URL string `json:"url,omitempty"`
Location string `json:"location,omitempty"`
PreviousWeight *weightBreakdown `json:"previous_weight,omitempty"` // as read from spooldb
NewWeight *weightBreakdown `json:"new_weight,omitempty"` // from the measured photo
Unit string `json:"unit,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
VoteWeights []float64 `json:"vote_weights,omitempty"` // each LLM vote's total reading
ModelConfidences []string `json:"model_confidences,omitempty"` // each LLM vote's self-rating
Updated *bool `json:"updated,omitempty"` // set on success: did we write to spooldb
Error string `json:"error,omitempty"`
}
func ptr(v float64) *float64 { return &v }
// spoolURLPrefix is the only QR payload we accept. Anything else (a different
// domain, or a /f/ filament link rather than a /s/ spool link) is rejected.
const spoolURLPrefix = "https://spooldb.com/s/"
func main() {
verbose := flag.Bool("v", false, "verbose: log each event's start/end with timings to stderr")
dryRun := flag.Bool("n", false, "dry run: read and report, but do not write weight changes back to the site")
serveAddr := flag.String("serve", "", "run the mobile putaway web server at this address (e.g. :8080)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: %s [-v] [-n] [-serve addr] <image>...\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
level := slog.LevelInfo
if *verbose {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
auth, err := claude.ResolveAuth()
if err != nil {
fail("resolve credentials: %v", err)
}
slog.Debug("claude auth", "mechanism", auth.Name())
if *serveAddr != "" {
if err := serve(*serveAddr, auth, *dryRun); err != nil {
fail("serve: %v", err)
}
return
}
if flag.NArg() < 1 {
flag.Usage()
os.Exit(2)
}
results := make([]result, 0, flag.NArg())
for _, path := range flag.Args() {
results = append(results, processImage(path, auth, *dryRun))
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(results)
}
// spoolSession is a fresh, logged-in spooldb session used for a single image:
// it owns one headless browser, does the read and the maybe-write, then closes.
// Every site interaction is best-effort — failures are logged and the
// corresponding output fields are simply omitted.
type spoolSession struct {
client *spooldb.Client
}
// openSpoolSession launches a browser and logs into spooldb. It returns nil (and
// logs) if credentials are missing or login fails, so the caller can carry on
// with just the weight read.
func openSpoolSession() *spoolSession {
user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS")
if user == "" || pass == "" {
slog.Warn("spooldb: skipping (SPOOLDB_USER/SPOOLDB_PASS not set)")
return nil
}
c, err := spooldb.New()
if err != nil {
slog.Warn("spooldb: skipping (browser launch failed)", "err", err)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := c.Login(ctx, user, pass); err != nil {
c.Close()
slog.Warn("spooldb: skipping (login failed)", "err", err)
return nil
}
return &spoolSession{client: c}
}
// info reads the spool's current location and weights, or nil if unavailable.
func (s *spoolSession) info(spoolID string) *spooldb.SpoolInfo {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
info, err := s.client.SpoolInfo(ctx, spoolID)
if err != nil {
slog.Warn("spooldb: lookup failed", "spool", spoolID, "err", err)
return nil
}
return &info
}
// setTotal writes a new total (with-spool) weight back to the site.
func (s *spoolSession) setTotal(spoolID string, grams float64) bool {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := s.client.SetTotalWeight(ctx, spoolID, grams); err != nil {
slog.Warn("spooldb: weight update failed", "spool", spoolID, "err", err)
return false
}
return true
}
func (s *spoolSession) close() {
if s.client != nil {
s.client.Close()
}
}
// processImage reads one photo, capturing any failure in the result's Error
// field so a single bad image doesn't abort the whole batch.
func processImage(path string, auth claude.Auth, dryRun bool) result {
img, err := loadImage(path)
if err != nil {
return result{Image: path, Error: fmt.Sprintf("load image: %v", err)}
}
return processImg(img, path, auth, dryRun)
}
// processImg runs the QR + weight + spooldb logic on a decoded image. name is
// just a label for the output (a file path or upload filename).
func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) result {
defer trace("image " + name)()
r := result{Image: name}
url, err := decodeQR(img)
if err != nil {
r.Error = fmt.Sprintf("read QR code: %v", err)
return r
}
if !strings.HasPrefix(url, spoolURLPrefix) {
r.Error = fmt.Sprintf("not a spool QR code (expected %s...): %s", spoolURLPrefix, url)
return r
}
r.SpoolID = spoolID(url)
r.URL = url
// Open a fresh spooldb session and read the spool's current location/weight,
// concurrently with the (slower) weight read off the photo. The same session
// is reused for the maybe-write below, then closed.
var sess *spoolSession
var info *spooldb.SpoolInfo
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if sess = openSpoolSession(); sess != nil {
info = sess.info(r.SpoolID)
}
}()
reading, err := claude.ReadWeight(img, auth)
wg.Wait()
if sess != nil {
defer sess.close()
}
if info != nil {
r.Location = info.Location
r.PreviousWeight = &weightBreakdown{
Spool: ptr(info.EmptySpoolGrams),
Filament: ptr(info.RemainingGrams),
Total: ptr(info.TotalGrams),
}
}
if err != nil {
r.Error = fmt.Sprintf("read weight: %v", err)
return r
}
if reading.Unit == "lb" || reading.Unit == "oz" {
r.Error = fmt.Sprintf("scale is set to imperial units (%s); switch it to grams", reading.Unit)
return r
}
if reading.Weight < 0 {
r.Error = fmt.Sprintf("scale read a negative weight (%g g); refusing to update", reading.Weight)
return r
}
r.Unit = reading.Unit
r.Confidence = ptr(reading.Confidence)
r.VoteWeights = reading.Weights
r.ModelConfidences = reading.ModelConfidences
// The photo gives total weight (filament + spool). When the empty-spool
// weight is known (from spooldb) we can also report the filament split.
r.NewWeight = &weightBreakdown{Total: ptr(reading.Weight)}
if info != nil {
r.NewWeight.Spool = ptr(info.EmptySpoolGrams)
r.NewWeight.Filament = ptr(reading.Weight - info.EmptySpoolGrams)
if reading.Weight-info.EmptySpoolGrams < 0 {
r.Error = fmt.Sprintf("measured total %g g is below the empty-spool weight %g g (negative filament); refusing to update", reading.Weight, info.EmptySpoolGrams)
return r
}
}
// Write the measured total back when it differs from the site's total — the
// site recomputes remaining filament using the empty-spool weight.
updated := false
if info != nil && math.Abs(reading.Weight-info.TotalGrams) >= 1 {
newRemaining := reading.Weight - info.EmptySpoolGrams
if dryRun {
slog.Info("spooldb: would update", "spool", r.SpoolID,
"remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight)
} else if sess.setTotal(r.SpoolID, reading.Weight) {
updated = true
slog.Info("spooldb: updated", "spool", r.SpoolID,
"remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight)
}
}
r.Updated = &updated
return r
}
func fail(format string, a ...interface{}) {
slog.Error(fmt.Sprintf(format, a...))
os.Exit(1)
}
// trace logs the start of an operation and, via the returned func, its end and
// duration. It is a no-op above debug level, so it only appears with -v.
func trace(op string) func() {
t0 := time.Now()
slog.Debug("begin", "op", op)
return func() { slog.Debug("end", "op", op, "dur", time.Since(t0)) }
}
func loadImage(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
// spoolID returns the last non-empty path segment of the QR URL.
func spoolID(url string) string {
s := strings.TrimRight(url, "/")
if i := strings.LastIndex(s, "/"); i >= 0 {
return s[i+1:]
}
return s
}
func decodeQR(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return "", err
}
res, err := qrcode.NewQRCodeReader().Decode(bmp, map[gozxing.DecodeHintType]interface{}{
gozxing.DecodeHintType_TRY_HARDER: true,
})
if err != nil {
return "", err
}
return res.GetText(), nil
}