add mobile putaway web server with camera/upload capture
This commit is contained in:
+136
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user