Skip to content

Instantly share code, notes, and snippets.

@korc
Created May 12, 2024 17:02
Show Gist options
  • Save korc/d0fe91f957aec486686ae4bae3cbefc4 to your computer and use it in GitHub Desktop.
Save korc/d0fe91f957aec486686ae4bae3cbefc4 to your computer and use it in GitHub Desktop.
web interface to sane
FROM python AS builder
RUN apt-get update && \
apt-get install --no-install-recommends -y python-dev-is-python3 libsane-dev && \
find /var/lib/apt/ /var/cache/apt -type f -delete
WORKDIR /src/
RUN pip wheel python-sane
FROM python
COPY --from=builder /src/python_sane*.whl .
RUN apt-get update && \
apt-get install --no-install-recommends -y libsane1 && \
find /var/lib/apt/ /var/cache/apt -type f -delete
RUN pip install flask pillow waitress python_sane*.whl
WORKDIR /app/
COPY *.py /app/bin/
COPY scan.html /app/static/
ENTRYPOINT [ "bin/entrypoint.py" ]
#!/usr/bin/env python3
import waitress, os, logging
from http_backend import app
sane_net_conf = "/etc/sane.d/net.conf"
cur_hosts = set()
if os.path.exists(sane_net_conf):
for line in open(sane_net_conf):
cur_hosts.add(line.strip())
for net_backend in os.environ.get("SANE_NET_HOSTS", "").split():
if not net_backend or net_backend in cur_hosts:
continue
with open(sane_net_conf, "a") as net_conf:
net_conf.write(f"{net_backend}\n")
logging.basicConfig(level=logging.INFO)
logging.info("starting server")
waitress.serve(app)
#!/usr/bin/env python3
import sane, PIL, logging, io, os, time, traceback, json
from flask import Flask, request, make_response, send_from_directory
from collections import namedtuple
sane.init()
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
opt_fields = (
"index",
"name",
"title",
"desc",
"type",
"unit",
"size",
"cap",
"constraint",
)
param_fields = ["format", "last_frame", "ppl", "depth", "bpl"]
devlist_fields = ["id", "vendor", "name", "type"]
save_dir = os.environ.get("SAVE_DIR", "/tmp")
@app.get("/")
def get_index():
logging.info("[%s] sending top page", request.remote_addr)
return send_from_directory("/app/static", "scan.html")
@app.get("/devices")
def get_devices():
logging.info("[%s] list devices", request.remote_addr)
return [dict(zip(devlist_fields, s)) for s in sane.get_devices()]
@app.get("/options/<devname>")
def get_options(devname):
logging.info("[%s] requesting device %r options", request.remote_addr, devname)
with sane.open(devname) as dev:
opts = [
dict(
zip(
opt_fields + ("value",),
opt
+ (
(lambda v: bool(v) if opt[4] == 0 else v)(
getattr(dev, opt[1], None)
),
),
)
)
for opt in dev.get_options()
]
return opts
def scan_to_file(img, filename):
full_path = os.path.join(save_dir, filename)
# img = dev.scan()
with open(full_path, "wb") as save_file:
img.save(save_file, "PNG")
@app.get("/debug/<host>/<int:port>")
def debug(host, port):
import debugpy
debugpy.listen((host, port))
debugpy.wait_for_client()
return "ok"
@app.get("/scan/<path:path>")
def get_scan(path):
return send_from_directory(save_dir, path)
def save_scan(img, filename):
info = dict(name=filename)
with open(os.path.join(save_dir, filename), "wb") as f:
img.save(f, "PNG")
info["size"] = os.fstat(f.fileno()).st_size
info["stamp"] = os.fstat(f.fileno()).st_mtime
return info
@app.delete("/scan/<path:path>")
def delete_scan(path):
os.unlink(os.path.join(save_dir, path))
return dict(ok=True)
@app.post("/scan/<devname>")
def post_scan(devname):
logging.info("[%s] scan from %r", request.remote_addr, devname)
opts = []
if request.json:
if "options" in request.json:
opts.extend(request.json["options"])
with sane.open(devname) as dev:
files = []
try:
for opt in opts:
setattr(dev, opt["name"], opt["value"])
auto_scan = getattr(dev, "auto scan", None)
scan_mode = getattr(dev, "ScanMode", None)
now = time.strftime("%y%m%d_%H%M%S")
logging.info("auto scan = %r", auto_scan)
if not auto_scan:
dev.start()
img = dev.snap(scan_mode == "Duplex")
if scan_mode == "Duplex":
files.append(save_scan(img, now + "-1.png"))
dev.start()
img = dev.snap()
files.append(save_scan(img, now + "-2.png"))
else:
files.append(save_scan(img, now + ".png"))
else:
idx = 0
while True:
try:
dev.start()
img = dev.snap(True)
except Exception as e:
logging.warn("multi-scan[idx=%d] exception %r", idx, e)
if idx > 0 and e.args[0] == "Document feeder out of documents":
break
raise
idx = idx + 1
files.append(save_scan(img, now + f"-{idx:04d}.png"))
return dict(files=files)
except Exception as e:
logging.warn("error scanning: %s: %r", type(e), e)
return make_response(dict(error=f"{e!s}"), 400)
@app.get("/parameters/<devname>")
def get_parameters(devname):
logging.info("[%s] requesting device %r parameters", request.remote_addr, devname)
with sane.open(devname) as dev:
params = dev.get_parameters()
return dict(zip(param_fields, params))
if __name__ == "__main__":
app.run(debug=True)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scanner</title>
<script type="importmap">
{
"imports": {
"vue": "https://cdn.jsdelivr.net/npm/vue@3.4.27/+esm"
}
}
</script>
<style>
.not-set {
font-style: italic;
color: #ccc;
}
#opts-table {
border-collapse: collapse;
border: 1px solid black;
}
#opts-table td {
border: 1px dashed #ccc;
}
#opts-table td.opt-title {
text-align: right;
}
#opts-table td.user-set {
font-weight: bold;
}
abbr.info {
vertical-align: super;
font-size: 0.67em;
}
#scan-image {
max-width: 100%;
border: 1px dashed gray;
}
</style>
</head>
<body>
<div id="app">
<div id="scannerlistArea" class="row">
<button @click="refreshScannerList" :disabled="busy">
{{ busy=='devices'?'Searching scanners..':'🗘' }}
</button>
<select
v-model="selectedScanner"
placeholder="Scanner"
:disabled="busy"
>
<option v-for="s in scannerList" :key="s.id" :value="s.id">
{{ s.vendor }} / {{ s.name }} / {{ s.type }}
</option>
</select>
<button :disabled="busy||!selectedScanner" @click="doScan">
{{busy==='scan'?'Scanning..':'Scan'}}
</button>
<label v-show="!!selectedScanner">
<input type="checkbox" v-model="showOptions" /> Opt.
</label>
</div>
<table id="opts-table" v-show="showOptions && !!selectedScanner">
<tbody v-show="scannerOptions.length">
<caption>
{{selectedScanner}} {{ busy==='options'?'(updating..)':''}}
<button :disabled="busy" @click="refreshOptions">🗘</button>
</caption>
<tr v-for="opt in scannerOptions" :key="opt.index">
<td class="opt-title" :class="{userSet:opt.userValue!==opt.value}">
{{opt.title}}
<abbr class="info" v-if="opt.desc!==opt.title" :title="opt.desc"
>?</abbr
>
</td>
<td>
<select
:disabled="busy"
v-model="opt.userValue"
@change="setOptPref(opt)"
v-if="(opt.type===1 || opt.type===3) && opt.constraint"
>
<option v-for="c in opt.constraint" :value="c">
{{c}} {{opt.value===c?'✅':''}}
{{optPref[opt.name]===c?'⭐':''}}
</option>
</select>
<span v-else-if="opt.value===null" class="not-set"
>(not set)</span
>
<input
v-else-if="opt.type===0"
:disabled="busy"
v-model="opt.userValue"
@change="setOptPref(opt)"
type="checkbox"
:checked="opt.value"
/>
<span v-else> {{ opt.value }} </span>
</td>
</tr>
</tbody>
</table>
<select
v-if="scannedImages.length"
v-model="scannedImage"
:disabled="busy"
>
<option value="">⬇Select image⬇</option>
<option v-for="si in scannedImages" :value="si.name" :key="si.name">
{{si.name}} ({{fmtSize(si.size)}})
</option>
</select>
<button v-if="scannedImage" @click="onDelete">🗑 Delete</button>
<br />
<img
id="scan-image"
v-if="scannedImage"
:src="`${apiPath}/scan/${scannedImage}`"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
/>
<div id="errorMessage" v-show="!!fetchError" class="error">
{{ `Error: ${fetchError?.message||fetchError?.err}` }}
<button @click="fetchError=null">☓</button>
</div>
</div>
<script type="module">
"use strict";
import { createApp, defineComponent, watch, ref } from "vue";
let apiPath = ".";
createApp({
setup() {
const scannerList = ref([]),
selectedScanner = ref(null),
busy = ref(false),
fetchError = ref(null);
const apiCall = (api, params, name, trResp) => {
if (!params) params = {};
if (!name) name = api;
if (!trResp) trResp = (r) => r.json();
busy.value = name;
fetchError.value = null;
return new Promise((resolve, reject) => {
fetch(`${apiPath}/${api}`, params)
.then((r) => (r.ok ? trResp(r) : Promise.reject(r)))
.then((json) => resolve(json))
.catch((err) => {
if (err instanceof Response) {
fetchError.value = {
err,
message: `Bad response: ${err.status} ${err.statusText}`,
};
if (err.headers.get("Content-Type") === "application/json")
err.json().then((json) => {
fetchError.value.json = json;
if (json.error) fetchError.value.message = json.error;
});
} else fetchError.value = { err, message: `${err}` };
reject(err);
})
.finally((res) => ((busy.value = false), res));
});
};
const scannerOptions = ref([]),
showOptions = ref(true),
optPref = ref(
Object.fromEntries(
Object.keys(localStorage)
.filter((k) => k.startsWith("sane_opt_"))
.map((k) => [
k.substring("sane_opt_".length),
JSON.parse(localStorage[k]),
])
)
);
const refreshOptions = () => {
apiCall(
"options/" + encodeURIComponent(selectedScanner.value),
{},
"options"
).then((v) => {
const pref = optPref.value;
scannerOptions.value = v.map((o) =>
Object.assign(o, {
userValue: o.name in pref ? pref[o.name] : o.value,
})
);
});
};
const refreshScannerList = () => {
return apiCall("devices")
.then((json) => {
scannerList.value = json;
if (json.length && !selectedScanner.value)
selectedScanner.value = json[0].id;
})
.then(refreshOptions);
};
refreshScannerList();
watch(selectedScanner, (v) => {
if (!v) {
v.forEach((o) => (o.userValue = o.value));
scannerOptions.value = [];
return;
}
refreshOptions();
});
const scannedImage = ref(null),
scannedImages = ref([]);
let touchStartX = 0;
return {
apiPath: ref(apiPath),
scannerList,
selectedScanner,
busy,
fetchError,
scannerOptions,
refreshScannerList,
refreshOptions,
showOptions,
optPref,
setOptPref(opt) {
optPref.value[opt.name] = opt.userValue;
localStorage[`sane_opt_${opt.name}`] = JSON.stringify(
opt.userValue
);
},
scannedImage,
scannedImages,
doScan() {
scannedImage.value = null;
showOptions.value = false;
apiCall(
"scan/" + encodeURIComponent(selectedScanner.value),
{
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
options: scannerOptions.value
.filter((o) => o.value !== o.userValue)
.map((o) => ({ name: o.name, value: o.userValue })),
}),
},
"scan"
)
.then((sr) => {
scannedImages.value = sr.files;
if (sr.files.length && !scannedImage.value)
scannedImage.value = sr.files[0].name;
})
.catch(console.warn.bind(null, "scan error"));
},
onDelete() {
const curName = scannedImage.value,
imgList = scannedImages.value;
if (!confirm(`Are you sure to delete ${curName}?`)) return;
apiCall("scan/" + encodeURIComponent(curName), {
method: "DELETE",
}).then(() => {
const idx = imgList.findIndex((i) => i.name === curName);
if (idx !== -1) imgList.splice(idx, 1);
if (!imgList.length) scannedImage.value = null;
else
scannedImage.value =
imgList[Math.min(imgList.length - 1, idx)].name;
});
},
onTouchStart(ev) {
console.log("touchStart", ev);
touchStartX = ev.touches[0]?.screenX;
},
onTouchEnd(ev) {
const evt = ev.changedTouches;
if (!evt || evt.length === 0 || evt.length > 1) return;
const images = scannedImages.value,
diff = touchStartX - evt[0].screenX;
let idx = images.findIndex((i) => scannedImage.value === i.name);
if (diff > 10) idx++;
else if (diff < 10) idx--;
if (idx >= images.length) idx = 0;
else if (idx < 0) idx = images.length - 1;
scannedImage.value = images[idx]?.name;
},
fmtSize: (bytes) =>
bytes >= 1024 * 1024 * 1024
? `${Math.round((10 * bytes) / 1024 / 1024 / 1024) / 10}g`
: bytes >= 1024 * 1024
? `${Math.round((10 * bytes) / 1024 / 1024) / 10}m`
: bytes >= 1024
? `${Math.round((10 * bytes) / 1024) / 10}k`
: `${bytes}b`,
};
},
}).mount("#app");
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment