add mobile putaway web server with camera/upload capture

This commit is contained in:
Ian Gulliver
2026-05-31 14:51:18 -07:00
parent e22d512be7
commit e70dce275c
3 changed files with 227 additions and 10 deletions
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0b0f17">
<title>Spool Putaway</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: #0b0f17; color: #e5e7eb; -webkit-text-size-adjust: 100%;
}
.wrap {
max-width: 560px; margin: 0 auto;
padding: max(16px, env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
min-height: 100dvh; display: flex; flex-direction: column; gap: 16px;
}
#result { flex: 1; display: flex; flex-direction: column; gap: 16px; justify-content: center; }
#buttons { display: flex; flex-direction: column; gap: 14px; }
.btn {
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; font-weight: 650; padding: 28px; border-radius: 18px;
border: none; background: #2563eb; color: #fff; cursor: pointer; width: 100%;
}
.btn.secondary { background: #1f2937; color: #e5e7eb; }
.btn:active { filter: brightness(.9); }
input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }
.busy { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.spinner { width: 64px; height: 64px; border: 6px solid #1f2937; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.busyText { color: #9ca3af; font-size: 1.1rem; }
.timer { color: #4b5563; font-variant-numeric: tabular-nums; }
.card { background: #111827; border: 1px solid #1f2937; border-radius: 18px; padding: 18px; }
.hero { text-align: center; padding: 24px 18px; }
.hero .label { color: #9ca3af; font-size: 1rem; letter-spacing: .03em; }
.hero .loc { font-size: 6rem; font-weight: 800; line-height: 1; margin: 6px 0; }
.hero.ok .loc { color: #34d399; }
.hero.warn .loc { color: #fbbf24; font-size: 2.4rem; }
.status { font-size: 1.25rem; font-weight: 650; }
.ok { color: #34d399; } .warn { color: #fbbf24; } .err { color: #f87171; } .muted { color: #9ca3af; }
.rows { margin-top: 10px; display: flex; justify-content: space-between; align-items: baseline; font-size: 1.05rem; }
.rows .k { color: #9ca3af; }
.rows .v { text-align: right; font-variant-numeric: tabular-nums; }
.big { font-size: 1.6rem; font-weight: 750; }
.arrow { color: #6b7280; }
.note { font-size: .95rem; color: #9ca3af; }
a { color: #60a5fa; }
.errcard { border-color: #7f1d1d; background: #1b1113; }
</style>
</head>
<body>
<div class="wrap">
<div id="result"></div>
<div id="buttons">
<label class="btn">Photo<input type="file" accept="image/*" id="photo"></label>
</div>
</div>
<script>
(function () {
const result = document.getElementById('result');
const buttons = document.getElementById('buttons');
const photo = document.getElementById('photo');
let tick = null;
function upload(file) {
buttons.style.display = 'none';
result.innerHTML = '<div class="busy"><div class="spinner"></div><div class="busyText">Processing…</div><div class="timer" id="timer">0s</div></div>';
photo.value = '';
const t0 = Date.now();
const timer = document.getElementById('timer');
tick = setInterval(() => { timer.textContent = Math.round((Date.now() - t0) / 1000) + 's'; }, 250);
const fd = new FormData();
fd.append('photo', file);
fetch('/process', { method: 'POST', body: fd })
.then(r => r.json())
.then(render)
.catch(() => render({ error: 'network error — try again' }))
.finally(() => { clearInterval(tick); buttons.style.display = 'flex'; });
}
photo.addEventListener('change', e => e.target.files[0] && upload(e.target.files[0]));
const g = n => Math.round(n) + ' g';
function render(res) {
if (res.error) {
result.innerHTML = '<div class="card errcard"><div class="status err">Couldn\'t process</div><p class="note">' + escapeHtml(res.error) + '</p></div>';
return;
}
const hasLoc = res.location && res.location.length;
const nw = res.new_weight || {}, pw = res.previous_weight || {};
let html = '<div class="card hero ' + (hasLoc ? 'ok' : 'warn') + '">' +
'<div class="label">Put it away at</div>' +
'<div class="loc">' + (hasLoc ? escapeHtml(res.location) : 'no location') + '</div></div>';
let status, rows = '';
if (res.previous_weight === undefined) {
status = '<div class="status warn">spooldb unavailable</div>';
if (nw.total != null) rows += row('On the scale', g(nw.total), true);
} else if (res.updated) {
status = '<div class="status ok">Updated</div>';
rows += '<div class="rows"><span class="k">Filament</span><span class="v big">' +
g(pw.filament) + ' <span class="arrow">→</span> ' + g(nw.filament) + '</span></div>';
} else {
status = '<div class="status muted">Already up to date</div>';
rows += row('Filament remaining', g(nw.filament), true);
}
if (nw.total != null) rows += row('On the scale', g(nw.total));
html += '<div class="card">' + status + rows + '</div>';
if (typeof res.confidence === 'number' && res.confidence < 0.6) {
html += '<div class="card"><div class="status warn">Low confidence</div>' +
'<p class="note">Double-check the scale. Votes: ' + (res.vote_weights || []).join(', ') + ' g</p></div>';
}
if (res.url) html += '<p class="note" style="text-align:center"><a href="' + res.url + '" target="_blank">' + escapeHtml(res.spool_id || 'spool') + '</a></p>';
result.innerHTML = html;
}
function row(k, v, big) {
return '<div class="rows"><span class="k">' + k + '</span><span class="v ' + (big ? 'big' : '') + '">' + v + '</span></div>';
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
})();
</script>
</body>
</html>
+22 -10
View File
@@ -52,15 +52,12 @@ const spoolURLPrefix = "https://spooldb.com/s/"
func main() { func main() {
verbose := flag.Bool("v", false, "verbose: log each event's start/end with timings to stderr") 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") 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() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: %s [-v] [-n] <image>...\n", os.Args[0]) fmt.Fprintf(os.Stderr, "usage: %s [-v] [-n] [-serve addr] <image>...\n", os.Args[0])
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
os.Exit(2)
}
level := slog.LevelInfo level := slog.LevelInfo
if *verbose { if *verbose {
@@ -77,6 +74,17 @@ func main() {
sp := &spoolSync{} sp := &spoolSync{}
defer sp.close() defer sp.close()
if *serveAddr != "" {
if err := serve(*serveAddr, auth, sp, *dryRun); err != nil {
fail("serve: %v", err)
}
return
}
if flag.NArg() < 1 {
flag.Usage()
os.Exit(2)
}
results := make([]result, 0, flag.NArg()) results := make([]result, 0, flag.NArg())
for _, path := range flag.Args() { for _, path := range flag.Args() {
results = append(results, processImage(path, auth, sp, *dryRun)) results = append(results, processImage(path, auth, sp, *dryRun))
@@ -163,14 +171,18 @@ func (s *spoolSync) close() {
// processImage reads one photo, capturing any failure in the result's Error // processImage reads one photo, capturing any failure in the result's Error
// field so a single bad image doesn't abort the whole batch. // field so a single bad image doesn't abort the whole batch.
func processImage(path string, auth claude.Auth, sp *spoolSync, dryRun bool) result { func processImage(path string, auth claude.Auth, sp *spoolSync, dryRun bool) result {
defer trace("image " + path)()
r := result{Image: path}
img, err := loadImage(path) img, err := loadImage(path)
if err != nil { if err != nil {
r.Error = fmt.Sprintf("load image: %v", err) return result{Image: path, Error: fmt.Sprintf("load image: %v", err)}
return r
} }
return processImg(img, path, auth, sp, 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, sp *spoolSync, dryRun bool) result {
defer trace("image " + name)()
r := result{Image: name}
url, err := decodeQR(img) url, err := decodeQR(img)
if err != nil { if err != nil {
+69
View File
@@ -0,0 +1,69 @@
package main
import (
_ "embed"
"encoding/json"
"image"
"log/slog"
"net/http"
"sync"
"time"
"spoolweight/claude"
)
//go:embed index.html
var indexHTML []byte
// serve runs the mobile putaway web server. Requests are serialized because the
// shared spooldb browser session is single-threaded.
func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error {
var mu sync.Mutex
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
})
mux.HandleFunc("POST /process", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := r.ParseMultipartForm(64 << 20); err != nil {
writeJSON(w, result{Error: "upload too large or malformed"})
return
}
file, hdr, err := r.FormFile("photo")
if err != nil {
writeJSON(w, result{Error: "no photo in request"})
return
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
writeJSON(w, result{Image: hdr.Filename, Error: "could not read image (try choosing from your library)"})
return
}
start := time.Now()
mu.Lock()
res := processImg(img, hdr.Filename, auth, sp, dryRun)
mu.Unlock()
slog.Info("processed upload", "file", hdr.Filename, "spool", res.SpoolID, "dur", time.Since(start))
writeJSON(w, res)
})
slog.Info("putaway server listening", "addr", addr, "dry_run", dryRun)
return http.ListenAndServe(addr, mux)
}
func writeJSON(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}