feat: Linux bulk importer for the Allflame Voyage Solver
Mirrors the solver's Windows-only voyage-import.ahk on KDE Wayland: - poe-clip-bridge.py: clipboard watcher that keeps only PoE item text, serves it on loopback and can trigger a sweep from the browser - voyage-sweep.py: hovers every cell of the in-game chart grid, Ctrl+C's it and hands over the whole batch in one go - userscript: feeds the batch into the solver page without focusing it Includes notes on the four Wayland pointer pitfalls this ran into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clipboard bridge for the Allflame Voyage Solver.
|
||||
|
||||
Watches the Wayland clipboard, keeps ONLY Path of Exile item text in memory
|
||||
and serves it on http://127.0.0.1:8477/clip for a Violentmonkey userscript.
|
||||
Also triggers the grid sweep, so a Firefox bookmark can start it.
|
||||
|
||||
Security: anything that does not look like PoE item text (passwords from
|
||||
KeePassXC, tokens, ...) is discarded immediately and never stored or served.
|
||||
Nothing is written to disk. The socket is bound to loopback only.
|
||||
Endpoints that act (sweep/pause/resume) require the token from
|
||||
~/.config/poe-clip-bridge/token - otherwise any web page could start a sweep
|
||||
that takes over mouse and keyboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8477
|
||||
ORIGIN = "https://one-more-map.github.io"
|
||||
TOKEN_FILE = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token"
|
||||
SWEEP = str(pathlib.Path.home() / ".local" / "bin" / "voyage-sweep.py")
|
||||
|
||||
# Only clipboard content matching this ever leaves the watcher.
|
||||
ITEM_RE = re.compile(
|
||||
r"^[ \t]*(?:Item Class|Rarity)\s*[::]|===\s*VOYAGE BORDER",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
state = {"seq": 0, "text": ""}
|
||||
paused = False
|
||||
sweep_proc = None
|
||||
sweep_log = deque(maxlen=40)
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
if TOKEN_FILE.exists():
|
||||
return TOKEN_FILE.read_text().strip()
|
||||
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_urlsafe(24)
|
||||
TOKEN_FILE.write_text(token + "\n")
|
||||
TOKEN_FILE.chmod(0o600)
|
||||
return token
|
||||
|
||||
|
||||
TOKEN = load_token()
|
||||
|
||||
|
||||
def publish(text: str) -> None:
|
||||
"""Store item text; called by the watcher and once again on resume."""
|
||||
if not ITEM_RE.search(text):
|
||||
return
|
||||
with lock:
|
||||
if text != state["text"]:
|
||||
state["seq"] += 1
|
||||
state["text"] = text
|
||||
print(f"[bridge] item #{state['seq']} ({len(text)} chars)", flush=True)
|
||||
|
||||
|
||||
def watch() -> None:
|
||||
"""Stream clipboard changes as NUL-delimited records from wl-paste."""
|
||||
proc = subprocess.Popen(
|
||||
["wl-paste", "--watch", "sh", "-c", "cat; printf '\\0'"],
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = proc.stdout.read1(4096) # read1: do not block for a full buffer
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\0" in buf:
|
||||
raw, buf = buf.split(b"\0", 1)
|
||||
if paused: # during a sweep every single copy would import separately
|
||||
continue
|
||||
publish(raw.decode("utf-8", "replace").strip())
|
||||
|
||||
|
||||
def read_clipboard() -> str:
|
||||
r = subprocess.run(["wl-paste", "--no-newline", "--type", "text/plain"],
|
||||
capture_output=True, text=True)
|
||||
return r.stdout.strip() if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def run_sweep() -> None:
|
||||
"""Run the grid sweep, capturing its output for the status page."""
|
||||
global sweep_proc, paused
|
||||
paused = True
|
||||
sweep_log.clear()
|
||||
sweep_log.append("sweep started")
|
||||
try:
|
||||
sweep_proc = subprocess.Popen(
|
||||
["python3", "-u", SWEEP, "run", "--delay", "4"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
for line in sweep_proc.stdout:
|
||||
for part in line.replace("\r", "\n").splitlines():
|
||||
if part.strip():
|
||||
sweep_log.append(part.strip())
|
||||
sweep_proc.wait()
|
||||
sweep_log.append(f"sweep finished (exit {sweep_proc.returncode})")
|
||||
except Exception as exc: # noqa: BLE001 - surfaced on the status page
|
||||
sweep_log.append(f"sweep failed: {exc}")
|
||||
finally:
|
||||
sweep_proc = None
|
||||
paused = False
|
||||
publish(read_clipboard()) # the batch the sweep just put there
|
||||
|
||||
|
||||
STATUS_PAGE = """<!doctype html><meta charset=utf-8>
|
||||
<title>Voyage sweep</title>
|
||||
<style>body{font:14px/1.5 monospace;background:#151515;color:#ddd;margin:2rem}
|
||||
h1{font-size:1rem;color:#8f8}pre{white-space:pre-wrap}</style>
|
||||
<h1>Voyage sweep</h1><pre id=log>...</pre>
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get('token');
|
||||
setInterval(async () => {
|
||||
const r = await fetch('/log?token=' + encodeURIComponent(t));
|
||||
const d = await r.json();
|
||||
document.getElementById('log').textContent =
|
||||
d.lines.join('\\n') + (d.running ? '\\n\\n(laeuft...)' : '\\n\\n(fertig)');
|
||||
}, 1000);
|
||||
</script>"""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _send(self, body, ctype="application/json", code=200):
|
||||
data = body.encode() if isinstance(body, str) else body
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", ORIGIN)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authed(self, query):
|
||||
return secrets.compare_digest(query.get("token", [""])[0], TOKEN)
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
path = parsed.path
|
||||
|
||||
if path == "/clip":
|
||||
with lock:
|
||||
self._send(json.dumps(state))
|
||||
return
|
||||
|
||||
if path in ("/sweep", "/log", "/pause", "/resume"):
|
||||
if not self._authed(query):
|
||||
self._send('{"error":"bad token"}', code=403)
|
||||
return
|
||||
|
||||
global paused
|
||||
if path == "/sweep":
|
||||
if sweep_proc is None:
|
||||
threading.Thread(target=run_sweep, daemon=True).start()
|
||||
self._send(STATUS_PAGE, ctype="text/html; charset=utf-8")
|
||||
elif path == "/log":
|
||||
self._send(json.dumps({"running": sweep_proc is not None,
|
||||
"lines": list(sweep_log)}))
|
||||
elif path == "/pause":
|
||||
paused = True
|
||||
self._send('{"paused":true}')
|
||||
elif path == "/resume":
|
||||
paused = False
|
||||
publish(read_clipboard())
|
||||
self._send('{"paused":false}')
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
print(f"[bridge] listening on http://{HOST}:{PORT}/clip", flush=True)
|
||||
print(f"[bridge] sweep bookmark: http://{HOST}:{PORT}/sweep?token={TOKEN}",
|
||||
flush=True)
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Print the personal bookmarklet that triggers a grid sweep from the browser.
|
||||
set -euo pipefail
|
||||
token_file="${XDG_CONFIG_HOME:-$HOME/.config}/poe-clip-bridge/token"
|
||||
if [[ ! -f $token_file ]]; then
|
||||
echo "No token yet - start the bridge once: systemctl --user start poe-clip-bridge.service" >&2
|
||||
exit 1
|
||||
fi
|
||||
token=$(cat "$token_file")
|
||||
echo "Add this as a bookmark (Ctrl+Shift+O -> new bookmark, paste into URL):"
|
||||
echo
|
||||
echo "javascript:fetch('http://127.0.0.1:8477/sweep?token=${token}');void 0"
|
||||
echo
|
||||
echo "Status page (opens a tab instead of staying on the solver page):"
|
||||
echo "http://127.0.0.1:8477/sweep?token=${token}"
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bulk-import PoE Charts into the Allflame Voyage Solver (Linux/KDE Wayland).
|
||||
|
||||
Linux counterpart of the site's voyage-import.ahk: hovers every cell of the
|
||||
in-game chart grid, Ctrl+C's it, collects the item texts and puts the whole
|
||||
batch into the clipboard, where poe-clip-bridge + the Violentmonkey userscript
|
||||
pick it up and paste it into the solver.
|
||||
|
||||
voyage-sweep.py calibrate # capture the grid corners
|
||||
voyage-sweep.py run # do the sweep
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
CONFIG = pathlib.Path.home() / ".config" / "voyage-sweep.json"
|
||||
DEFAULTS = {
|
||||
"cols": 6,
|
||||
"rows": 10,
|
||||
"tl": None, # [x, y] centre of the top-left cell
|
||||
"br": None, # [x, y] centre of the bottom-right cell
|
||||
"hover_delay": 0.14, # let the tooltip build before Ctrl+C
|
||||
"clip_timeout": 0.6, # how long to wait for clipboard content
|
||||
"poe_window": "Path of Exile",
|
||||
}
|
||||
CHART_RE = re.compile(r"^[ \t]*Item Class\s*[::]", re.IGNORECASE | re.MULTILINE)
|
||||
MAX_STEP = 150 # larger relative jumps get distorted by pointer acceleration
|
||||
ENV = {**os.environ, "YDOTOOL_SOCKET": f"/run/user/{os.getuid()}/.ydotool_socket"}
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
return subprocess.run(cmd, env=ENV, capture_output=True, text=True, **kw)
|
||||
|
||||
|
||||
def load_config():
|
||||
cfg = dict(DEFAULTS)
|
||||
if CONFIG.exists():
|
||||
cfg.update(json.loads(CONFIG.read_text()))
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg):
|
||||
CONFIG.write_text(json.dumps(cfg, indent=2) + "\n")
|
||||
|
||||
|
||||
# ---------- pointer ----------
|
||||
|
||||
def cursor():
|
||||
"""Pointer position. Reliable while the pointer is over an XWayland window."""
|
||||
out = run(["xdotool", "getmouselocation", "--shell"]).stdout
|
||||
pos = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l)
|
||||
return int(pos["X"]), int(pos["Y"])
|
||||
|
||||
|
||||
def cursor_kwin():
|
||||
"""Pointer position straight from KWin. Slow (~0.3s) but always correct,
|
||||
also while the pointer sits on a Wayland-native window where X sees nothing."""
|
||||
js = pathlib.Path(f"/tmp/voyage-probe-{os.getpid()}.js")
|
||||
js.write_text('print("VOYAGEPROBE " + workspace.cursorPos.x + ","'
|
||||
' + workspace.cursorPos.y);\n')
|
||||
name = f"voyageprobe{os.getpid()}"
|
||||
since = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
sid = run(["qdbus6", "org.kde.KWin", "/Scripting",
|
||||
"org.kde.kwin.Scripting.loadScript", str(js), name]).stdout.strip()
|
||||
run(["qdbus6", "org.kde.KWin", f"/Scripting/Script{sid}",
|
||||
"org.kde.kwin.Script.run"])
|
||||
time.sleep(0.25)
|
||||
log = run(["journalctl", "_COMM=kwin_wayland", "--since", since,
|
||||
"--no-pager", "-o", "cat"]).stdout
|
||||
run(["qdbus6", "org.kde.KWin", "/Scripting",
|
||||
"org.kde.kwin.Scripting.unloadScript", name])
|
||||
js.unlink(missing_ok=True)
|
||||
hits = re.findall(r"VOYAGEPROBE (\d+),(\d+)", log)
|
||||
if not hits:
|
||||
raise RuntimeError("KWin cursor probe returned nothing")
|
||||
return int(hits[-1][0]), int(hits[-1][1])
|
||||
|
||||
|
||||
# Injected motion is not applied 1:1 - this display multiplies it (measured 2.0).
|
||||
# The factor is learned from the actual movement instead of being hardcoded.
|
||||
_gain = 1.0
|
||||
|
||||
|
||||
def move_to(x, y, tries=40, coarse=False, tol=1):
|
||||
"""Closed loop: step, re-read, correct, and learn the motion gain."""
|
||||
global _gain
|
||||
read = cursor_kwin if coarse else cursor
|
||||
for _ in range(tries):
|
||||
cx, cy = read()
|
||||
dx, dy = x - cx, y - cy
|
||||
if abs(dx) <= tol and abs(dy) <= tol:
|
||||
return True
|
||||
sx = max(-MAX_STEP, min(MAX_STEP, round(dx / _gain)))
|
||||
sy = max(-MAX_STEP, min(MAX_STEP, round(dy / _gain)))
|
||||
run(["ydotool", "mousemove", "-x", str(sx), "-y", str(sy)])
|
||||
time.sleep(0.03)
|
||||
nx, ny = read()
|
||||
for sent, moved in ((sx, nx - cx), (sy, ny - cy)):
|
||||
if abs(sent) >= 20 and abs(moved) >= 1:
|
||||
_gain = max(0.2, min(8.0, 0.7 * _gain + 0.3 * (moved / sent)))
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def ctrl_c():
|
||||
run(["ydotool", "key", "29:1", "46:1", "46:0", "29:0"]) # LEFTCTRL + C
|
||||
|
||||
|
||||
# ---------- clipboard ----------
|
||||
|
||||
def clip_arm(token):
|
||||
"""Put a unique marker in the clipboard.
|
||||
|
||||
'wl-copy --clear' is asynchronous: a poll right after it still reads the
|
||||
previous owner's data, which made every cell report the first chart again.
|
||||
Waiting for the marker to disappear is race-free.
|
||||
"""
|
||||
subprocess.run(["wl-copy"], input=token, text=True, env=ENV)
|
||||
|
||||
|
||||
def clip_read():
|
||||
r = run(["wl-paste", "--no-newline", "--type", "text/plain"])
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def clip_wait(timeout, token):
|
||||
"""Wait until the game replaced our marker with the copied item text."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
text = clip_read().strip()
|
||||
if text and text != token:
|
||||
return text
|
||||
time.sleep(0.03)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------- grid ----------
|
||||
|
||||
def poe_geometry(cfg):
|
||||
"""Window origin+size of the PoE window (an XWayland client, so X knows it)."""
|
||||
wid = run(["xdotool", "search", "--name", cfg["poe_window"]]).stdout.split()
|
||||
if not wid:
|
||||
sys.exit(f"No window named '{cfg['poe_window']}' found - is PoE running?")
|
||||
out = run(["xdotool", "getwindowgeometry", "--shell", wid[-1]]).stdout
|
||||
g = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l)
|
||||
return int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])
|
||||
|
||||
|
||||
def cell_pos(cfg, row, col, origin):
|
||||
"""Grid coordinates are stored relative to the window, so moving the
|
||||
PoE window does not invalidate the calibration."""
|
||||
tlx, tly = cfg["tl"]
|
||||
brx, bry = cfg["br"]
|
||||
dx = (brx - tlx) / (cfg["cols"] - 1) if cfg["cols"] > 1 else 0
|
||||
dy = (bry - tly) / (cfg["rows"] - 1) if cfg["rows"] > 1 else 0
|
||||
return round(origin[0] + tlx + col * dx), round(origin[1] + tly + row * dy)
|
||||
|
||||
|
||||
def bridge(action):
|
||||
"""Pause/resume the clipboard bridge over its local HTTP API.
|
||||
|
||||
Not 'systemctl stop': when the sweep is started by the bridge itself
|
||||
(Firefox bookmark), stopping the unit would kill this process too.
|
||||
"""
|
||||
token_file = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token"
|
||||
if not token_file.exists():
|
||||
return
|
||||
token = urllib.parse.quote(token_file.read_text().strip())
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
f"http://127.0.0.1:8477/{action}?token={token}", timeout=2).read()
|
||||
except OSError:
|
||||
pass # bridge not running - sweep still works, just no auto-import
|
||||
|
||||
|
||||
def countdown(msg, seconds=5):
|
||||
print(msg)
|
||||
for i in range(seconds, 0, -1):
|
||||
print(f" {i} ...", end="\r", flush=True)
|
||||
time.sleep(1)
|
||||
print(" " * 20, end="\r")
|
||||
|
||||
|
||||
# ---------- commands ----------
|
||||
|
||||
def cmd_calibrate(cfg, args):
|
||||
cfg["cols"], cfg["rows"] = args.cols, args.rows
|
||||
ox, oy = poe_geometry(cfg)[:2]
|
||||
print(f"Grid: {cfg['cols']} columns x {cfg['rows']} rows. Window at {ox},{oy}.")
|
||||
countdown("\nPut the mouse on the CENTRE of the TOP-LEFT cell.")
|
||||
cx, cy = cursor()
|
||||
cfg["tl"] = [cx - ox, cy - oy]
|
||||
print(f" top-left = {cfg['tl']} (window-relative)")
|
||||
countdown("\nNow the CENTRE of the BOTTOM-RIGHT cell (even if empty).")
|
||||
cx, cy = cursor()
|
||||
cfg["br"] = [cx - ox, cy - oy]
|
||||
print(f" bottom-right = {cfg['br']} (window-relative)")
|
||||
if cfg["tl"] == cfg["br"]:
|
||||
sys.exit("Both corners are identical - nothing saved.")
|
||||
save_config(cfg)
|
||||
print(f"\nSaved to {CONFIG}. Cell spacing: "
|
||||
f"{(cfg['br'][0] - cfg['tl'][0]) / max(1, cfg['cols'] - 1):.1f} x "
|
||||
f"{(cfg['br'][1] - cfg['tl'][1]) / max(1, cfg['rows'] - 1):.1f} px")
|
||||
|
||||
|
||||
def preflight():
|
||||
sock = pathlib.Path(ENV["YDOTOOL_SOCKET"])
|
||||
if not sock.exists():
|
||||
sys.exit("ydotoold is not running (no %s).\nStart it with: "
|
||||
"systemctl --user start ydotool.service" % sock)
|
||||
|
||||
|
||||
def cmd_run(cfg, args):
|
||||
if not cfg["tl"] or not cfg["br"]:
|
||||
sys.exit("Not calibrated yet - run: voyage-sweep.py calibrate")
|
||||
preflight()
|
||||
rows = args.rows or cfg["rows"]
|
||||
|
||||
ox, oy, ww, wh = poe_geometry(cfg)
|
||||
subprocess.run(["kdotool", "search", "--name", cfg["poe_window"],
|
||||
"windowactivate"], capture_output=True)
|
||||
countdown("\nSweeping - keep hands off mouse and keyboard.", args.delay)
|
||||
|
||||
# The fast X11 pointer read only works while the pointer is over the game,
|
||||
# so get it in there first using KWin's own (slower) cursor position.
|
||||
if not move_to(ox + ww // 2, oy + wh // 2, coarse=True, tol=25):
|
||||
sys.exit("Could not move the pointer into the PoE window.")
|
||||
|
||||
bridge("pause") # otherwise every single copy gets imported separately
|
||||
charts, skipped, first, identical = [], 0, None, True
|
||||
try:
|
||||
for row in range(rows):
|
||||
for col in range(cfg["cols"]):
|
||||
x, y = cell_pos(cfg, row, col, (ox, oy))
|
||||
token = f"voyage-sweep-{row}-{col}-{time.monotonic_ns()}"
|
||||
clip_arm(token)
|
||||
if not move_to(x, y):
|
||||
print(f" cell {row + 1}/{col + 1}: pointer did not reach {x},{y}")
|
||||
skipped += 1
|
||||
continue
|
||||
time.sleep(cfg["hover_delay"])
|
||||
ctrl_c()
|
||||
text = clip_wait(cfg["clip_timeout"], token)
|
||||
if not text or not CHART_RE.search(text):
|
||||
skipped += 1
|
||||
continue
|
||||
charts.append(text)
|
||||
if first is None:
|
||||
first = text
|
||||
elif text != first:
|
||||
identical = False
|
||||
print(f" charts {len(charts)} skipped {skipped}", end="\r", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborted.")
|
||||
finally:
|
||||
bridge("resume")
|
||||
|
||||
print(" " * 40, end="\r")
|
||||
if len(charts) >= 5 and identical:
|
||||
sys.exit("\nEvery cell copied the SAME chart - calibration is off "
|
||||
"(or the PoE window moved). Re-run: voyage-sweep.py calibrate")
|
||||
if not charts:
|
||||
sys.exit(f"\nNothing copied ({skipped} empty/non-chart cells).")
|
||||
|
||||
blob = "\n".join(charts)
|
||||
subprocess.run(["wl-copy"], input=blob, text=True)
|
||||
print(f"\n{len(charts)} charts copied, {skipped} cells empty/non-chart.")
|
||||
print("Batch is in the clipboard - the userscript imports it into the solver.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
cal = sub.add_parser("calibrate", help="capture the grid corners")
|
||||
cal.add_argument("--cols", type=int, default=DEFAULTS["cols"])
|
||||
cal.add_argument("--rows", type=int, default=DEFAULTS["rows"])
|
||||
|
||||
sweep = sub.add_parser("run", help="sweep the grid and import")
|
||||
sweep.add_argument("--delay", type=int, default=5,
|
||||
help="seconds before the sweep starts (default 5)")
|
||||
sweep.add_argument("--rows", type=int, help="sweep only the first N rows")
|
||||
|
||||
args = ap.parse_args()
|
||||
cfg = load_config()
|
||||
{"calibrate": cmd_calibrate, "run": cmd_run}[args.cmd](cfg, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user