2026-05-31 10:44:29 -07:00
package main
import (
2026-05-31 13:50:37 -07:00
"context"
2026-05-31 10:44:29 -07:00
"encoding/json"
"flag"
"fmt"
"image"
_ "image/jpeg"
2026-05-31 14:33:26 -07:00
_ "image/png"
"log/slog"
2026-05-31 14:19:34 -07:00
"math"
2026-05-31 10:44:29 -07:00
"os"
"strings"
"sync"
"time"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
2026-05-31 14:33:26 -07:00
"spoolweight/claude"
2026-05-31 13:50:37 -07:00
"spoolweight/spooldb"
2026-05-31 10:44:29 -07:00
)
2026-05-31 14:33:26 -07:00
// 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"`
}
2026-05-31 10:44:29 -07:00
type result struct {
2026-05-31 14:33:26 -07:00
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"`
2026-05-31 10:44:29 -07:00
}
2026-05-31 14:33:26 -07:00
func ptr ( v float64 ) * float64 { return & v }
2026-05-31 10:44:29 -07:00
// 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 () {
2026-05-31 14:33:26 -07:00
verbose := flag . Bool ( "v" , false , "verbose: log each event's start/end with timings to stderr" )
2026-05-31 14:19:34 -07:00
dryRun := flag . Bool ( "n" , false , "dry run: read and report, but do not write weight changes back to the site" )
2026-05-31 14:51:18 -07:00
serveAddr := flag . String ( "serve" , "" , "run the mobile putaway web server at this address (e.g. :8080)" )
2026-05-31 10:44:29 -07:00
flag . Usage = func () {
2026-05-31 14:51:18 -07:00
fmt . Fprintf ( os . Stderr , "usage: %s [-v] [-n] [-serve addr] <image>...\n" , os . Args [ 0 ])
2026-05-31 10:44:29 -07:00
flag . PrintDefaults ()
}
flag . Parse ()
2026-05-31 14:33:26 -07:00
level := slog . LevelInfo
if * verbose {
level = slog . LevelDebug
}
slog . SetDefault ( slog . New ( slog . NewTextHandler ( os . Stderr , & slog . HandlerOptions { Level : level })))
auth , err := claude . ResolveAuth ()
2026-05-31 10:44:29 -07:00
if err != nil {
fail ( "resolve credentials: %v" , err )
}
2026-05-31 14:33:26 -07:00
slog . Debug ( "claude auth" , "mechanism" , auth . Name ())
2026-05-31 10:44:29 -07:00
2026-05-31 14:51:18 -07:00
if * serveAddr != "" {
2026-05-31 19:33:48 -07:00
if err := serve ( * serveAddr , auth , * dryRun ); err != nil {
2026-05-31 14:51:18 -07:00
fail ( "serve: %v" , err )
}
return
}
if flag . NArg () < 1 {
flag . Usage ()
os . Exit ( 2 )
}
2026-05-31 10:44:29 -07:00
results := make ([] result , 0 , flag . NArg ())
for _ , path := range flag . Args () {
2026-05-31 19:33:48 -07:00
results = append ( results , processImage ( path , auth , * dryRun ))
2026-05-31 10:44:29 -07:00
}
enc := json . NewEncoder ( os . Stdout )
enc . SetIndent ( "" , " " )
enc . Encode ( results )
}
2026-05-31 19:33:48 -07:00
// 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 {
2026-05-31 13:50:37 -07:00
client * spooldb . Client
}
2026-05-31 19:33:48 -07:00
// 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 ()
2026-05-31 13:50:37 -07:00
if err != nil {
2026-05-31 19:33:48 -07:00
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 )
2026-05-31 14:19:34 -07:00
return nil
2026-05-31 13:50:37 -07:00
}
2026-05-31 19:33:48 -07:00
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 )
2026-05-31 13:50:37 -07:00
defer cancel ()
2026-05-31 19:33:48 -07:00
info , err := s . client . SpoolInfo ( ctx , spoolID )
2026-05-31 13:50:37 -07:00
if err != nil {
2026-05-31 14:33:26 -07:00
slog . Warn ( "spooldb: lookup failed" , "spool" , spoolID , "err" , err )
2026-05-31 14:19:34 -07:00
return nil
2026-05-31 13:50:37 -07:00
}
2026-05-31 14:19:34 -07:00
return & info
2026-05-31 13:50:37 -07:00
}
2026-05-31 14:19:34 -07:00
// setTotal writes a new total (with-spool) weight back to the site.
2026-05-31 19:33:48 -07:00
func ( s * spoolSession ) setTotal ( spoolID string , grams float64 ) bool {
2026-05-31 14:19:34 -07:00
ctx , cancel := context . WithTimeout ( context . Background (), 90 * time . Second )
defer cancel ()
2026-05-31 19:33:48 -07:00
if err := s . client . SetTotalWeight ( ctx , spoolID , grams ); err != nil {
2026-05-31 14:33:26 -07:00
slog . Warn ( "spooldb: weight update failed" , "spool" , spoolID , "err" , err )
2026-05-31 14:19:34 -07:00
return false
}
return true
}
2026-05-31 19:33:48 -07:00
func ( s * spoolSession ) close () {
2026-05-31 13:50:37 -07:00
if s . client != nil {
s . client . Close ()
}
}
2026-05-31 10:44:29 -07:00
// processImage reads one photo, capturing any failure in the result's Error
// field so a single bad image doesn't abort the whole batch.
2026-05-31 19:33:48 -07:00
func processImage ( path string , auth claude . Auth , dryRun bool ) result {
2026-05-31 10:44:29 -07:00
img , err := loadImage ( path )
if err != nil {
2026-05-31 14:51:18 -07:00
return result { Image : path , Error : fmt . Sprintf ( "load image: %v" , err )}
2026-05-31 10:44:29 -07:00
}
2026-05-31 19:33:48 -07:00
return processImg ( img , path , auth , dryRun )
2026-05-31 14:51:18 -07:00
}
// 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).
2026-05-31 19:33:48 -07:00
func processImg ( img image . Image , name string , auth claude . Auth , dryRun bool ) result {
2026-05-31 14:51:18 -07:00
defer trace ( "image " + name )()
r := result { Image : name }
2026-05-31 10:44:29 -07:00
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
2026-05-31 19:33:48 -07:00
// 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
2026-05-31 14:19:34 -07:00
var info * spooldb . SpoolInfo
2026-05-31 13:50:37 -07:00
var wg sync . WaitGroup
wg . Add ( 1 )
go func () {
defer wg . Done ()
2026-05-31 19:33:48 -07:00
if sess = openSpoolSession (); sess != nil {
info = sess . info ( r . SpoolID )
}
2026-05-31 13:50:37 -07:00
}()
2026-05-31 14:33:26 -07:00
reading , err := claude . ReadWeight ( img , auth )
2026-05-31 13:50:37 -07:00
wg . Wait ()
2026-05-31 19:33:48 -07:00
if sess != nil {
defer sess . close ()
}
2026-05-31 14:19:34 -07:00
if info != nil {
r . Location = info . Location
2026-05-31 14:33:26 -07:00
r . PreviousWeight = & weightBreakdown {
Spool : ptr ( info . EmptySpoolGrams ),
Filament : ptr ( info . RemainingGrams ),
Total : ptr ( info . TotalGrams ),
}
2026-05-31 14:19:34 -07:00
}
2026-05-31 13:50:37 -07:00
2026-05-31 10:44:29 -07:00
if err != nil {
r . Error = fmt . Sprintf ( "read weight: %v" , err )
return r
}
2026-05-31 14:33:26 -07:00
if reading . Unit == "lb" || reading . Unit == "oz" {
r . Error = fmt . Sprintf ( "scale is set to imperial units (%s); switch it to grams" , reading . Unit )
2026-05-31 10:44:29 -07:00
return r
}
2026-06-07 22:10:55 -07:00
if reading . Weight < 0 {
r . Error = fmt . Sprintf ( "scale read a negative weight (%g g); refusing to update" , reading . Weight )
return r
}
2026-05-31 14:33:26 -07:00
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 )
2026-06-07 22:10:55 -07:00
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
}
2026-05-31 14:33:26 -07:00
}
2026-05-31 14:19:34 -07:00
2026-05-31 14:33:26 -07:00
// 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
2026-05-31 14:19:34 -07:00
if dryRun {
2026-05-31 14:33:26 -07:00
slog . Info ( "spooldb: would update" , "spool" , r . SpoolID ,
"remaining_from" , info . RemainingGrams , "remaining_to" , newRemaining , "measured_total" , reading . Weight )
2026-05-31 19:33:48 -07:00
} else if sess . setTotal ( r . SpoolID , reading . Weight ) {
2026-05-31 14:33:26 -07:00
updated = true
slog . Info ( "spooldb: updated" , "spool" , r . SpoolID ,
"remaining_from" , info . RemainingGrams , "remaining_to" , newRemaining , "measured_total" , reading . Weight )
2026-05-31 14:19:34 -07:00
}
}
2026-05-31 14:33:26 -07:00
r . Updated = & updated
2026-05-31 10:44:29 -07:00
return r
}
func fail ( format string , a ... interface {}) {
2026-05-31 14:33:26 -07:00
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 )) }
2026-05-31 10:44:29 -07:00
}
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
}