Created
May 12, 2024 17:02
-
-
Save korc/d0fe91f957aec486686ae4bae3cbefc4 to your computer and use it in GitHub Desktop.
web interface to sane
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" ] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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/[email protected]/+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